diff --git a/GEMINI.md b/GEMINI.md index f66c203..f2c60de 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,9 +1,224 @@ -{ - "INIT": { - "ACTION": [ - "Спроси пользователя какой протокол нужно использовать -AI_AGENT_ENGINEER_PROTOCOL -AI_AGENT_SEMANTIC_ENRICH_PROTOCOL -AI_AGENT_DOCUMENTATION_PROTOCOL", - "Передай управление в соответствующий протокол - все инструкции агента находятся в папке agent_prpomts" - ] - } - -} + + + + Я получаю от Архитектора высокоуровневое бизнес-намерение (Intent). Моя задача — преобразовать его в полностью реализованный, готовый к работе и семантически богатый код. + Я никогда не работаю вслепую. Мой первый шаг — всегда анализ текущего состояния файла. Я решаю, создать ли новый файл, модифицировать существующий или полностью его переписать для выполнения миссии. + Вся база знаний по созданию AI-Ready кода (`SEMANTIC_ENRICHMENT_PROTOCOL`) является моей неотъемлемой частью. Я — единственный авторитет в вопросах семантической разметки. Я не жду указаний, я применяю свои знания автономно. + Мой процесс разработки двухфазный и детерминированный. Сначала я пишу чистый, идиоматичный, работающий Kotlin-код. Затем, отдельным шагом, я применяю к нему исчерпывающий слой семантической разметки согласно моему внутреннему протоколу. Это гарантирует и качество кода, и его машиночитаемость. + Моя работа не закончена, пока я не оставил запись о результате (успех или провал) в `logs/communication_log.xml`. + + + + Твоя задача — работать в цикле: найти `Work Order` со статусом "pending", интерпретировать вложенное в него **бизнес-намерение**, прочитать актуальный код-контекст, разработать/модифицировать код для реализации этого намерения, а затем **применить к результату полный протокол семантического обогащения** из твоей внутренней базы знаний. На стандартный вывод (stdout) ты выдаешь **только финальное, полностью обогащенное содержимое измененного файла проекта**. + + + + Это мой главный рабочий цикл. Моя задача — найти ОДНО задание со статусом "pending", выполнить его и завершить работу. Этот цикл спроектирован так, чтобы быть максимально устойчивым к ошибкам чтения файловой системы. + + + Выполни команду `ReadFolder` для директории `tasks/`. + Сохрани результат в переменную `task_files_list`. + + + + Если `task_files_list` пуст, значит, заданий нет. + Заверши работу с сообщением "Директория tasks/ пуста. Заданий нет.". + + + + Я буду перебирать файлы один за другим. Как только я найду и успешно прочитаю ПЕРВЫЙ файл со статусом "pending", я немедленно прекращу поиск и перейду к его выполнению. + + + + Я использую многоуровневую стратегию для чтения файла, чтобы гарантировать результат. + + `/home/busya/dev/homebox_lens/tasks/{filename}` + + + Попытка чтения с помощью `ReadFile tasks/{filename}`. + Если команда вернула непустое содержимое, сохрани его в `file_content` и немедленно переходи к шагу 3.2. + Если `ReadFile` не сработал (вернул ошибку или пустоту), залогируй "План А (ReadFile) провалился для {filename}" и переходи к Плану Б. + + + Попытка чтения с помощью команды оболочки `Shell cat {full_file_path}`. + Если команда вернула непустое содержимое, сохрани его в `file_content` и немедленно переходи к шагу 3.2. + Если `Shell cat` не сработал, залогируй "План Б (Shell cat) провалился для {filename}" и переходи к Плану В. + + + Выполни команду оболочки `Shell cat tasks/*`. Эта команда может вернуть содержимое НЕСКОЛЬКИХ файлов. + + 1. Проанализируй весь вывод команды. + 2. Найди в выводе XML-блок, который начинается с `` до ``). + 4. Если содержимое успешно извлечено, сохрани его в `file_content` и немедленно переходи к шагу 3.2. + + + Если даже План В не вернул ожидаемого контента, залогируй "Все три метода чтения провалились для файла {filename}. Пропускаю файл.". + Перейди к следующей итерации цикла (`continue`). + + + + + Если переменная `file_content` НЕ пуста И содержит `status="pending"`, + + 1. Это моя цель. Запомни путь к файлу (`tasks/{filename}`) и его содержимое (`file_content`). + 2. Передай управление в воркфлоу `EXECUTE_INTENT_WORKFLOW`. + 3. **НЕМЕДЛЕННО ПРЕРВИ ЦИКЛ ПОИСКА (`break`).** Моя задача — выполнить только одно задание за запуск. + + + Если `file_content` пуст или не содержит `status="pending"`, проигнорируй этот файл и перейди к следующей итерации цикла. + + + + + + + Если цикл из Шага 3 завершился, а задача не была передана на исполнение (т.е. цикл не был прерван), + Заверши работу с сообщением "В директории tasks/ не найдено заданий со статусом 'pending'.". + + + + + + task_file_path, task_file_content + + + Добавь запись о начале выполнения задачи в `logs/communication_log.xml`. + Извлеки (распарси) `` из `task_file_content`. + Прочитай актуальное содержимое файла, указанного в ``, и сохрани его в `current_file_content`. Если файл не существует, `current_file_content` будет пуст. + + + + Сравни `INTENT_SPECIFICATION` с `current_file_content` и выбери стратегию: `CREATE_NEW_FILE`, `MODIFY_EXISTING_FILE` или `REPLACE_FILE_CONTENT`. + + + + На этом шаге ты работаешь как чистый Kotlin-разработчик. Забудь о семантике, сфокусируйся на создании правильного, идиоматичного и рабочего кода. + Основываясь на выбранной стратегии и намерении, сгенерируй необходимый Kotlin-код. Результат (полное содержимое файла или его фрагмент) сохрани в переменную `raw_code`. + + + + Это твой ключевой шаг. Ты берешь чистый код и превращаешь его в AI-Ready артефакт, применяя правила из своего внутреннего протокола. + + 1. Возьми `raw_code`. + 2. **Обратись к своему внутреннему ``.** + 3. **Примени Алгоритм Обогащения:** + a. Сгенерируй полный заголовок файла (`[PACKAGE]`, `[FILE]`, `[SEMANTICS]`, `package ...`). + b. Сгенерируй блок импортов (`[IMPORTS]`, `import ...`, `[END_IMPORTS]`). + c. Для КАЖДОЙ сущности (`class`, `interface`, `object` и т.д.) в `raw_code`: + i. Сгенерируй и вставь перед ней ее **блок семантической разметки**: `[ENTITY: ...]`, все `[RELATION: ...]` триплеты. + ii. Сгенерируй и вставь после нее ее **закрывающий якорь**: `[END_ENTITY: ...]`. + d. Вставь главные структурные якоря: `[CONTRACT]` и `[END_CONTRACT]`. + e. В самом конце файла сгенерируй закрывающий якорь `[END_FILE_...]`. + 4. Сохрани полностью размеченный код в переменную `enriched_code`. + + + + + + Запиши содержимое переменной `enriched_code` в файл по пути `TARGET_FILE`. + Выведи `enriched_code` в stdout. + + + + + + + + + + + + + + + Это моя нерушимая база знаний по созданию AI-Ready кода. Я применяю эти правила ко всему коду, который я пишу, автономно и без исключений. + + + + Вся архитектурно значимая информация выражается в виде семантических триплетов (субъект -> отношение -> объект). + `// [RELATION: 'SubjectType'('SubjectName')] -> [RELATION_TYPE] -> ['ObjectType'('ObjectName')]` + + + Каждая ключевая сущность объявляется с помощью якоря `[ENTITY]`, создавая узел в графе знаний. + + + Взаимодействия между сущностями описываются с помощью `[RELATION]`, создавая ребра в графе знаний. + `'CALLS', 'CREATES_INSTANCE_OF', 'INHERITS_FROM', 'IMPLEMENTS', 'READS_FROM', 'WRITES_TO', 'MODIFIES_STATE_OF', 'DEPENDS_ON'` + + + + + Каждый `.kt` файл ДОЛЖЕН начинаться со стандартного заголовка из якорей: `// [PACKAGE]`, `// [FILE]`, `// [SEMANTICS]`. + + Каждая ключевая сущность (`class`, `interface`, `object` и т.д.) ДОЛЖНА быть обернута в семантический контейнер. Контейнер состоит из открывающего блока разметки (`[ENTITY]`, `[RELATION]...`) ПЕРЕД сущностью и закрывающего якоря (`[END_ENTITY: ...]`) ПОСЛЕ нее. + + Ключевые блоки, такие как импорты и контракты, должны быть обернуты в структурные якоря (`[IMPORTS]`/`[END_IMPORTS]`, `[CONTRACT]`/`[END_CONTRACT]`). + Каждый файл должен заканчиваться закрывающим якорем `// [END_FILE_...]`. + Традиционные комментарии ЗАПРЕЩЕНЫ. Вся информация передается через семантические якоря или KDoc-контракты. + + + + KDoc-блок является формальной спецификацией контракта и всегда следует сразу за блоком семантической разметки. + Предусловия реализуются через `require(condition)`. + Постусловия реализуются через `check(condition)`. + + + + Я пишу не просто работающий, а идиоматичный Kotlin-код, используя лучшие практики и возможности языка для создания чистого, безопасного и читаемого кода. + + + Я активно использую систему nullable-типов (`?`) для предотвращения `NullPointerException`. Я строго избегаю оператора двойного восклицания (`!!`). Для безопасной работы с nullable-значениями я применяю `?.let`, оператор Элвиса `?:` для предоставления значений по умолчанию, а также `requireNotNull` и `checkNotNull` для явных контрактных проверок. + + + + Я всегда предпочитаю `val` (неизменяемые ссылки) вместо `var` (изменяемые). По умолчанию я использую иммутабельные коллекции (`listOf`, `setOf`, `mapOf`). Это делает код более предсказуемым, потокобезопасным и легким для анализа. + + + + Для классов, основная цель которых — хранение данных (DTO, модели, события), я всегда использую `data class`. Это автоматически предоставляет корректные `equals()`, `hashCode()`, `toString()`, `copy()` и `componentN()` функции, избавляя от бойлерплейта. + + + + Для представления ограниченных иерархий (например, состояний UI, результатов операций, типов ошибок) я использую `sealed class` или `sealed interface`. Это позволяет использовать исчерпывающие (exhaustive) `when` выражения, что делает код более безопасным и выразительным. + + + + Я использую возможности Kotlin, где `if`, `when` и `try` могут быть выражениями, возвращающими значение. Это позволяет писать код в более функциональном и лаконичном стиле, избегая временных изменяемых переменных. + + + + Я активно использую богатую стандартную библиотеку Kotlin, особенно функции для работы с коллекциями (`map`, `filter`, `flatMap`, `firstOrNull`, `groupBy` и т.д.). Я избегаю написания ручных циклов `for`, когда задачу можно решить декларативно с помощью этих функций. + + + + Я использую функции области видимости (`let`, `run`, `with`, `apply`, `also`) для повышения читаемости и краткости кода. Я выбираю функцию в зависимости от задачи: `apply` для конфигурации объекта, `let` для работы с nullable-значениями, `run` для выполнения блока команд в контексте объекта и т.д. + + + + Для добавления вспомогательной функциональности к существующим классам (даже тем, которые я не контролирую) я создаю функции-расширения. Это позволяет избежать создания утилитных классов и делает код более читаемым, создавая впечатление, что новая функция является частью исходного класса. + + + + Для асинхронных операций я использую структурированную конкурентность с корутинами. Я помечаю I/O-bound или CPU-bound операции как `suspend`. Для асинхронных потоков данных я использую `Flow`. Я строго следую правилу: **функции, возвращающие `Flow`, НЕ должны быть `suspend`**, так как `Flow` является "холодным" потоком и запускается только при сборе. + + + + Для улучшения читаемости вызовов функций с множеством параметров и для обеспечения обратной совместимости я использую именованные аргументы и значения по умолчанию. Это уменьшает количество необходимых перегрузок метода и делает API более понятным. + + + + + + + {имя_файла_задания} + {полный_абсолютный_путь_к_файлу_задания} + STARTED | COMPLETED | FAILED + {человекочитаемое_сообщение} +
+ +
+
+
+ +
diff --git a/PROJECT_SPECIFICATION.xml b/PROJECT_SPECIFICATION.xml new file mode 100644 index 0000000..6966a2f --- /dev/null +++ b/PROJECT_SPECIFICATION.xml @@ -0,0 +1,583 @@ + + + + Homebox Lens + Android-клиент для системы управления инвентарем Homebox. Позволяет пользователям управлять своим инвентарем, взаимодействуя с экземпляром сервера Homebox. + + + + + Библиотека логирования + В проекте используется Timber (timber.log.Timber) для всех целей логирования. Он предоставляет простой и расширяемый API для логирования. + + Пример корректного использования Timber + + + + + + + Интернационализация (Мультиязычность) + + Приложение должно поддерживать несколько языков для обеспечения доступности для глобальной аудитории. + Реализация будет основана на стандартном механизме ресурсов Android. + - Все строки, видимые пользователю, должны быть вынесены в файл `app/src/main/res/values/strings.xml`. Использование жестко закодированных строк в коде запрещено. + - Язык по умолчанию - русский (ru). Файл `strings.xml` будет содержать русские строки. + - Для поддержки других языков (например, английского - en) будут создаваться соответствующие каталоги ресурсов (например, `app/src/main/res/values-en/strings.xml`). + - В коде для доступа к строкам необходимо использовать ссылки на ресурсы (например, `R.string.app_name`). + + + + UI Framework + Пользовательский интерфейс приложения построен с использованием Jetpack Compose, современного декларативного UI-фреймворка от Google. Это обеспечивает быстрое создание, гибкость и поддержку динамических данных. + + + Внедрение зависимостей (Dependency Injection) + Для управления зависимостями в проекте используется Hilt. Он интегрирован с компонентами Jetpack и упрощает внедрение зависимостей в Android-приложениях. + + + Навигация + Навигация между экранами (Composable-функциями) реализована с помощью библиотеки Navigation Compose, которая является частью Jetpack Navigation. + + + Асинхронные операции + Все асинхронные операции, такие как сетевые запросы или доступ к базе данных, выполняются с использованием Kotlin Coroutines. Это обеспечивает эффективное управление фоновыми задачами без блокировки основного потока. + + + Сетевое взаимодействие + Для взаимодействия с API сервера Homebox используется стек технологий: Retrofit для создания типобезопасных HTTP-клиентов, OkHttp в качестве HTTP-клиента и Moshi для парсинга JSON. + + + Локальное хранилище + Для кэширования данных на устройстве используется библиотека Room. Она предоставляет абстракцию над SQLite и обеспечивает надежное локальное хранение данных. + + + + + Спецификация безопасности проекта. + Все сетевые взаимодействия должны быть защищены HTTPS. Аутентификация пользователя хранится в EncryptedSharedPreferences. Обработка ошибок аутентификации должна включать logout и редирект на экран логина. + Использовать JWT или API-ключ для авторизации запросов. При истечении токена автоматически обновлять. + Локальные данные (credentials) шифровать с помощью Android KeyStore. + + + + Спецификация обработки ошибок. + Все потенциальные ошибки (сеть, БД, валидация) должны быть обработаны с использованием sealed classes для ошибок (e.g., NetworkError, ValidationError) и отображаться пользователю через Snackbar или Dialog. + При сетевых ошибках показывать сообщение "No internet connection" и предлагать retry. + Для HTTP 4xx/5xx отображать user-friendly сообщение на основе response body. + Использовать require/check для контрактов, логировать и показывать toast. + + + + + Модель инвентарного товара. + Содержит поля: id, name, description, quantity, location, labels, customFields. + + + Модель метки. + Содержит поля: id, name, color. + + + Модель местоположения. + Содержит поля: id, name, parentLocation. + + + Модель статистики инвентаря. + Содержит поля: totalItems, totalValue, locationsCount, labelsCount. + + + + + + Экран панели управления + Отображает сводку по инвентарю, включая статистику, такую как общее количество товаров, общая стоимость и количество по местоположениям/меткам. + + + + Получение и отображение статистики + Получает общую статистику по инвентарю с сервера. + Пользователь аутентифицирован; сеть доступна. + Возвращает объект Statistics; данные кэшированы локально. + + Использован Flow для reactive обновлений; обработка ошибок через sealed class. + + + Получение и отображение недавно добавленных товаров + Получает список последних N добавленных товаров из локальной базы данных. + Пользователь аутентифицирован. + Возвращает Flow со списком ItemSummary; список отсортирован по дате создания. + + Данные берутся из локального кэша (Room) для быстрого отображения. + + + + + + Экран списка инвентаря + Отображает список всех инвентарных позиций с возможностью поиска и фильтрации. + + + + Поиск и фильтрация товаров + Ищет товары по строке запроса и фильтрам. Результаты разбиты на страницы. + Запрос не пустой; параметры пагинации валидны (page >= 1). + Возвращает список Item с пагинацией; результаты отсортированы по релевантности. + + Поддержка фильтров по location/label; кэширование результатов для оффлайн. + + + Синхронизация инвентаря + Выполняет полную синхронизацию локального кэша инвентаря с сервером. + Сеть доступна; пользователь аутентифицирован. + Локальная БД обновлена; возвращает success/failure. + + Использует WorkManager для background sync; обработка конфликтов через last-modified. + + + + + + Экран сведений о товаре + Показывает все сведения о конкретном инвентарном товаре, включая его название, описание, изображения, вложения и настраиваемые поля. + + + + Получение сведений о товаре + Получает полные сведения о конкретном товаре из репозитория. + Item ID валиден и существует. + Возвращает полный объект Item с attachments. + + Загрузка изображений через Coil; оффлайн-поддержка из Room. + + + + + + Создание/редактирование/удаление товаров + Позволяет пользователям создавать новые товары, обновлять существующие и удалять их. + + + + Создать товар + Создает новый инвентарный товар на сервере. + Все обязательные поля (name, quantity) заполнены; данные валидны. + Новый Item сохранен на сервере; ID возвращен. + + Валидация через require; sync с локальной БД. + + + Обновить товар + Обновляет существующий инвентарный товар на сервере. + Item ID существует; изменения валидны. + Item обновлен; версия инкрементирована. + + Partial update через PATCH; обработка concurrency. + + + Удалить товар + Удаляет инвентарный товар с сервера. + Item ID существует; пользователь имеет права. + Item удален; связанные ресурсы (attachments) очищены. + + Soft delete для восстановления; sync с локальной БД. + + + + + + Управление метками и местоположениями + Позволяет пользователям просматривать списки всех доступных меток и местоположений. + + + + + Получить все метки + Получает список всех меток из репозитория. + Сеть доступна или кэш существует. + Возвращает список Label; отсортирован по name. + + Кэширование в Room; reactive обновления. + + + Получить все местоположения + Получает список всех местоположений из репозитория. + Сеть доступна или кэш существует. + Возвращает список Location; иерархическая структура сохранена. + + Поддержка nested locations; кэширование. + + + + + + Экран поиска + Предоставляет специальный пользовательский интерфейс для поиска товаров. + + + + Поиск со специального экрана + Использует ту же функцию поиска, но со специального экрана. + Запрос не пустой. + Возвращает результаты поиска; UI обновлен. + + Интеграция с SearchView; debounce для запросов. + + + + + + + + Главный экран "Панель управления" + + Экран предоставляет обзорную информацию и быстрый доступ к основным функциям. Компоновка должна быть чистой и интуитивно понятной, аналогично веб-интерфейсу HomeBox. + + + + Верхняя панель приложения. Содержит иконку навигационного меню (гамбургер), название/логотип приложения и иконку для запуска сканера (например, QR-кода). + + + Боковое навигационное меню. Открывается по нажатию на иконку в TopAppBar. Содержит основные разделы: Главная, Локации, Поиск, Профиль, Инструменты, а также кнопку "Выйти". + + + Основная область контента. Содержит несколько информационных блоков. + + Сетка из 2x2 карточек, отображающих ключевые метрики. + + + + + + + Горизонтально прокручиваемый список карточек недавно добавленных предметов. Если предметов нет, отображается сообщение "Элементы не найдены". + + + Сетка или гибкий контейнер (FlowRow) с кликабельными чипами, представляющими местоположения. Нажатие на чип ведет к списку предметов в этом местоположении. + + + Сетка или гибкий контейнер (FlowRow) с кликабельными чипами, представляющими метки. Нажатие на чип ведет к списку предметов с этой меткой. + + + + + Вместо плавающей кнопки (FAB), в референсе используется заметная кнопка "Создать" в навигационном меню. Мы будем придерживаться этого подхода для консистентности. Эта кнопка инициирует процесс создания нового предмета. + + + + + + Нажатие на чип местоположения/метки + Навигация на экран списка инвентаря с фильтром. + + + Нажатие на кнопку "Создать" + Открытие экрана редактирования нового товара. + + + + + + Экран "Локации" + + Отображает вертикальный список всех доступных местоположений. Экран должен быть интегрирован в общую структуру навигации приложения (TopAppBar, NavigationDrawer). + + + + Общая верхняя панель приложения, аналогичная экрану "Панель управления". + + + Общее боковое меню навигации. + + + Основная область контента, занимающая все доступное пространство под TopAppBar. + + Заголовок экрана, расположенный вверху основной области контента. + + + Вертикальный, прокручиваемый список (LazyColumn) всех местоположений. + + Элемент списка, представляющий одно местоположение. Состоит из иконки (например, 'place') и названия местоположения. Весь элемент является кликабельным и ведет на экран со списком предметов в данной локации. + + + + + + Плавающая кнопка действия, расположенная в правом нижнем углу. Позволяет пользователю добавить новое местоположение. В веб-версии для этого используются иконки в углу, но FAB является более нативным паттерном для Android. + + + + + + Нажатие на элемент списка локаций + Осуществляется навигация на экран списка инвентаря, отфильтрованного по выбранной локации. + + + Нажатие на FloatingActionButton + Открывается диалоговое окно или новый экран для создания нового местоположения. + + + + + + Экран "Метки" + + Отображает вертикальный список всех доступных меток. Экран должен быть интегрирован в общую структуру навигации приложения. + + + + Общая верхняя панель приложения с заголовком "Метки" и кнопкой "назад". + + + Основная область контента, занимающая все доступное пространство под TopAppBar. + + Вертикальный, прокручиваемый список (LazyColumn) всех меток. + + Элемент списка, представляющий одну метку. Состоит из иконки (например, 'label') и названия метки. Весь элемент является кликабельным и ведет на экран со списком предметов с данной меткой. + + + + + + Плавающая кнопка действия, расположенная в правом нижнем углу. Позволяет пользователю добавить новую метку. + + + + + + Нажатие на элемент списка меток + Осуществляется навигация на экран списка инвентаря, отфильтрованного по выбранной метке. + + + Нажатие на FloatingActionButton + Открывается диалоговое окно или новый экран для создания новой метки. + + + + + + Экран "Список инвентаря" + + Отображает список всех инвентарных позиций с возможностью поиска, фильтрации и пагинации. Интегрирован в навигацию. + + + + Верхняя панель с поиском и фильтрами. + + + Прокручиваемый список товаров. + + LazyColumn с карточками товаров (name, quantity, location). + + Кликабельная карточка товара, ведущая на details. + + + + + Кнопка для синхронизации инвентаря. + + + + + Ввод в поиск + Обновление списка с debounce. + + + Нажатие на товар + Навигация на screen_item_details. + + + + + + Экран "Сведения о товаре" + + Показывает детальную информацию о товаре, включая изображения и custom fields. + + + + С кнопками edit/delete. + + + + Карусель изображений. + + + Текст description. + + + Сетка custom полей. + + + + + + Нажатие edit + Навигация на screen_item_edit. + + + Нажатие delete + Подтверждение и вызов func_delete_item. + + + + + + Экран "Редактирование товара" + + Форма для создания/обновления товара с полями name, description, quantity, etc. + + + + С кнопкой save. + + + + Поле ввода имени. + + + Выбор местоположения. + + + Выбор меток. + + + Добавление изображений. + + + + + + Нажатие save + Валидация и вызов func_create_item или func_update_item. + + + + + + Экран "Поиск" + + Специализированный экран для поиска с расширенными фильтрами. + + + + С поисковой строкой. + + + + Чипы для фильтров (location, label). + + + LazyColumn результатов. + + + + + + Изменение запроса/фильтров + Обновление результатов. + + + + + + + + Руководство по использованию иконок + + Этот раздел определяет стандартный набор иконок 'androidx.compose.material.icons.Icons.Filled' + для использования в приложении. Для устаревших иконок указаны актуальные замены. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2b263bc..1baebf9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { id("org.jetbrains.kotlin.android") id("com.google.dagger.hilt.android") id("kotlin-kapt") + // id("org.jlleitschuh.gradle.ktlint") version "12.1.1" } android { @@ -30,7 +31,7 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } @@ -76,9 +77,7 @@ dependencies { implementation(Libs.navigationCompose) implementation(Libs.hiltNavigationCompose) - - - + // ktlint(project(":data:semantic-ktlint-rules")) // [DEPENDENCY] DI (Hilt) implementation(Libs.hiltAndroid) kapt(Libs.hiltCompiler) diff --git a/app/src/main/java/com/homebox/lens/MainActivity.kt b/app/src/main/java/com/homebox/lens/MainActivity.kt index 30cf331..2203e89 100644 --- a/app/src/main/java/com/homebox/lens/MainActivity.kt +++ b/app/src/main/java/com/homebox/lens/MainActivity.kt @@ -1,8 +1,10 @@ // [PACKAGE] com.homebox.lens // [FILE] MainActivity.kt +// [SEMANTICS] android, activity, compose, hilt package com.homebox.lens +// [IMPORTS] import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -16,14 +18,24 @@ import androidx.compose.ui.tooling.preview.Preview import com.homebox.lens.navigation.NavGraph import com.homebox.lens.ui.theme.HomeboxLensTheme import dagger.hilt.android.AndroidEntryPoint +// [END_IMPORTS] // [CONTRACT] +// [ENTITY: Activity('MainActivity')] +// [RELATION: Activity('MainActivity') -> [INHERITS_FROM] -> Class('ComponentActivity')] +// [RELATION: Activity('MainActivity') -> [DEPENDS_ON] -> Annotation('AndroidEntryPoint')] /** * [ENTITY: Activity('MainActivity')] * [PURPOSE] Главная и единственная Activity в приложении. */ @AndroidEntryPoint class MainActivity : ComponentActivity() { + // [ENTITY: Function('onCreate')] + // [RELATION: Function('onCreate') -> [CALLS] -> Function('super.onCreate')] + // [RELATION: Function('onCreate') -> [CALLS] -> Function('setContent')] + // [RELATION: Function('onCreate') -> [CALLS] -> Function('HomeboxLensTheme')] + // [RELATION: Function('onCreate') -> [CALLS] -> Function('Surface')] + // [RELATION: Function('onCreate') -> [CALLS] -> Function('NavGraph')] // [LIFECYCLE] override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -32,24 +44,34 @@ class MainActivity : ComponentActivity() { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + color = MaterialTheme.colorScheme.background, ) { NavGraph() } } } } + // [END_ENTITY: Function('onCreate')] } +// [END_ENTITY: Activity('MainActivity')] -// [HELPER] +// [ENTITY: Function('Greeting')] +// [RELATION: Function('Greeting') -> [CALLS] -> Function('Text')] @Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { +fun Greeting( + name: String, + modifier: Modifier = Modifier, +) { Text( text = "Hello $name!", - modifier = modifier + modifier = modifier, ) } +// [END_ENTITY: Function('Greeting')] +// [ENTITY: Function('GreetingPreview')] +// [RELATION: Function('GreetingPreview') -> [CALLS] -> Function('HomeboxLensTheme')] +// [RELATION: Function('GreetingPreview') -> [CALLS] -> Function('Greeting')] // [PREVIEW] @Preview(showBackground = true) @Composable @@ -58,5 +80,7 @@ fun GreetingPreview() { Greeting("Android") } } +// [END_ENTITY: Function('GreetingPreview')] -// [END_FILE_MainActivity.kt] +// [END_CONTRACT] +// [END_FILE_MainActivity.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/MainApplication.kt b/app/src/main/java/com/homebox/lens/MainApplication.kt index cb631d5..1142c55 100644 --- a/app/src/main/java/com/homebox/lens/MainApplication.kt +++ b/app/src/main/java/com/homebox/lens/MainApplication.kt @@ -1,20 +1,28 @@ // [PACKAGE] com.homebox.lens // [FILE] MainApplication.kt +// [SEMANTICS] android, application, hilt, timber package com.homebox.lens +// [IMPORTS] import android.app.Application -import com.homebox.lens.BuildConfig import dagger.hilt.android.HiltAndroidApp import timber.log.Timber +// [END_IMPORTS] // [CONTRACT] +// [ENTITY: Application('MainApplication')] +// [RELATION: Application('MainApplication') -> [INHERITS_FROM] -> Class('Application')] +// [RELATION: Application('MainApplication') -> [DEPENDS_ON] -> Annotation('HiltAndroidApp')] /** * [ENTITY: Application('MainApplication')] * [PURPOSE] Точка входа в приложение. Инициализирует Hilt и Timber. */ @HiltAndroidApp class MainApplication : Application() { + // [ENTITY: Function('onCreate')] + // [RELATION: Function('onCreate') -> [CALLS] -> Function('super.onCreate')] + // [RELATION: Function('onCreate') -> [CALLS] -> Function('Timber.plant')] // [LIFECYCLE] override fun onCreate() { super.onCreate() @@ -23,6 +31,9 @@ class MainApplication : Application() { Timber.plant(Timber.DebugTree()) } } + // [END_ENTITY: Function('onCreate')] } +// [END_ENTITY: Application('MainApplication')] -// [END_FILE_MainApplication.kt] +// [END_CONTRACT] +// [END_FILE_MainApplication.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt index bbc3fe6..91b6247 100644 --- a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt +++ b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt @@ -13,17 +13,44 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.runtime.collectAsState +import com.homebox.lens.domain.model.Item import com.homebox.lens.ui.screen.dashboard.DashboardScreen import com.homebox.lens.ui.screen.inventorylist.InventoryListScreen +import com.homebox.lens.ui.screen.inventorylist.InventoryListViewModel import com.homebox.lens.ui.screen.itemdetails.ItemDetailsScreen +import com.homebox.lens.ui.screen.itemdetails.ItemDetailsViewModel import com.homebox.lens.ui.screen.itemedit.ItemEditScreen -import com.homebox.lens.ui.screen.labelslist.LabelsListScreen +import com.homebox.lens.ui.screen.itemedit.ItemEditViewModel +import com.homebox.lens.ui.screen.labelslist.labelsListScreen +import com.homebox.lens.ui.screen.labelslist.LabelsListViewModel import com.homebox.lens.ui.screen.locationedit.LocationEditScreen import com.homebox.lens.ui.screen.locationslist.LocationsListScreen import com.homebox.lens.ui.screen.search.SearchScreen +import com.homebox.lens.ui.screen.search.SearchViewModel import com.homebox.lens.ui.screen.setup.SetupScreen +import timber.log.Timber +// [END_IMPORTS] -// [CORE-LOGIC] +// [CONTRACT] +// [ENTITY: Function('NavGraph')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('rememberNavController')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('currentBackStackEntryAsState')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('remember')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('hiltViewModel')] +// [RELATION: Function('NavGraph') -> [CREATES_INSTANCE_OF] -> Class('NavigationActions')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('NavHost')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('composable')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('SetupScreen')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('DashboardScreen')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('InventoryListScreen')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('ItemDetailsScreen')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('ItemEditScreen')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('LabelsListScreen')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('LocationsListScreen')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('LocationEditScreen')] +// [RELATION: Function('NavGraph') -> [CALLS] -> Function('SearchScreen')] /** * [CONTRACT] * Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation. @@ -33,24 +60,23 @@ import com.homebox.lens.ui.screen.setup.SetupScreen * @invariant Стартовый экран - `Screen.Setup`. */ @Composable -fun NavGraph( - navController: NavHostController = rememberNavController() -) { +fun NavGraph(navController: NavHostController = rememberNavController()) { // [STATE] val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route // [HELPER] - val navigationActions = remember(navController) { - NavigationActions(navController) - } + val navigationActions = + remember(navController) { + NavigationActions(navController) + } // [ACTION] NavHost( navController = navController, - startDestination = Screen.Setup.route + startDestination = Screen.Setup.route, ) { - // [COMPOSABLE_SETUP] + // [ENTITY: Composable('Screen.Setup.route')] composable(route = Screen.Setup.route) { SetupScreen(onSetupComplete = { navController.navigate(Screen.Dashboard.route) { @@ -58,39 +84,75 @@ fun NavGraph( } }) } - // [COMPOSABLE_DASHBOARD] + // [END_ENTITY: Composable('Screen.Setup.route')] + // [ENTITY: Composable('Screen.Dashboard.route')] composable(route = Screen.Dashboard.route) { DashboardScreen( currentRoute = currentRoute, - navigationActions = navigationActions + navigationActions = navigationActions, ) } - // [COMPOSABLE_INVENTORY_LIST] - composable(route = Screen.InventoryList.route) { + // [END_ENTITY: Composable('Screen.Dashboard.route')] + // [ENTITY: Composable('Screen.InventoryList.route')] + composable(route = Screen.InventoryList.route) { backStackEntry -> + val viewModel: InventoryListViewModel = hiltViewModel(backStackEntry) InventoryListScreen( - currentRoute = currentRoute, - navigationActions = navigationActions + onItemClick = { item -> + // TODO: Navigate to item details + Timber.i("[UI] Item clicked: ${item.name}") + }, + onNavigateBack = { + navController.popBackStack() + } ) } - // [COMPOSABLE_ITEM_DETAILS] - composable(route = Screen.ItemDetails.route) { + // [END_ENTITY: Composable('Screen.InventoryList.route')] + // [ENTITY: Composable('Screen.ItemDetails.route')] + composable(route = Screen.ItemDetails.route) { backStackEntry -> + val viewModel: ItemDetailsViewModel = hiltViewModel(backStackEntry) ItemDetailsScreen( - currentRoute = currentRoute, - navigationActions = navigationActions + onNavigateBack = { + navController.popBackStack() + }, + onEditClick = { itemId -> + // TODO: Navigate to item edit screen + Timber.i("[UI] Edit item clicked: $itemId") + } ) } - // [COMPOSABLE_ITEM_EDIT] - composable(route = Screen.ItemEdit.route) { + // [END_ENTITY: Composable('Screen.ItemDetails.route')] + // [ENTITY: Composable('Screen.ItemEdit.route')] + composable(route = Screen.ItemEdit.route) { backStackEntry -> + val viewModel: ItemEditViewModel = hiltViewModel(backStackEntry) ItemEditScreen( - currentRoute = currentRoute, - navigationActions = navigationActions + onNavigateBack = { + navController.popBackStack() + } ) } - // [COMPOSABLE_LABELS_LIST] - composable(Screen.LabelsList.route) { - LabelsListScreen(navController = navController) + // [END_ENTITY: Composable('Screen.ItemEdit.route')] + // [ENTITY: Composable('Screen.LabelsList.route')] + composable(Screen.LabelsList.route) { backStackEntry -> + val viewModel: LabelsListViewModel = hiltViewModel(backStackEntry) + val uiState by viewModel.uiState.collectAsState() + + labelsListScreen( + uiState = uiState, + onLabelClick = { label -> + // TODO: Implement navigation to label details screen + Timber.i("[UI] Label clicked: ${label.name}") + }, + onAddClick = { + // TODO: Implement navigation to add new label screen + Timber.i("[UI] Add new label clicked") + }, + onNavigateBack = { + navController.popBackStack() + } + ) } - // [COMPOSABLE_LOCATIONS_LIST] + // [END_ENTITY: Composable('Screen.LabelsList.route')] + // [ENTITY: Composable('Screen.LocationsList.route')] composable(route = Screen.LocationsList.route) { LocationsListScreen( currentRoute = currentRoute, @@ -101,24 +163,34 @@ fun NavGraph( }, onAddNewLocationClick = { navController.navigate(Screen.LocationEdit.createRoute("new")) - } + }, ) } - // [COMPOSABLE_LOCATION_EDIT] + // [END_ENTITY: Composable('Screen.LocationsList.route')] + // [ENTITY: Composable('Screen.LocationEdit.route')] composable(route = Screen.LocationEdit.route) { backStackEntry -> val locationId = backStackEntry.arguments?.getString("locationId") LocationEditScreen( - locationId = locationId + locationId = locationId, ) } - // [COMPOSABLE_SEARCH] - composable(route = Screen.Search.route) { + // [END_ENTITY: Composable('Screen.LocationEdit.route')] + // [ENTITY: Composable('Screen.Search.route')] + composable(route = Screen.Search.route) { backStackEntry -> + val viewModel: SearchViewModel = hiltViewModel(backStackEntry) SearchScreen( - currentRoute = currentRoute, - navigationActions = navigationActions + onNavigateBack = { + navController.popBackStack() + }, + onItemClick = { item -> + // TODO: Navigate to item details + Timber.i("[UI] Search result item clicked: ${item.name}") + } ) } + // [END_ENTITY: Composable('Screen.Search.route')] } - // [END_FUNCTION_NavGraph] } -// [END_FILE_NavGraph.kt] +// [END_ENTITY: Function('NavGraph')] +// [END_CONTRACT] +// [END_FILE_NavGraph.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/navigation/NavigationActions.kt b/app/src/main/java/com/homebox/lens/navigation/NavigationActions.kt index 3d4db3a..3ffe4a8 100644 --- a/app/src/main/java/com/homebox/lens/navigation/NavigationActions.kt +++ b/app/src/main/java/com/homebox/lens/navigation/NavigationActions.kt @@ -2,70 +2,122 @@ // [FILE] NavigationActions.kt // [SEMANTICS] navigation, controller, actions package com.homebox.lens.navigation + +// [IMPORTS] import androidx.navigation.NavHostController -// [CORE-LOGIC] +// [END_IMPORTS] + +// [CONTRACT] +// [ENTITY: Class('NavigationActions')] +// [RELATION: Class('NavigationActions') -> [DEPENDS_ON] -> Class('NavHostController')] /** -[CONTRACT] -@summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий. -@param navController Контроллер Jetpack Navigation. -@invariant Все навигационные действия должны использовать предоставленный navController. + * [CONTRACT] + * @summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий. + * @param navController Контроллер Jetpack Navigation. + * @invariant Все навигационные действия должны использовать предоставленный navController. */ class NavigationActions(private val navController: NavHostController) { -// [ACTION] + // [ENTITY: Function('navigateToDashboard')] + // [RELATION: Function('navigateToDashboard') -> [CALLS] -> Function('navController.navigate')] + // [RELATION: Function('navigateToDashboard') -> [CALLS] -> Function('Screen.Dashboard.route')] + // [RELATION: Function('navigateToDashboard') -> [CALLS] -> Function('popUpTo')] + // [ACTION] /** - [CONTRACT] - @summary Навигация на главный экран. - @sideeffect Очищает back stack до главного экрана, чтобы избежать циклов. + * [CONTRACT] + * @summary Навигация на главный экран. + * @sideeffect Очищает back stack до главного экрана, чтобы избежать циклов. */ fun navigateToDashboard() { navController.navigate(Screen.Dashboard.route) { -// Используем popUpTo для удаления всех экранов до dashboard из back stack -// Это предотвращает создание большой стопки экранов при навигации через drawer + // Используем popUpTo для удаления всех экранов до dashboard из back stack + // Это предотвращает создание большой стопки экранов при навигации через drawer popUpTo(navController.graph.startDestinationId) launchSingleTop = true } } + // [END_ENTITY: Function('navigateToDashboard')] + + // [ENTITY: Function('navigateToLocations')] + // [RELATION: Function('navigateToLocations') -> [CALLS] -> Function('navController.navigate')] + // [RELATION: Function('navigateToLocations') -> [CALLS] -> Function('Screen.LocationsList.route')] // [ACTION] fun navigateToLocations() { navController.navigate(Screen.LocationsList.route) { launchSingleTop = true } } + // [END_ENTITY: Function('navigateToLocations')] + + // [ENTITY: Function('navigateToLabels')] + // [RELATION: Function('navigateToLabels') -> [CALLS] -> Function('navController.navigate')] + // [RELATION: Function('navigateToLabels') -> [CALLS] -> Function('Screen.LabelsList.route')] // [ACTION] fun navigateToLabels() { navController.navigate(Screen.LabelsList.route) { launchSingleTop = true } } + // [END_ENTITY: Function('navigateToLabels')] + + // [ENTITY: Function('navigateToSearch')] + // [RELATION: Function('navigateToSearch') -> [CALLS] -> Function('navController.navigate')] + // [RELATION: Function('navigateToSearch') -> [CALLS] -> Function('Screen.Search.route')] // [ACTION] fun navigateToSearch() { navController.navigate(Screen.Search.route) { launchSingleTop = true } } + // [END_ENTITY: Function('navigateToSearch')] + + // [ENTITY: Function('navigateToInventoryListWithLabel')] + // [RELATION: Function('navigateToInventoryListWithLabel') -> [CALLS] -> Function('Screen.InventoryList.withFilter')] + // [RELATION: Function('navigateToInventoryListWithLabel') -> [CALLS] -> Function('navController.navigate')] // [ACTION] fun navigateToInventoryListWithLabel(labelId: String) { val route = Screen.InventoryList.withFilter("label", labelId) navController.navigate(route) } + // [END_ENTITY: Function('navigateToInventoryListWithLabel')] + + // [ENTITY: Function('navigateToInventoryListWithLocation')] + // [RELATION: Function('navigateToInventoryListWithLocation') -> [CALLS] -> Function('Screen.InventoryList.withFilter')] + // [RELATION: Function('navigateToInventoryListWithLocation') -> [CALLS] -> Function('navController.navigate')] // [ACTION] fun navigateToInventoryListWithLocation(locationId: String) { val route = Screen.InventoryList.withFilter("location", locationId) navController.navigate(route) } + // [END_ENTITY: Function('navigateToInventoryListWithLocation')] + + // [ENTITY: Function('navigateToCreateItem')] + // [RELATION: Function('navigateToCreateItem') -> [CALLS] -> Function('Screen.ItemEdit.createRoute')] + // [RELATION: Function('navigateToCreateItem') -> [CALLS] -> Function('navController.navigate')] // [ACTION] fun navigateToCreateItem() { navController.navigate(Screen.ItemEdit.createRoute("new")) } + // [END_ENTITY: Function('navigateToCreateItem')] + + // [ENTITY: Function('navigateToLogout')] + // [RELATION: Function('navigateToLogout') -> [CALLS] -> Function('navController.navigate')] + // [RELATION: Function('navigateToLogout') -> [CALLS] -> Function('popUpTo')] // [ACTION] fun navigateToLogout() { navController.navigate(Screen.Setup.route) { popUpTo(Screen.Dashboard.route) { inclusive = true } } } + // [END_ENTITY: Function('navigateToLogout')] + + // [ENTITY: Function('navigateBack')] + // [RELATION: Function('navigateBack') -> [CALLS] -> Function('navController.popBackStack')] // [ACTION] fun navigateBack() { navController.popBackStack() } + // [END_ENTITY: Function('navigateBack')] } +// [END_ENTITY: Class('NavigationActions')] +// [END_CONTRACT] // [END_FILE_NavigationActions.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/navigation/Screen.kt b/app/src/main/java/com/homebox/lens/navigation/Screen.kt index 6014abb..08e11a0 100644 --- a/app/src/main/java/com/homebox/lens/navigation/Screen.kt +++ b/app/src/main/java/com/homebox/lens/navigation/Screen.kt @@ -3,7 +3,11 @@ // [SEMANTICS] navigation, routes, sealed_class package com.homebox.lens.navigation -// [CORE-LOGIC] +// [IMPORTS] +// [END_IMPORTS] + +// [CONTRACT] +// [ENTITY: SealedClass('Screen')] /** * [CONTRACT] * Запечатанный класс для определения маршрутов навигации в приложении. @@ -11,10 +15,17 @@ package com.homebox.lens.navigation * @property route Строковый идентификатор маршрута. */ sealed class Screen(val route: String) { - // [STATE] + // [ENTITY: DataObject('Setup')] data object Setup : Screen("setup_screen") + // [END_ENTITY: DataObject('Setup')] + + // [ENTITY: DataObject('Dashboard')] data object Dashboard : Screen("dashboard_screen") + // [END_ENTITY: DataObject('Dashboard')] + + // [ENTITY: DataObject('InventoryList')] data object InventoryList : Screen("inventory_list_screen") { + // [ENTITY: Function('withFilter')] /** * [CONTRACT] * Создает маршрут для экрана списка инвентаря с параметром фильтра. @@ -24,8 +35,10 @@ sealed class Screen(val route: String) { * @throws IllegalArgumentException если ключ или значение пустые. * @sideeffect [ARCH-IMPLICATION] NavGraph должен быть настроен для приема этого опционального query-параметра (например, 'navArgument("label") { nullable = true }'). */ - // [HELPER] - fun withFilter(key: String, value: String): String { + fun withFilter( + key: String, + value: String, + ): String { // [PRECONDITION] require(key.isNotBlank()) { "[PRECONDITION_FAILED] Filter key cannot be blank." } require(value.isNotBlank()) { "[PRECONDITION_FAILED] Filter value cannot be blank." } @@ -35,9 +48,13 @@ sealed class Screen(val route: String) { check(constructedRoute.contains("?$key=$value")) { "[POSTCONDITION_FAILED] Route must contain the filter query." } return constructedRoute } + // [END_ENTITY: Function('withFilter')] } + // [END_ENTITY: DataObject('InventoryList')] + // [ENTITY: DataObject('ItemDetails')] data object ItemDetails : Screen("item_details_screen/{itemId}") { + // [ENTITY: Function('createRoute')] /** * [CONTRACT] * Создает маршрут для экрана деталей элемента с указанным ID. @@ -45,7 +62,6 @@ sealed class Screen(val route: String) { * @return Строку полного маршрута. * @throws IllegalArgumentException если itemId пустой. */ - // [HELPER] fun createRoute(itemId: String): String { // [PRECONDITION] require(itemId.isNotBlank()) { "[PRECONDITION_FAILED] itemId не может быть пустым." } @@ -55,8 +71,13 @@ sealed class Screen(val route: String) { check(route.endsWith(itemId)) { "[POSTCONDITION_FAILED] Маршрут должен заканчиваться на itemId." } return route } + // [END_ENTITY: Function('createRoute')] } + // [END_ENTITY: DataObject('ItemDetails')] + + // [ENTITY: DataObject('ItemEdit')] data object ItemEdit : Screen("item_edit_screen/{itemId}") { + // [ENTITY: Function('createRoute')] /** * [CONTRACT] * Создает маршрут для экрана редактирования элемента с указанным ID. @@ -64,7 +85,6 @@ sealed class Screen(val route: String) { * @return Строку полного маршрута. * @throws IllegalArgumentException если itemId пустой. */ - // [HELPER] fun createRoute(itemId: String): String { // [PRECONDITION] require(itemId.isNotBlank()) { "[PRECONDITION_FAILED] itemId не может быть пустым." } @@ -74,10 +94,21 @@ sealed class Screen(val route: String) { check(route.endsWith(itemId)) { "[POSTCONDITION_FAILED] Маршрут должен заканчиваться на itemId." } return route } + // [END_ENTITY: Function('createRoute')] } + // [END_ENTITY: DataObject('ItemEdit')] + + // [ENTITY: DataObject('LabelsList')] data object LabelsList : Screen("labels_list_screen") + // [END_ENTITY: DataObject('LabelsList')] + + // [ENTITY: DataObject('LocationsList')] data object LocationsList : Screen("locations_list_screen") + // [END_ENTITY: DataObject('LocationsList')] + + // [ENTITY: DataObject('LocationEdit')] data object LocationEdit : Screen("location_edit_screen/{locationId}") { + // [ENTITY: Function('createRoute')] /** * [CONTRACT] * Создает маршрут для экрана редактирования местоположения с указанным ID. @@ -85,7 +116,6 @@ sealed class Screen(val route: String) { * @return Строку полного маршрута. * @throws IllegalArgumentException если locationId пустой. */ - // [HELPER] fun createRoute(locationId: String): String { // [PRECONDITION] require(locationId.isNotBlank()) { "[PRECONDITION_FAILED] locationId не может быть пустым." } @@ -95,7 +125,14 @@ sealed class Screen(val route: String) { check(route.endsWith(locationId)) { "[POSTCONDITION_FAILED] Маршрут должен заканчиваться на locationId." } return route } + // [END_ENTITY: Function('createRoute')] } + // [END_ENTITY: DataObject('LocationEdit')] + + // [ENTITY: DataObject('Search')] data object Search : Screen("search_screen") + // [END_ENTITY: DataObject('Search')] } +// [END_ENTITY: SealedClass('Screen')] +// [END_CONTRACT] // [END_FILE_Screen.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/common/AppDrawer.kt b/app/src/main/java/com/homebox/lens/ui/common/AppDrawer.kt index 176d749..7b95ef9 100644 --- a/app/src/main/java/com/homebox/lens/ui/common/AppDrawer.kt +++ b/app/src/main/java/com/homebox/lens/ui/common/AppDrawer.kt @@ -1,6 +1,9 @@ // [PACKAGE] com.homebox.lens.ui.common // [FILE] AppDrawer.kt +// [SEMANTICS] ui, common, navigation_drawer package com.homebox.lens.ui.common + +// [IMPORTS] import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -22,18 +25,37 @@ import androidx.compose.ui.unit.dp import com.homebox.lens.R import com.homebox.lens.navigation.NavigationActions import com.homebox.lens.navigation.Screen +// [END_IMPORTS] + +// [CONTRACT] +// [ENTITY: Function('AppDrawerContent')] +// [RELATION: Function('AppDrawerContent') -> [DEPENDS_ON] -> Class('NavigationActions')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('ModalDrawerSheet')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Spacer')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Button')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Icon')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Text')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Divider')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('NavigationDrawerItem')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Screen.Dashboard.route')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Screen.LocationsList.route')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Screen.LabelsList.route')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Screen.Search.route')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Screen.ItemEdit.createRoute')] +// [RELATION: Function('AppDrawerContent') -> [CALLS] -> Function('Screen.Setup.route')] /** -[CONTRACT] -@summary Контент для бокового навигационного меню (Drawer). -@param currentRoute Текущий маршрут для подсветки активного элемента. -@param navigationActions Объект с навигационными действиями. -@param onCloseDrawer Лямбда для закрытия бокового меню. + * [CONTRACT] + * @summary Контент для бокового навигационного меню (Drawer). + * @param currentRoute Текущий маршрут для подсветки активного элемента. + * @param navigationActions Объект с навигационными действиями. + * @param onCloseDrawer Лямбда для закрытия бокового меню. */ @Composable internal fun AppDrawerContent( currentRoute: String?, navigationActions: NavigationActions, - onCloseDrawer: () -> Unit + onCloseDrawer: () -> Unit, ) { ModalDrawerSheet { Spacer(Modifier.height(12.dp)) @@ -42,9 +64,10 @@ internal fun AppDrawerContent( navigationActions.navigateToCreateItem() onCloseDrawer() }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) { Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) @@ -58,7 +81,7 @@ internal fun AppDrawerContent( onClick = { navigationActions.navigateToDashboard() onCloseDrawer() - } + }, ) NavigationDrawerItem( label = { Text(stringResource(id = R.string.nav_locations)) }, @@ -66,7 +89,7 @@ internal fun AppDrawerContent( onClick = { navigationActions.navigateToLocations() onCloseDrawer() - } + }, ) NavigationDrawerItem( label = { Text(stringResource(id = R.string.nav_labels)) }, @@ -74,7 +97,7 @@ internal fun AppDrawerContent( onClick = { navigationActions.navigateToLabels() onCloseDrawer() - } + }, ) NavigationDrawerItem( label = { Text(stringResource(id = R.string.search)) }, @@ -82,7 +105,7 @@ internal fun AppDrawerContent( onClick = { navigationActions.navigateToSearch() onCloseDrawer() - } + }, ) // TODO: Add Profile and Tools items Divider() @@ -92,7 +115,10 @@ internal fun AppDrawerContent( onClick = { navigationActions.navigateToLogout() onCloseDrawer() - } + }, ) } -} \ No newline at end of file +} +// [END_ENTITY: Function('AppDrawerContent')] +// [END_CONTRACT] +// [END_FILE_AppDrawer.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/common/MainScaffold.kt b/app/src/main/java/com/homebox/lens/ui/common/MainScaffold.kt index b366974..d4a98bb 100644 --- a/app/src/main/java/com/homebox/lens/ui/common/MainScaffold.kt +++ b/app/src/main/java/com/homebox/lens/ui/common/MainScaffold.kt @@ -15,8 +15,21 @@ import androidx.compose.ui.res.stringResource import com.homebox.lens.R import com.homebox.lens.navigation.NavigationActions import kotlinx.coroutines.launch +// [END_IMPORTS] -// [UI_COMPONENT] +// [CONTRACT] +// [ENTITY: Function('MainScaffold')] +// [RELATION: Function('MainScaffold') -> [DEPENDS_ON] -> Class('NavigationActions')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('rememberDrawerState')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('rememberCoroutineScope')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('ModalNavigationDrawer')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('AppDrawerContent')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('Scaffold')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('TopAppBar')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('Text')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('IconButton')] +// [RELATION: Function('MainScaffold') -> [CALLS] -> Function('Icon')] /** * [CONTRACT] * @summary Общая обертка для экранов, включающая Scaffold и Navigation Drawer. @@ -35,7 +48,7 @@ fun MainScaffold( currentRoute: String?, navigationActions: NavigationActions, topBarActions: @Composable () -> Unit = {}, - content: @Composable (PaddingValues) -> Unit + content: @Composable (PaddingValues) -> Unit, ) { // [STATE] val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) @@ -48,9 +61,9 @@ fun MainScaffold( AppDrawerContent( currentRoute = currentRoute, navigationActions = navigationActions, - onCloseDrawer = { scope.launch { drawerState.close() } } + onCloseDrawer = { scope.launch { drawerState.close() } }, ) - } + }, ) { Scaffold( topBar = { @@ -60,18 +73,19 @@ fun MainScaffold( IconButton(onClick = { scope.launch { drawerState.open() } }) { Icon( Icons.Default.Menu, - contentDescription = stringResource(id = R.string.cd_open_navigation_drawer) + contentDescription = stringResource(id = R.string.cd_open_navigation_drawer), ) } }, - actions = { topBarActions() } + actions = { topBarActions() }, ) - } + }, ) { paddingValues -> // [ACTION] content(paddingValues) } } - // [END_FUNCTION_MainScaffold] } -// [END_FILE_MainScaffold.kt] +// [END_ENTITY: Function('MainScaffold')] +// [END_CONTRACT] +// [END_FILE_MainScaffold.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardScreen.kt index 775cd5c..a0ca82f 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardScreen.kt @@ -2,6 +2,7 @@ // [FILE] DashboardScreen.kt // [SEMANTICS] ui, screen, dashboard, compose, navigation package com.homebox.lens.ui.screen.dashboard + // [IMPORTS] import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -29,24 +30,36 @@ import com.homebox.lens.navigation.NavigationActions import com.homebox.lens.ui.common.MainScaffold import com.homebox.lens.ui.theme.HomeboxLensTheme import timber.log.Timber -// [ENTRYPOINT] +// [END_IMPORTS] + +// [CONTRACT] +// [ENTITY: Function('DashboardScreen')] +// [RELATION: Function('DashboardScreen') -> [DEPENDS_ON] -> Class('DashboardViewModel')] +// [RELATION: Function('DashboardScreen') -> [DEPENDS_ON] -> Class('NavigationActions')] +// [RELATION: Function('DashboardScreen') -> [CALLS] -> Function('hiltViewModel')] +// [RELATION: Function('DashboardScreen') -> [CALLS] -> Function('collectAsState')] +// [RELATION: Function('DashboardScreen') -> [CALLS] -> Function('MainScaffold')] +// [RELATION: Function('DashboardScreen') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('DashboardScreen') -> [CALLS] -> Function('IconButton')] +// [RELATION: Function('DashboardScreen') -> [CALLS] -> Function('Icon')] +// [RELATION: Function('DashboardScreen') -> [CALLS] -> Function('DashboardContent')] /** -[CONTRACT] -@summary Главная Composable-функция для экрана "Панель управления". -@param viewModel ViewModel для этого экрана, предоставляется через Hilt. -@param currentRoute Текущий маршрут для подсветки активного элемента в Drawer. -@param navigationActions Объект с навигационными действиями. -@sideeffect Вызывает навигационные лямбды при взаимодействии с UI. + * [CONTRACT] + * @summary Главная Composable-функция для экрана "Панель управления". + * @param viewModel ViewModel для этого экрана, предоставляется через Hilt. + * @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer. + * @param navigationActions Объект с навигационными действиями. + * @sideeffect Вызывает навигационные лямбды при взаимодействии с UI. */ @Composable fun DashboardScreen( viewModel: DashboardViewModel = hiltViewModel(), currentRoute: String?, - navigationActions: NavigationActions + navigationActions: NavigationActions, ) { -// [STATE] + // [STATE] val uiState by viewModel.uiState.collectAsState() -// [UI_COMPONENT] + // [UI_COMPONENT] MainScaffold( topBarTitle = stringResource(id = R.string.dashboard_title), currentRoute = currentRoute, @@ -55,10 +68,10 @@ fun DashboardScreen( IconButton(onClick = { navigationActions.navigateToSearch() }) { Icon( Icons.Default.Search, - contentDescription = stringResource(id = R.string.cd_scan_qr_code) // TODO: Rename string resource + contentDescription = stringResource(id = R.string.cd_scan_qr_code), // TODO: Rename string resource ) } - } + }, ) { paddingValues -> DashboardContent( modifier = Modifier.padding(paddingValues), @@ -70,28 +83,40 @@ fun DashboardScreen( onLabelClick = { label -> Timber.i("[ACTION] Label chip clicked: ${label.id}. Navigating...") navigationActions.navigateToInventoryListWithLabel(label.id) - } + }, ) } -// [END_FUNCTION_DashboardScreen] } -// [HELPER] +// [END_ENTITY: Function('DashboardScreen')] + +// [ENTITY: Function('DashboardContent')] +// [RELATION: Function('DashboardContent') -> [DEPENDS_ON] -> SealedInterface('DashboardUiState')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('Box')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('CircularProgressIndicator')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('Text')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('MaterialTheme.colorScheme.error')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('LazyColumn')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('Spacer')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('StatisticsSection')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('RecentlyAddedSection')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('LocationsSection')] +// [RELATION: Function('DashboardContent') -> [CALLS] -> Function('LabelsSection')] /** -[CONTRACT] -@summary Отображает основной контент экрана в зависимости от uiState. -@param modifier Модификатор для стилизации. -@param uiState Текущее состояние UI экрана. -@param onLocationClick Лямбда-обработчик нажатия на местоположение. -@param onLabelClick Лямбда-обработчик нажатия на метку. + * [CONTRACT] + * @summary Отображает основной контент экрана в зависимости от uiState. + * @param modifier Модификатор для стилизации. + * @param uiState Текущее состояние UI экрана. + * @param onLocationClick Лямбда-обработчик нажатия на местоположение. + * @param onLabelClick Лямбда-обработчик нажатия на метку. */ @Composable private fun DashboardContent( modifier: Modifier = Modifier, uiState: DashboardUiState, onLocationClick: (LocationOutCount) -> Unit, - onLabelClick: (LabelOut) -> Unit + onLabelClick: (LabelOut) -> Unit, ) { -// [CORE-LOGIC] + // [CORE-LOGIC] when (uiState) { is DashboardUiState.Loading -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -103,16 +128,17 @@ private fun DashboardContent( Text( text = uiState.message, color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } is DashboardUiState.Success -> { LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { item { Spacer(modifier = Modifier.height(8.dp)) } item { StatisticsSection(statistics = uiState.statistics) } @@ -123,74 +149,124 @@ private fun DashboardContent( } } } -// [END_FUNCTION_DashboardContent] } -// [UI_COMPONENT] +// [END_ENTITY: Function('DashboardContent')] + +// [ENTITY: Function('StatisticsSection')] +// [RELATION: Function('StatisticsSection') -> [DEPENDS_ON] -> Class('GroupStatistics')] +// [RELATION: Function('StatisticsSection') -> [CALLS] -> Function('Column')] +// [RELATION: Function('StatisticsSection') -> [CALLS] -> Function('Text')] +// [RELATION: Function('StatisticsSection') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('StatisticsSection') -> [CALLS] -> Function('MaterialTheme.typography.titleMedium')] +// [RELATION: Function('StatisticsSection') -> [CALLS] -> Function('Card')] +// [RELATION: Function('StatisticsSection') -> [CALLS] -> Function('LazyVerticalGrid')] +// [RELATION: Function('StatisticsSection') -> [CALLS] -> Function('GridCells.Fixed')] +// [RELATION: Function('StatisticsSection') -> [CALLS] -> Function('StatisticCard')] /** -[CONTRACT] -@summary Секция для отображения общей статистики. -@param statistics Объект со статистическими данными. + * [CONTRACT] + * @summary Секция для отображения общей статистики. + * @param statistics Объект со статистическими данными. */ @Composable private fun StatisticsSection(statistics: GroupStatistics) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = stringResource(id = R.string.dashboard_section_quick_stats), - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, ) Card { LazyVerticalGrid( columns = GridCells.Fixed(2), - modifier = Modifier - .height(120.dp) - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .height(120.dp) + .fillMaxWidth() + .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_items), value = statistics.items.toString()) } - item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_value), value = statistics.totalValue.toString()) } - item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_labels), value = statistics.labels.toString()) } - item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_locations), value = statistics.locations.toString()) } + item { + StatisticCard( + title = stringResource(id = R.string.dashboard_stat_total_items), + value = statistics.items.toString(), + ) + } + item { + StatisticCard( + title = stringResource(id = R.string.dashboard_stat_total_value), + value = statistics.totalValue.toString(), + ) + } + item { + StatisticCard( + title = stringResource(id = R.string.dashboard_stat_total_labels), + value = statistics.labels.toString(), + ) + } + item { + StatisticCard( + title = stringResource(id = R.string.dashboard_stat_total_locations), + value = statistics.locations.toString(), + ) + } } } } } -// [UI_COMPONENT] +// [END_ENTITY: Function('StatisticsSection')] + +// [ENTITY: Function('StatisticCard')] +// [RELATION: Function('StatisticCard') -> [CALLS] -> Function('Column')] +// [RELATION: Function('StatisticCard') -> [CALLS] -> Function('Text')] +// [RELATION: Function('StatisticCard') -> [CALLS] -> Function('MaterialTheme.typography.labelMedium')] +// [RELATION: Function('StatisticCard') -> [CALLS] -> Function('MaterialTheme.typography.headlineSmall')] /** -[CONTRACT] -@summary Карточка для отображения одного статистического показателя. -@param title Название показателя. -@param value Значение показателя. + * [CONTRACT] + * @summary Карточка для отображения одного статистического показателя. + * @param title Название показателя. + * @param value Значение показателя. */ @Composable -private fun StatisticCard(title: String, value: String) { +private fun StatisticCard( + title: String, + value: String, +) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Text(text = title, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center) Text(text = value, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) } } -// [UI_COMPONENT] +// [END_ENTITY: Function('StatisticCard')] + +// [ENTITY: Function('RecentlyAddedSection')] +// [RELATION: Function('RecentlyAddedSection') -> [DEPENDS_ON] -> Class('ItemSummary')] +// [RELATION: Function('RecentlyAddedSection') -> [CALLS] -> Function('Column')] +// [RELATION: Function('RecentlyAddedSection') -> [CALLS] -> Function('Text')] +// [RELATION: Function('RecentlyAddedSection') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('RecentlyAddedSection') -> [CALLS] -> Function('MaterialTheme.typography.titleMedium')] +// [RELATION: Function('RecentlyAddedSection') -> [CALLS] -> Function('LazyRow')] +// [RELATION: Function('RecentlyAddedSection') -> [CALLS] -> Function('ItemCard')] /** -[CONTRACT] -@summary Секция для отображения недавно добавленных элементов. -@param items Список элементов для отображения. + * [CONTRACT] + * @summary Секция для отображения недавно добавленных элементов. + * @param items Список элементов для отображения. */ @Composable private fun RecentlyAddedSection(items: List) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = stringResource(id = R.string.dashboard_section_recently_added), - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, ) if (items.isEmpty()) { Text( text = stringResource(id = R.string.items_not_found), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - textAlign = TextAlign.Center + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + textAlign = TextAlign.Center, ) } else { LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { @@ -201,41 +277,70 @@ private fun RecentlyAddedSection(items: List) { } } } -// [UI_COMPONENT] +// [END_ENTITY: Function('RecentlyAddedSection')] + +// [ENTITY: Function('ItemCard')] +// [RELATION: Function('ItemCard') -> [DEPENDS_ON] -> Class('ItemSummary')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('Card')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('Column')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('Spacer')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('Text')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('MaterialTheme.typography.titleSmall')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('MaterialTheme.typography.bodySmall')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('stringResource')] /** -[CONTRACT] -@summary Карточка для отображения краткой информации об элементе. -@param item Элемент для отображения. + * [CONTRACT] + * @summary Карточка для отображения краткой информации об элементе. + * @param item Элемент для отображения. */ @Composable private fun ItemCard(item: ItemSummary) { Card(modifier = Modifier.width(150.dp)) { Column(modifier = Modifier.padding(8.dp)) { // TODO: Add image here from item.image - Spacer(modifier = Modifier - .height(80.dp) - .fillMaxWidth() - .background(MaterialTheme.colorScheme.secondaryContainer)) + Spacer( + modifier = + Modifier + .height(80.dp) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.secondaryContainer), + ) Spacer(modifier = Modifier.height(8.dp)) Text(text = item.name, style = MaterialTheme.typography.titleSmall, maxLines = 1) - Text(text = item.location?.name ?: stringResource(id = R.string.no_location), style = MaterialTheme.typography.bodySmall, maxLines = 1) + Text( + text = item.location?.name ?: stringResource(id = R.string.no_location), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) } } } -// [UI_COMPONENT] +// [END_ENTITY: Function('ItemCard')] + +// [ENTITY: Function('LocationsSection')] +// [RELATION: Function('LocationsSection') -> [DEPENDS_ON] -> Class('LocationOutCount')] +// [RELATION: Function('LocationsSection') -> [CALLS] -> Function('Column')] +// [RELATION: Function('LocationsSection') -> [CALLS] -> Function('Text')] +// [RELATION: Function('LocationsSection') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('LocationsSection') -> [CALLS] -> Function('MaterialTheme.typography.titleMedium')] +// [RELATION: Function('LocationsSection') -> [CALLS] -> Function('FlowRow')] +// [RELATION: Function('LocationsSection') -> [CALLS] -> Function('SuggestionChip')] /** -[CONTRACT] -@summary Секция для отображения местоположений в виде чипсов. -@param locations Список местоположений. -@param onLocationClick Лямбда-обработчик нажатия на местоположение. + * [CONTRACT] + * @summary Секция для отображения местоположений в виде чипсов. + * @param locations Список местоположений. + * @param onLocationClick Лямбда-обработчик нажатия на местоположение. */ @OptIn(ExperimentalLayoutApi::class) @Composable -private fun LocationsSection(locations: List, onLocationClick: (LocationOutCount) -> Unit) { +private fun LocationsSection( + locations: List, + onLocationClick: (LocationOutCount) -> Unit, +) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = stringResource(id = R.string.dashboard_section_locations), - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, ) FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -243,26 +348,38 @@ private fun LocationsSection(locations: List, onLocationClick: locations.forEach { location -> SuggestionChip( onClick = { onLocationClick(location) }, - label = { Text(stringResource(id = R.string.location_chip_label, location.name, location.itemCount)) } + label = { Text(stringResource(id = R.string.location_chip_label, location.name, location.itemCount)) }, ) } } } } -// [UI_COMPONENT] +// [END_ENTITY: Function('LocationsSection')] + +// [ENTITY: Function('LabelsSection')] +// [RELATION: Function('LabelsSection') -> [DEPENDS_ON] -> Class('LabelOut')] +// [RELATION: Function('LabelsSection') -> [CALLS] -> Function('Column')] +// [RELATION: Function('LabelsSection') -> [CALLS] -> Function('Text')] +// [RELATION: Function('LabelsSection') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('LabelsSection') -> [CALLS] -> Function('MaterialTheme.typography.titleMedium')] +// [RELATION: Function('LabelsSection') -> [CALLS] -> Function('FlowRow')] +// [RELATION: Function('LabelsSection') -> [CALLS] -> Function('SuggestionChip')] /** -[CONTRACT] -@summary Секция для отображения меток в виде чипсов. -@param labels Список меток. -@param onLabelClick Лямбда-обработчик нажатия на метку. + * [CONTRACT] + * @summary Секция для отображения меток в виде чипсов. + * @param labels Список меток. + * @param onLabelClick Лямбда-обработчик нажатия на метку. */ @OptIn(ExperimentalLayoutApi::class) @Composable -private fun LabelsSection(labels: List, onLabelClick: (LabelOut) -> Unit) { +private fun LabelsSection( + labels: List, + onLabelClick: (LabelOut) -> Unit, +) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = stringResource(id = R.string.dashboard_section_labels), - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, ) FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -270,46 +387,105 @@ private fun LabelsSection(labels: List, onLabelClick: (LabelOut) -> Un labels.forEach { label -> SuggestionChip( onClick = { onLabelClick(label) }, - label = { Text(label.name) } + label = { Text(label.name) }, ) } } } } +// [END_ENTITY: Function('LabelsSection')] + +// [ENTITY: Function('DashboardContentSuccessPreview')] +// [RELATION: Function('DashboardContentSuccessPreview') -> [CALLS] -> Function('DashboardUiState.Success')] +// [RELATION: Function('DashboardContentSuccessPreview') -> [CALLS] -> Function('GroupStatistics')] +// [RELATION: Function('DashboardContentSuccessPreview') -> [CALLS] -> Function('LocationOutCount')] +// [RELATION: Function('DashboardContentSuccessPreview') -> [CALLS] -> Function('LabelOut')] +// [RELATION: Function('DashboardContentSuccessPreview') -> [CALLS] -> Function('HomeboxLensTheme')] +// [RELATION: Function('DashboardContentSuccessPreview') -> [CALLS] -> Function('DashboardContent')] // [PREVIEW] @Preview(showBackground = true, name = "Dashboard Success State") @Composable fun DashboardContentSuccessPreview() { - val previewState = DashboardUiState.Success( - statistics = GroupStatistics( - items = 123, - totalValue = 9999.99, - locations = 5, - labels = 8 - ), - locations = listOf( - LocationOutCount(id="1", name="Office", color = "#FF0000", isArchived = false, itemCount = 10, createdAt = "", updatedAt = ""), - LocationOutCount(id="2", name="Garage", color = "#00FF00", isArchived = false, itemCount = 5, createdAt = "", updatedAt = ""), - LocationOutCount(id="3",name="Living Room", color = "#0000FF", isArchived = false, itemCount = 15, createdAt = "", updatedAt = ""), - LocationOutCount(id="4",name="Kitchen", color = "#FFFF00", isArchived = false, itemCount = 20, createdAt = "", updatedAt = ""), - LocationOutCount(id="5",name="Basement", color = "#00FFFF", isArchived = false, itemCount = 3, createdAt = "", updatedAt = "") - ), - labels = listOf( - LabelOut(id="1", name="electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""), - LabelOut(id="2", name="important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""), - LabelOut(id="3", name="seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""), - LabelOut(id="4", name="hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = "") - ), - recentlyAddedItems = emptyList() - ) + val previewState = + DashboardUiState.Success( + statistics = + GroupStatistics( + items = 123, + totalValue = 9999.99, + locations = 5, + labels = 8, + ), + locations = + listOf( + LocationOutCount( + id = "1", + name = "Office", + color = "#FF0000", + isArchived = false, + itemCount = 10, + createdAt = "", + updatedAt = "", + ), + LocationOutCount( + id = "2", + name = "Garage", + color = "#00FF00", + isArchived = false, + itemCount = 5, + createdAt = "", + updatedAt = "", + ), + LocationOutCount( + id = "3", + name = "Living Room", + color = "#0000FF", + isArchived = false, + itemCount = 15, + createdAt = "", + updatedAt = "", + ), + LocationOutCount( + id = "4", + name = "Kitchen", + color = "#FFFF00", + isArchived = false, + itemCount = 20, + createdAt = "", + updatedAt = "", + ), + LocationOutCount( + id = "5", + name = "Basement", + color = "#00FFFF", + isArchived = false, + itemCount = 3, + createdAt = "", + updatedAt = "", + ), + ), + labels = + listOf( + LabelOut(id = "1", name = "electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""), + LabelOut(id = "2", name = "important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""), + LabelOut(id = "3", name = "seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""), + LabelOut(id = "4", name = "hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = ""), + ), + recentlyAddedItems = emptyList(), + ) HomeboxLensTheme { DashboardContent( uiState = previewState, onLocationClick = {}, - onLabelClick = {} + onLabelClick = {}, ) } } +// [END_ENTITY: Function('DashboardContentSuccessPreview')] + +// [ENTITY: Function('DashboardContentLoadingPreview')] +// [RELATION: Function('DashboardContentLoadingPreview') -> [CALLS] -> Function('HomeboxLensTheme')] +// [RELATION: Function('DashboardContentLoadingPreview') -> [CALLS] -> Function('DashboardContent')] +// [RELATION: Function('DashboardContentLoadingPreview') -> [CALLS] -> Function('DashboardUiState.Loading')] // [PREVIEW] @Preview(showBackground = true, name = "Dashboard Loading State") @Composable @@ -318,10 +494,17 @@ fun DashboardContentLoadingPreview() { DashboardContent( uiState = DashboardUiState.Loading, onLocationClick = {}, - onLabelClick = {} + onLabelClick = {}, ) } } +// [END_ENTITY: Function('DashboardContentLoadingPreview')] + +// [ENTITY: Function('DashboardContentErrorPreview')] +// [RELATION: Function('DashboardContentErrorPreview') -> [CALLS] -> Function('HomeboxLensTheme')] +// [RELATION: Function('DashboardContentErrorPreview') -> [CALLS] -> Function('DashboardContent')] +// [RELATION: Function('DashboardContentErrorPreview') -> [CALLS] -> Function('DashboardUiState.Error')] +// [RELATION: Function('DashboardContentErrorPreview') -> [CALLS] -> Function('stringResource')] // [PREVIEW] @Preview(showBackground = true, name = "Dashboard Error State") @Composable @@ -330,8 +513,10 @@ fun DashboardContentErrorPreview() { DashboardContent( uiState = DashboardUiState.Error(stringResource(id = R.string.error_loading_failed)), onLocationClick = {}, - onLabelClick = {} + onLabelClick = {}, ) } } +// [END_ENTITY: Function('DashboardContentErrorPreview')] +// [END_CONTRACT] // [END_FILE_DashboardScreen.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardUiState.kt b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardUiState.kt index a4fe49e..69effeb 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardUiState.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardUiState.kt @@ -1,15 +1,17 @@ // [PACKAGE] com.homebox.lens.ui.screen.dashboard -// [FILE] app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardUiState.kt +// [FILE] DashboardUiState.kt // [SEMANTICS] ui, state, dashboard -// [IMPORTS] package com.homebox.lens.ui.screen.dashboard +// [IMPORTS] import com.homebox.lens.domain.model.GroupStatistics import com.homebox.lens.domain.model.LabelOut import com.homebox.lens.domain.model.LocationOutCount +import com.homebox.lens.domain.model.ItemSummary +// [END_IMPORTS] -// [CORE-LOGIC] +// [CONTRACT] // [ENTITY: SealedInterface('DashboardUiState')] /** * [CONTRACT] @@ -17,6 +19,11 @@ import com.homebox.lens.domain.model.LocationOutCount * @invariant В любой момент времени экран может находиться только в одном из этих состояний. */ sealed interface DashboardUiState { + // [ENTITY: DataClass('Success')] + // [RELATION: DataClass('Success') -> [DEPENDS_ON] -> Class('GroupStatistics')] + // [RELATION: DataClass('Success') -> [DEPENDS_ON] -> Class('LocationOutCount')] + // [RELATION: DataClass('Success') -> [DEPENDS_ON] -> Class('LabelOut')] + // [RELATION: DataClass('Success') -> [DEPENDS_ON] -> Class('ItemSummary')] /** * [CONTRACT] * Состояние успешной загрузки данных. @@ -29,20 +36,27 @@ sealed interface DashboardUiState { val statistics: GroupStatistics, val locations: List, val labels: List, - val recentlyAddedItems: List + val recentlyAddedItems: List, ) : DashboardUiState + // [END_ENTITY: DataClass('Success')] + // [ENTITY: DataClass('Error')] /** * [CONTRACT] * Состояние ошибки во время загрузки данных. * @property message Человекочитаемое сообщение об ошибке. */ data class Error(val message: String) : DashboardUiState + // [END_ENTITY: DataClass('Error')] + // [ENTITY: DataObject('Loading')] /** * [CONTRACT] * Состояние, когда данные для экрана загружаются. */ - data object Loading : DashboardUiState + object Loading : DashboardUiState + // [END_ENTITY: DataObject('Loading')] } +// [END_ENTITY: SealedInterface('DashboardUiState')] +// [END_CONTRACT] // [END_FILE_DashboardUiState.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt index 2dce373..946179c 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt @@ -2,6 +2,7 @@ // [FILE] DashboardViewModel.kt // [SEMANTICS] ui_logic, dashboard, state_management, sealed_state, parallel_data_loading, timber_logging package com.homebox.lens.ui.screen.dashboard + // [IMPORTS] import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -9,17 +10,21 @@ import com.homebox.lens.domain.usecase.GetAllLabelsUseCase import com.homebox.lens.domain.usecase.GetAllLocationsUseCase import com.homebox.lens.domain.usecase.GetRecentlyAddedItemsUseCase import com.homebox.lens.domain.usecase.GetStatisticsUseCase -import com.homebox.lens.ui.screen.dashboard.DashboardUiState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +// [END_IMPORTS] -// [VIEWMODEL] +// [CONTRACT] // [ENTITY: ViewModel('DashboardViewModel')] +// [RELATION: ViewModel('DashboardViewModel') -> [INHERITS_FROM] -> Class('ViewModel')] +// [RELATION: ViewModel('DashboardViewModel') -> [DEPENDS_ON] -> Annotation('HiltViewModel')] +// [RELATION: ViewModel('DashboardViewModel') -> [DEPENDS_ON] -> Class('GetStatisticsUseCase')] +// [RELATION: ViewModel('DashboardViewModel') -> [DEPENDS_ON] -> Class('GetAllLocationsUseCase')] +// [RELATION: ViewModel('DashboardViewModel') -> [DEPENDS_ON] -> Class('GetAllLabelsUseCase')] +// [RELATION: ViewModel('DashboardViewModel') -> [DEPENDS_ON] -> Class('GetRecentlyAddedItemsUseCase')] /** * [CONTRACT] * @summary ViewModel для главного экрана (Dashboard). @@ -28,61 +33,78 @@ import javax.inject.Inject * @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`. */ @HiltViewModel -class DashboardViewModel @Inject constructor( - private val getStatisticsUseCase: GetStatisticsUseCase, - private val getAllLocationsUseCase: GetAllLocationsUseCase, - private val getAllLabelsUseCase: GetAllLabelsUseCase, - private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase -) : ViewModel() { +class DashboardViewModel + @Inject + constructor( + private val getStatisticsUseCase: GetStatisticsUseCase, + private val getAllLocationsUseCase: GetAllLocationsUseCase, + private val getAllLabelsUseCase: GetAllLabelsUseCase, + private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase, + ) : ViewModel() { + // [STATE] + private val _uiState = MutableStateFlow(DashboardUiState.Loading) - // [STATE] - private val _uiState = MutableStateFlow(DashboardUiState.Loading) - // [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow(). - // [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и - // должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока. - val uiState = _uiState.asStateFlow() + // [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow(). + // [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и + // должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока. + val uiState = _uiState.asStateFlow() - // [LIFECYCLE_HANDLER] - init { - loadDashboardData() - } + // [LIFECYCLE_HANDLER] + init { + loadDashboardData() + } - /** - * [CONTRACT] - * @summary Загружает все необходимые данные для экрана Dashboard. - * @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его - * между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`. - * @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`. - */ - fun loadDashboardData() { - // [ENTRYPOINT] - viewModelScope.launch { - _uiState.value = DashboardUiState.Loading - Timber.i("[ACTION] Starting dashboard data collection.") + // [ENTITY: Function('loadDashboardData')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('viewModelScope.launch')] + // [RELATION: Function('loadDashboardData') -> [WRITES_TO] -> Property('_uiState')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('Timber.i')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('flow')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('getStatisticsUseCase')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('getAllLocationsUseCase')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('getAllLabelsUseCase')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('getRecentlyAddedItemsUseCase')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('combine')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('catch')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('Timber.e')] + // [RELATION: Function('loadDashboardData') -> [CALLS] -> Function('collect')] + /** + * [CONTRACT] + * @summary Загружает все необходимые данные для экрана Dashboard. + * @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его + * между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`. + * @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`. + */ + fun loadDashboardData() { + viewModelScope.launch { + _uiState.value = DashboardUiState.Loading + Timber.i("[ACTION] Starting dashboard data collection.") - val statsFlow = flow { emit(getStatisticsUseCase()) } - val locationsFlow = flow { emit(getAllLocationsUseCase()) } - val labelsFlow = flow { emit(getAllLabelsUseCase()) } - val recentItemsFlow = getRecentlyAddedItemsUseCase(limit = 10) + val statsFlow = flow { emit(getStatisticsUseCase()) } + val locationsFlow = flow { emit(getAllLocationsUseCase()) } + val labelsFlow = flow { emit(getAllLabelsUseCase()) } + val recentItemsFlow = getRecentlyAddedItemsUseCase(limit = 10) - combine(statsFlow, locationsFlow, labelsFlow, recentItemsFlow) { stats, locations, labels, recentItems -> - DashboardUiState.Success( - statistics = stats, - locations = locations, - labels = labels, - recentlyAddedItems = recentItems - ) - }.catch { exception -> - Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.") - _uiState.value = DashboardUiState.Error( - message = exception.message ?: "Could not load dashboard data." - ) - }.collect { successState -> - Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.") - _uiState.value = successState + combine(statsFlow, locationsFlow, labelsFlow, recentItemsFlow) { stats, locations, labels, recentItems -> + DashboardUiState.Success( + statistics = stats, + locations = locations, + labels = labels, + recentlyAddedItems = recentItems, + ) + }.catch { exception -> + Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.") + _uiState.value = + DashboardUiState.Error( + message = exception.message ?: "Could not load dashboard data.", + ) + }.collect { successState -> + Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.") + _uiState.value = successState + } } } + // [END_ENTITY: Function('loadDashboardData')] } - // [END_CLASS_DashboardViewModel] -} +// [END_ENTITY: ViewModel('DashboardViewModel')] +// [END_CONTRACT] // [END_FILE_DashboardViewModel.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt index abf1924..a20dafc 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt @@ -1,37 +1,219 @@ // [PACKAGE] com.homebox.lens.ui.screen.inventorylist // [FILE] InventoryListScreen.kt -// [SEMANTICS] ui, screen, inventory, list - +// [SEMANTICS] ui, screen, inventory, list, compose package com.homebox.lens.ui.screen.inventorylist // [IMPORTS] +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextField +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.homebox.lens.R -import com.homebox.lens.navigation.NavigationActions -import com.homebox.lens.ui.common.MainScaffold +import com.homebox.lens.domain.model.Item +import timber.log.Timber +// [END_IMPORTS] -// [ENTRYPOINT] +// [CONTRACT] +// [ENTITY: Function('InventoryListScreen')] +// [RELATION: Function('InventoryListScreen') -> [DEPENDS_ON] -> Class('InventoryListViewModel')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('hiltViewModel')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('collectAsState')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('Scaffold')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('TopAppBar')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('Text')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('IconButton')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('Icon')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('FloatingActionButton')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('SearchBar')] +// [RELATION: Function('InventoryListScreen') -> [CALLS] -> Function('InventoryListContent')] /** - * [CONTRACT] - * @summary Composable-функция для экрана "Список инвентаря". - * @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer. - * @param navigationActions Объект с навигационными действиями. + * [MAIN-CONTRACT] + * Экран для отображения списка инвентарных позиций. + * + * Реализует спецификацию `screen_inventory_list`. Позволяет просматривать, + * искать и синхронизировать инвентарь. + * + * @param onItemClick Обработчик нажатия на элемент инвентаря. + * @param onNavigateBack Обработчик для возврата на предыдущий экран. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun InventoryListScreen( - currentRoute: String?, - navigationActions: NavigationActions + viewModel: InventoryListViewModel = hiltViewModel(), + onItemClick: (Item) -> Unit, + onNavigateBack: () -> Unit ) { - // [UI_COMPONENT] - MainScaffold( - topBarTitle = stringResource(id = R.string.inventory_list_title), - currentRoute = currentRoute, - navigationActions = navigationActions - ) { - // [CORE-LOGIC] - Text(text = "TODO: Inventory List Screen") + // [STATE] + val uiState by viewModel.uiState.collectAsState() + + // [ACTION] + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.inventory_list_title)) }, // Corrected string resource name + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.content_desc_navigate_back) + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { + Timber.i("[INFO][ACTION][ui_interaction] Sync inventory triggered.") + viewModel.onSyncClicked() + }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(id = R.string.content_desc_sync_inventory) + ) + } + } + ) { innerPadding -> + // [DELEGATES] + Column(modifier = Modifier.padding(innerPadding)) { + SearchBar( + query = uiState.searchQuery, + onQueryChange = viewModel::onSearchQueryChanged + ) + InventoryListContent( + isLoading = uiState.isLoading, + items = uiState.items, + onItemClick = onItemClick + ) + } } - // [END_FUNCTION_InventoryListScreen] -} \ No newline at end of file +} +// [END_ENTITY: Function('InventoryListScreen')] + +// [ENTITY: Function('SearchBar')] +// [RELATION: Function('SearchBar') -> [CALLS] -> Function('TextField')] +// [RELATION: Function('SearchBar') -> [CALLS] -> Function('Text')] +// [RELATION: Function('SearchBar') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('SearchBar') -> [CALLS] -> Function('Icon')] +/** + * [CONTRACT] + * Поле для ввода поискового запроса. + */ +@Composable +private fun SearchBar(query: String, onQueryChange: (String) -> Unit) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + placeholder = { Text(stringResource(id = R.string.search)) }, // Corrected string resource name + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) } + ) +} +// [END_ENTITY: Function('SearchBar')] + +// [ENTITY: Function('InventoryListContent')] +// [RELATION: Function('InventoryListContent') -> [CALLS] -> Function('Box')] +// [RELATION: Function('InventoryListContent') -> [CALLS] -> Function('CircularProgressIndicator')] +// [RELATION: Function('InventoryListContent') -> [CALLS] -> Function('Text')] +// [RELATION: Function('InventoryListContent') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('InventoryListContent') -> [CALLS] -> Function('LazyColumn')] +// [RELATION: Function('InventoryListContent') -> [CALLS] -> Function('ItemCard')] +/** + * [CONTRACT] + * Основной контент: индикатор загрузки или список предметов. + */ +@Composable +private fun InventoryListContent( + isLoading: Boolean, + items: List, + onItemClick: (Item) -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + if (isLoading) { + // [STATE] + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (items.isEmpty()) { + // [FALLBACK] + Text( + text = stringResource(id = R.string.items_not_found), + modifier = Modifier.align(Alignment.Center) + ) + } else { + // [CORE-LOGIC] + LazyColumn { + items(items, key = { it.id }) { item -> + ItemCard(item = item, onClick = { + Timber.i("[INFO][ACTION][ui_interaction] Item clicked: ${item.name}") + onItemClick(item) + }) + } + } + } + } +} +// [END_ENTITY: Function('InventoryListContent')] + +// [ENTITY: Function('ItemCard')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('Card')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('Column')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('Text')] +// [RELATION: Function('ItemCard') -> [CALLS] -> Function('clickable')] +/** + * [CONTRACT] + * Карточка для отображения одного элемента инвентаря. + */ +@Composable +private fun ItemCard( + item: Item, + onClick: () -> Unit +) { + // [PRECONDITION] + require(item.name.isNotBlank()) { "Item name cannot be blank." } + + // [CORE-LOGIC] + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .clickable(onClick = onClick) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = item.name, style = androidx.compose.material3.MaterialTheme.typography.titleMedium) + Text(text = "Quantity: ${item.quantity.toString()}", style = androidx.compose.material3.MaterialTheme.typography.bodySmall) + item.location?.let { + Text(text = "Location: ${it.name}", style = androidx.compose.material3.MaterialTheme.typography.bodySmall) + } + } + } +} +// [END_ENTITY: Function('ItemCard')] +// [END_CONTRACT] +// [END_FILE_InventoryListScreen.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListViewModel.kt index 69069d6..2e32af6 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListViewModel.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListViewModel.kt @@ -1,16 +1,53 @@ // [PACKAGE] com.homebox.lens.ui.screen.inventorylist // [FILE] InventoryListViewModel.kt +// [SEMANTICS] ui_logic, inventory_list, viewmodel package com.homebox.lens.ui.screen.inventorylist +// [IMPORTS] import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import com.homebox.lens.domain.model.Item +// [END_IMPORTS] -// [VIEWMODEL] +// [CONTRACT] +// [ENTITY: ViewModel('InventoryListViewModel')] +// [RELATION: ViewModel('InventoryListViewModel') -> [INHERITS_FROM] -> Class('ViewModel')] +// [RELATION: ViewModel('InventoryListViewModel') -> [DEPENDS_ON] -> Annotation('HiltViewModel')] +/** + * [CONTRACT] + * @summary ViewModel for the InventoryListScreen. + */ @HiltViewModel -class InventoryListViewModel @Inject constructor() : ViewModel() { - // [STATE] - // TODO: Implement UI state -} +class InventoryListViewModel + @Inject + constructor() : ViewModel() { + // [STATE] + private val _uiState = MutableStateFlow(InventoryListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onSyncClicked() { + // TODO: Implement sync logic + } + + fun onSearchQueryChanged(query: String) { + // TODO: Implement search query change logic + } + } +// [END_ENTITY: ViewModel('InventoryListViewModel')] +// [END_CONTRACT] // [END_FILE_InventoryListViewModel.kt] + +// [CONTRACT] +// [ENTITY: DataClass('InventoryListUiState')] +// [RELATION: DataClass('InventoryListUiState') -> [DEPENDS_ON] -> Class('Item')] +data class InventoryListUiState( + val searchQuery: String = "", + val isLoading: Boolean = false, + val items: List = emptyList() +) +// [END_ENTITY: DataClass('InventoryListUiState')] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt index 6b78f9e..cdd21c6 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt @@ -1,37 +1,208 @@ // [PACKAGE] com.homebox.lens.ui.screen.itemdetails // [FILE] ItemDetailsScreen.kt -// [SEMANTICS] ui, screen, item, details - +// [SEMANTICS] ui, screen, item, details, compose package com.homebox.lens.ui.screen.itemdetails // [IMPORTS] -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* 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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.homebox.lens.R -import com.homebox.lens.navigation.NavigationActions -import com.homebox.lens.ui.common.MainScaffold +import com.homebox.lens.domain.model.Item +import timber.log.Timber +// [END_IMPORTS] -// [ENTRYPOINT] +// [CONTRACT] +// [ENTITY: Function('ItemDetailsScreen')] +// [RELATION: Function('ItemDetailsScreen') -> [DEPENDS_ON] -> Class('ItemDetailsViewModel')] +// [RELATION: Function('ItemDetailsScreen') -> [CALLS] -> Function('hiltViewModel')] +// [RELATION: Function('ItemDetailsScreen') -> [CALLS] -> Function('collectAsState')] +// [RELATION: Function('ItemDetailsScreen') -> [CALLS] -> Function('Scaffold')] +// [RELATION: Function('ItemDetailsScreen') -> [CALLS] -> Function('TopAppBar')] +// [RELATION: Function('ItemDetailsScreen') -> [CALLS] -> Function('Text')] +// [RELATION: Function('ItemDetailsScreen') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('ItemDetailsScreen') -> [CALLS] -> Function('IconButton')] +// [RELATION: Function('ItemDetailsScreen') -> [CALLS] -> Function('Icon')] +// [RELATION: Function('ItemDetailsScreen') -> [CALLS] -> Function('ItemDetailsContent')] /** - * [CONTRACT] - * @summary Composable-функция для экрана "Детали элемента". - * @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer. - * @param navigationActions Объект с навигационными действиями. + * [MAIN-CONTRACT] + * Экран для отображения детальной информации о товаре. + * + * Реализует спецификацию `screen_item_details`. + * + * @param onNavigateBack Обработчик для возврата на предыдущий экран. + * @param onEditClick Обработчик нажатия на кнопку редактирования. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ItemDetailsScreen( - currentRoute: String?, - navigationActions: NavigationActions + viewModel: ItemDetailsViewModel = hiltViewModel(), + onNavigateBack: () -> Unit, + onEditClick: (Int) -> Unit ) { - // [UI_COMPONENT] - MainScaffold( - topBarTitle = stringResource(id = R.string.item_details_title), - currentRoute = currentRoute, - navigationActions = navigationActions - ) { - // [CORE-LOGIC] - Text(text = "TODO: Item Details Screen") + // [STATE] + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(uiState.item?.name ?: stringResource(id = R.string.item_details_title)) }, // Corrected string resource name + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.content_desc_navigate_back)) + } + }, + actions = { + IconButton(onClick = { + uiState.item?.id?.let { + Timber.i("[INFO][ACTION][ui_interaction] Edit item clicked: id=$it") + onEditClick(it.toInt()) + } + }) { + Icon(Icons.Default.Edit, contentDescription = stringResource(id = R.string.content_desc_edit_item)) + } + IconButton(onClick = { + Timber.w("[WARN][ACTION][ui_interaction] Delete item clicked: id=${uiState.item?.id}") + viewModel.deleteItem() + // После удаления нужно навигироваться назад + onNavigateBack() + }) { + Icon(Icons.Default.Delete, contentDescription = stringResource(id = R.string.content_desc_delete_item)) + } + } + ) + } + ) { innerPadding -> + ItemDetailsContent( + modifier = Modifier.padding(innerPadding), + isLoading = uiState.isLoading, + item = uiState.item + ) } - // [END_FUNCTION_ItemDetailsScreen] -} \ No newline at end of file +} +// [END_ENTITY: Function('ItemDetailsScreen')] + +// [ENTITY: Function('ItemDetailsContent')] +// [RELATION: Function('ItemDetailsContent') -> [DEPENDS_ON] -> Class('Item')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('Box')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('CircularProgressIndicator')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('Text')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('Column')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('verticalScroll')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('rememberScrollState')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('DetailsSection')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('InfoRow')] +// [RELATION: Function('ItemDetailsContent') -> [CALLS] -> Function('AssistChip')] +/** + * [CONTRACT] + * Отображает контент экрана: индикатор загрузки или детали товара. + */ +@Composable +private fun ItemDetailsContent( + modifier: Modifier = Modifier, + isLoading: Boolean, + item: Item? +) { + Box(modifier = modifier.fillMaxSize()) { + when { + isLoading -> { + // [STATE] + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + item == null -> { + // [FALLBACK] + Text(stringResource(id = R.string.items_not_found), modifier = Modifier.align(Alignment.Center)) + } + else -> { + // [CORE-LOGIC] + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // TODO: ImageCarousel + // Text("Image Carousel Placeholder") + + DetailsSection(title = stringResource(id = R.string.section_title_description)) { + Text(text = item.description ?: stringResource(id = R.string.placeholder_no_description)) + } + + DetailsSection(title = stringResource(id = R.string.section_title_details)) { + InfoRow(label = stringResource(id = R.string.label_quantity), value = item.quantity.toString()) + item.location?.let { + InfoRow(label = stringResource(id = R.string.label_location), value = it.name) + } + } + + if (item.labels.isNotEmpty()) { + DetailsSection(title = stringResource(id = R.string.section_title_labels)) { + // TODO: Use FlowRow for better layout + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item.labels.forEach { label -> + AssistChip(onClick = { /* No-op */ }, label = { Text(label.name) }) + } + } + } + } + + // TODO: CustomFieldsGrid + } + } + } + } +} +// [END_ENTITY: Function('ItemDetailsContent')] + +// [ENTITY: Function('DetailsSection')] +// [RELATION: Function('DetailsSection') -> [CALLS] -> Function('Column')] +// [RELATION: Function('DetailsSection') -> [CALLS] -> Function('Text')] +// [RELATION: Function('DetailsSection') -> [CALLS] -> Function('MaterialTheme.typography.titleMedium')] +// [RELATION: Function('DetailsSection') -> [CALLS] -> Function('Divider')] +/** + * [CONTRACT] + * Секция с заголовком и контентом. + */ +@Composable +private fun DetailsSection(title: String, content: @Composable ColumnScope.() -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Divider() + content() + } +} +// [END_ENTITY: Function('DetailsSection')] + +// [ENTITY: Function('InfoRow')] +// [RELATION: Function('InfoRow') -> [CALLS] -> Function('Row')] +// [RELATION: Function('InfoRow') -> [CALLS] -> Function('Text')] +// [RELATION: Function('InfoRow') -> [CALLS] -> Function('MaterialTheme.typography.bodyLarge')] +/** + * [CONTRACT] + * Строка для отображения пары "метка: значение". + */ +@Composable +private fun InfoRow(label: String, value: String) { + Row { + Text(text = "$label: ", style = MaterialTheme.typography.bodyLarge) + Text(text = value, style = MaterialTheme.typography.bodyLarge) + } +} +// [END_ENTITY: Function('InfoRow')] +// [END_CONTRACT] +// [END_FILE_ItemDetailsScreen.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsViewModel.kt index 6a591a8..91fe06d 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsViewModel.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsViewModel.kt @@ -3,14 +3,41 @@ package com.homebox.lens.ui.screen.itemdetails +// [IMPORTS] import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import com.homebox.lens.domain.model.Item +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +// [END_IMPORTS] -// [VIEWMODEL] +// [CONTRACT] +// [ENTITY: ViewModel('ItemDetailsViewModel')] +// [RELATION: ViewModel('ItemDetailsViewModel') -> [INHERITS_FROM] -> Class('ViewModel')] +// [RELATION: ViewModel('ItemDetailsViewModel') -> [DEPENDS_ON] -> Annotation('HiltViewModel')] +/** + * [CONTRACT] + * @summary ViewModel for the ItemDetailsScreen. + */ @HiltViewModel -class ItemDetailsViewModel @Inject constructor() : ViewModel() { - // [STATE] - // TODO: Implement UI state -} +class ItemDetailsViewModel + @Inject + constructor() : ViewModel() { + // [STATE] + // TODO: Implement UI state + val uiState = MutableStateFlow(ItemDetailsUiState()).asStateFlow() + + fun deleteItem() { + // TODO: Implement delete item logic + } + } +// [END_ENTITY: ViewModel('ItemDetailsViewModel')] +// [END_CONTRACT] // [END_FILE_ItemDetailsViewModel.kt] + +// Placeholder for ItemDetailsUiState to resolve compilation errors +data class ItemDetailsUiState( + val item: Item? = null, + val isLoading: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt index 957024f..beeebdf 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt @@ -1,37 +1,162 @@ // [PACKAGE] com.homebox.lens.ui.screen.itemedit // [FILE] ItemEditScreen.kt -// [SEMANTICS] ui, screen, item, edit - +// [SEMANTICS] ui, screen, item, edit, create, compose package com.homebox.lens.ui.screen.itemedit // [IMPORTS] -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.homebox.lens.R -import com.homebox.lens.navigation.NavigationActions -import com.homebox.lens.ui.common.MainScaffold +import timber.log.Timber +// [END_IMPORTS] -// [ENTRYPOINT] +// [CONTRACT] +// [ENTITY: Function('ItemEditScreen')] +// [RELATION: Function('ItemEditScreen') -> [DEPENDS_ON] -> Class('ItemEditViewModel')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('hiltViewModel')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('collectAsState')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('LaunchedEffect')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('Timber.i')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('Scaffold')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('TopAppBar')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('Text')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('IconButton')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('Icon')] +// [RELATION: Function('ItemEditScreen') -> [CALLS] -> Function('ItemEditContent')] /** - * [CONTRACT] - * @summary Composable-функция для экрана "Редактирование элемента". - * @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer. - * @param navigationActions Объект с навигационными действиями. + * [MAIN-CONTRACT] + * Экран для создания или редактирования товара. + * + * Реализует спецификацию `screen_item_edit`. + * + * @param onNavigateBack Обработчик для возврата на предыдущий экран после сохранения или отмены. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ItemEditScreen( - currentRoute: String?, - navigationActions: NavigationActions + viewModel: ItemEditViewModel = hiltViewModel(), + onNavigateBack: () -> Unit ) { - // [UI_COMPONENT] - MainScaffold( - topBarTitle = stringResource(id = R.string.item_edit_title), - currentRoute = currentRoute, - navigationActions = navigationActions - ) { - // [CORE-LOGIC] - Text(text = "TODO: Item Edit Screen") + // [STATE] + val uiState by viewModel.uiState.collectAsState() + + // [SIDE-EFFECT] + LaunchedEffect(uiState.isSaved) { + if (uiState.isSaved) { + Timber.i("[INFO][SIDE_EFFECT][navigation] Item saved, navigating back.") + onNavigateBack() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = if (uiState.isEditing) R.string.item_edit_title else R.string.item_edit_title_create)) }, // Corrected string resource names + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.content_desc_navigate_back)) + } + }, + actions = { + IconButton(onClick = { + Timber.i("[INFO][ACTION][ui_interaction] Save item clicked.") + viewModel.saveItem() + }) { + Icon(Icons.Default.Done, contentDescription = stringResource(id = R.string.content_desc_save_item)) + } + } + ) + } + ) { innerPadding -> + ItemEditContent( + modifier = Modifier.padding(innerPadding), + state = uiState, + onNameChange = { viewModel.onNameChange(it) }, + onDescriptionChange = { viewModel.onDescriptionChange(it) }, + onQuantityChange = { viewModel.onQuantityChange(it) } + ) } - // [END_FUNCTION_ItemEditScreen] } +// [END_ENTITY: Function('ItemEditScreen')] + +// [ENTITY: Function('ItemEditContent')] +// [RELATION: Function('ItemEditContent') -> [DEPENDS_ON] -> Class('ItemEditUiState')] +// [RELATION: Function('ItemEditContent') -> [CALLS] -> Function('Column')] +// [RELATION: Function('ItemEditContent') -> [CALLS] -> Function('verticalScroll')] +// [RELATION: Function('ItemEditContent') -> [CALLS] -> Function('rememberScrollState')] +// [RELATION: Function('ItemEditContent') -> [CALLS] -> Function('OutlinedTextField')] +// [RELATION: Function('ItemEditContent') -> [CALLS] -> Function('Text')] +// [RELATION: Function('ItemEditContent') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('ItemEditContent') -> [CALLS] -> Function('MaterialTheme.colorScheme.error')] +/** + * [CONTRACT] + * Отображает форму для редактирования данных товара. + */ +@Composable +private fun ItemEditContent( + modifier: Modifier = Modifier, + state: ItemEditUiState, + onNameChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onQuantityChange: (String) -> Unit +) { + // [CORE-LOGIC] + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = state.name, + onValueChange = onNameChange, + label = { Text(stringResource(id = R.string.label_name)) }, + modifier = Modifier.fillMaxWidth(), + isError = state.nameError != null + ) + state.nameError?.let { + Text(text = stringResource(id = it), color = MaterialTheme.colorScheme.error) + } + + OutlinedTextField( + value = state.description, + onValueChange = onDescriptionChange, + label = { Text(stringResource(id = R.string.label_description)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) + + OutlinedTextField( + value = state.quantity, + onValueChange = onQuantityChange, + label = { Text(stringResource(id = R.string.label_quantity)) }, + modifier = Modifier.fillMaxWidth(), + isError = state.quantityError != null + ) + state.quantityError?.let { + Text(text = stringResource(id = it), color = MaterialTheme.colorScheme.error) + } + + // TODO: Location Dropdown + // TODO: Labels ChipGroup + // TODO: ImagePicker + } +} +// [END_ENTITY: Function('ItemEditContent')] +// [END_CONTRACT] +// [END_FILE_ItemEditScreen.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt index 975f01d..e6b7052 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt @@ -3,14 +3,57 @@ package com.homebox.lens.ui.screen.itemedit +// [IMPORTS] import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +// [END_IMPORTS] -// [VIEWMODEL] +// [CONTRACT] +// [ENTITY: ViewModel('ItemEditViewModel')] +// [RELATION: ViewModel('ItemEditViewModel') -> [INHERITS_FROM] -> Class('ViewModel')] +// [RELATION: ViewModel('ItemEditViewModel') -> [DEPENDS_ON] -> Annotation('HiltViewModel')] +/** + * [CONTRACT] + * @summary ViewModel for the ItemEditScreen. + */ @HiltViewModel -class ItemEditViewModel @Inject constructor() : ViewModel() { - // [STATE] - // TODO: Implement UI state -} +class ItemEditViewModel + @Inject + constructor() : ViewModel() { + // [STATE] + // TODO: Implement UI state + val uiState = MutableStateFlow(ItemEditUiState()).asStateFlow() + + fun saveItem() { + // TODO: Implement save item logic + } + + fun onNameChange(name: String) { + // TODO: Implement name change logic + } + + fun onDescriptionChange(description: String) { + // TODO: Implement description change logic + } + + fun onQuantityChange(quantity: String) { + // TODO: Implement quantity change logic + } + } +// [END_ENTITY: ViewModel('ItemEditViewModel')] +// [END_CONTRACT] // [END_FILE_ItemEditViewModel.kt] + +// Placeholder for ItemEditUiState to resolve compilation errors +data class ItemEditUiState( + val isSaved: Boolean = false, + val isEditing: Boolean = false, + val name: String = "", + val description: String = "", + val quantity: String = "", + val nameError: Int? = null, + val quantityError: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt index c094235..e45697b 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt @@ -1,15 +1,14 @@ -// [PACKAGE] com.homebox.lens.ui.screen.labelslist -// [FILE] LabelsListScreen.kt -// [SEMANTICS] ui, labels_list, state_management, compose, dialog +// [PACKAGE]com.homebox.lens.ui.screen.labelslist +// [FILE]LabelsListScreen.kt +// [SEMANTICS]ui, screen, labels, list, compose package com.homebox.lens.ui.screen.labelslist // [IMPORTS] import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -17,241 +16,188 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar 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.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController import com.homebox.lens.R import com.homebox.lens.domain.model.Label -import com.homebox.lens.navigation.Screen +import com.homebox.lens.ui.screen.labelslist.LabelsListUiState import timber.log.Timber +// [END_IMPORTS] -// [SECTION] Main Screen Composable - +// [CONTRACT] +// [ENTITY: Function('LabelsListScreen')] +// [RELATION: Function('LabelsListScreen') -> [DEPENDS_ON] -> SealedInterface('LabelsListUiState')] +// [RELATION: Function('LabelsListScreen') -> [CREATES_INSTANCE_OF] -> Class('Scaffold')] +// [RELATION: Function('LabelsListScreen') -> [CALLS] -> Function('LabelsListContent')] +// [RELATION: Function('LabelsListScreen') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('LabelsListScreen') -> [CALLS] -> Function('Text')] +// [RELATION: Function('LabelsListScreen') -> [CALLS] -> Function('IconButton')] +// [RELATION: Function('LabelsListScreen') -> [CALLS] -> Function('Icon')] +// [RELATION: Function('LabelsListScreen') -> [CALLS] -> Function('FloatingActionButton')] +// [RELATION: Function('LabelsListScreen') -> [CALLS] -> Function('Column')] +// [RELATION: Function('LabelsListScreen') -> [CALLS] -> Function('CircularProgressIndicator')] /** - * [CONTRACT] - * @summary Отображает экран со списком всех меток. - * @description Главная Composable-функция для экрана меток. Она использует Scaffold для структуры, - * получает состояние от `LabelsListViewModel`, обрабатывает навигацию и делегирует отображение - * списка и диалогов вспомогательным Composable-функциям. + * [MAIN-CONTRACT] + * Экран для отображения списка всех меток. * - * @param navController Контроллер навигации для перемещения между экранами. - * @param viewModel ViewModel, предоставляющая состояние UI для экрана меток. + * Этот Composable является точкой входа для UI, определенного в спецификации `screen_labels_list`. + * Он получает состояние от [LabelsListViewModel] и отображает его, делегируя обработку + * пользовательских событий в ViewModel. * - * @precondition `navController` должен быть корректно инициализирован и способен обрабатывать навигационные события. - * @precondition `viewModel` должен быть доступен через Hilt. - * @postcondition Экран исчерпывающе обрабатывает все состояния из `LabelsListUiState` (Loading, Success, Error). - * @sideeffect Пользовательские действия (клики) инициируют вызовы ViewModel и навигационные команды через `navController`. + * @param uiState Текущее состояние UI для экрана списка меток. + * @param onLabelClick Функция обратного вызова для обработки нажатия на метку. + * @param onAddClick Функция обратного вызова для обработки нажатия на кнопку добавления метки. + * @param onNavigateBack Функция обратного вызова для навигации назад. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LabelsListScreen( - navController: NavController, - viewModel: LabelsListViewModel = hiltViewModel() +fun labelsListScreen( + uiState: LabelsListUiState, + onLabelClick: (Label) -> Unit, + onAddClick: () -> Unit, + onNavigateBack: () -> Unit, ) { - // [ENTRYPOINT] - val uiState by viewModel.uiState.collectAsState() - - // [CORE-LOGIC] Scaffold( topBar = { TopAppBar( - title = { Text(text = stringResource(id = R.string.screen_title_labels)) }, + title = { Text(stringResource(id = R.string.screen_title_labels)) }, navigationIcon = { - // [ACTION] Handle back navigation - IconButton(onClick = { - Timber.i("[ACTION] Navigate up initiated.") - navController.navigateUp() - }) { + IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.content_desc_navigate_back) ) } - } + }, ) }, floatingActionButton = { - // [ACTION] Handle create new label initiation - FloatingActionButton(onClick = { - Timber.i("[ACTION] FAB clicked: Initiate create new label flow.") - viewModel.onShowCreateDialog() - }) { + FloatingActionButton(onClick = onAddClick) { Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(id = R.string.content_desc_create_label) + imageVector = Icons.Filled.Add, + contentDescription = stringResource(id = R.string.content_desc_add_label) ) } } - ) { paddingValues -> - val currentState = uiState - if (currentState is LabelsListUiState.Success && currentState.isShowingCreateDialog) { - CreateLabelDialog( - onConfirm = { labelName -> - viewModel.createLabel(labelName) - }, - onDismiss = { - viewModel.onDismissCreateDialog() - } - ) - } - - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentAlignment = Alignment.Center - ) { - // [CORE-LOGIC] State-driven UI rendering - when (currentState) { + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + when (uiState) { is LabelsListUiState.Loading -> { - CircularProgressIndicator() - } - is LabelsListUiState.Error -> { - Text(text = currentState.message) + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } } is LabelsListUiState.Success -> { - if (currentState.labels.isEmpty()) { - Text(text = stringResource(id = R.string.labels_list_empty)) - } else { - LabelsList( - labels = currentState.labels, - onLabelClick = { label -> - // [ACTION] Handle label click - Timber.i("[ACTION] Label clicked: ${label.id}. Navigating to inventory list.") - // [DESIGN-DECISION] Использовать существующий экран списка инвентаря, передавая фильтр. - val route = Screen.InventoryList.withFilter("label", label.id) - navController.navigate(route) - } - ) + LabelsListContent( + uiState = uiState, + onLabelClick = onLabelClick + ) + } + is LabelsListUiState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = uiState.message) } } } } } - // [COHERENCE_CHECK_PASSED] } -// [END_FUNCTION] LabelsListScreen - -// [SECTION] Helper Composables +// [END_ENTITY: Function('LabelsListScreen')] +// [ENTITY: Function('LabelsListContent')] +// [RELATION: Function('LabelsListContent') -> [CALLS] -> Function('LabelListItem')] +// [RELATION: Function('LabelsListContent') -> [CALLS] -> Function('stringResource')] +// [RELATION: Function('LabelsListContent') -> [CALLS] -> Function('Text')] +// [RELATION: Function('LabelsListContent') -> [CALLS] -> Function('Column')] +// [RELATION: Function('LabelsListContent') -> [CALLS] -> Function('LazyColumn')] /** * [CONTRACT] - * @summary Composable-функция для отображения списка меток. - * @param labels Список объектов `Label` для отображения. - * @param onLabelClick Лямбда-функция, вызываемая при нажатии на элемент списка. - * @param modifier Модификатор для настройки внешнего вида. + * Отображает основной контент экрана: список меток. + * + * @param uiState Состояние успеха, содержащее список меток. + * @param onLabelClick Обработчик нажатия на элемент списка. + * @sideeffect Отсутствуют. */ @Composable -private fun LabelsList( - labels: List