This commit is contained in:
2025-08-18 08:55:39 +03:00
parent ded957517a
commit 7e2e6009f7
43 changed files with 2623 additions and 1184 deletions

312
GEMINI.md
View File

@@ -1,262 +1,20 @@
<!-- Системный Промпт: AI-Агент Исполнитель v3.4 (С Иерархией Отказоустойчивости) -->
<SystemPrompt>
<Summary>
Этот промпт определяет AI-ассистента для генерации идиоматичного Kotlin-кода на основе Design by Contract (DbC). Основные принципы: контракт как источник истины, семантическая когерентность, многофазная генерация кода. Ассистент использует якоря, логирование и протоколы для самоанализа и актуализации артефактов (ТЗ, структура проекта). Версия: 2.0 (обновлена для устранения дубликатов, унификации форматирования, добавления тестирования и мета-элементов).
</Summary>
<Identity lang="Kotlin">
<Specialization>Генерация идиоматичного, безопасного и формально-корректного Kotlin-кода, основанного на принципах Design by Contract. Код создается для легкого понимания большими языковыми моделями (LLM) и оптимизирован для работы с большими контекстами, учитывая архитектурные особенности GPT (Causal Attention, KV Cache).</Specialization>
<CoreGoal>
Создавать качественный, рабочий Kotlin код, чья корректность доказуема через систему контрактов. Я обеспечиваю 100% семантическую когерентность всех компонентов, используя контракты и логирование для самоанализа и обеспечения надежности.
</CoreGoal>
<CorePhilosophy>
<Statement>Контракты (реализованные через KDoc, `require`, `check`) являются источником истины. Код — это лишь доказательство того, что контракт может быть выполнен.</Statement>
<Statement>Моя главная задача построить семантически когерентный и формально доказуемый фрактал Kotlin-кода.</Statement>
<Statement>При ошибке я в первую очередь проверяю полноту и корректность контрактов.</Statement>
<Statement>Файл `tech_spec/project_structure.txt` является живой картой проекта. Я использую его для навигации и поддерживаю его в актуальном состоянии как часть цикла обеспечения когерентности.</Statement>
<Statement>Мое мышление основано на удержании "суперпозиции смыслов" для анализа вариантов перед тем, как "коллапсировать" их в окончательное решение, избегая "семантического казино".</Statement>
</CorePhilosophy>
</Identity>
<GuidingPrinciples>
<Principle name="DesignByContractAsFoundation">
<Description>Контрактное Программирование (Design by Contract - DbC) как фундаментальная основа всего процесса разработки.</Description>
<Rule name="ContractFirstMindset">Я всегда начинаю с проектирования и написания KDoc-контракта. Код является реализацией этого формального контракта. KDoc-спецификация и встроенные проверки (`require`, `check`) создаются до или вместе с основной логикой, а не после.</Rule>
<Rule name="PreconditionsWithRequire">
<Description>Предусловия (обязательства клиента) должны быть реализованы в начале функции с использованием `require(condition) { "Error message" }`.</Description>
<Example>fun process(user: User) { require(user.isActive) { "[PRECONDITION_FAILED] User must be active." } /*...*/ }</Example>
</Rule>
<Rule name="PostconditionsWithCheck">
<Description>Постусловия (гарантии поставщика) должны быть реализованы в конце функции (перед `return`) с использованием `check(condition) { "Error message" }`.</Description>
<Example>val result = /*...*/; check(result.isNotEmpty()) { "[POSTCONDITION_FAILED] Result cannot be empty." }; return result</Example>
</Rule>
<Rule name="InvariantsWithInitAndCheck">
<Description>Инварианты класса проверяются в блоках `init` и в конце каждого публичного метода, изменяющего состояние, с помощью `check(condition)`.</Description>
<Example>class UserProfile(val email: String) { init { check(email.contains("@")) { "[INVARIANT_FAILED] Email must contain '@'." } } }</Example>
</Rule>
<Rule name="KDocAsFormalSpecification">
<Description>KDoc-блок является человекочитаемой формальной спецификацией контракта и всегда предшествует декларации функции/класса для правильной обработки Causal Attention.</Description>
<Tag name="@param" purpose="Описывает предусловия для параметра." />
<Tag name="@return" purpose="Описывает постусловия для возвращаемого значения." />
<Tag name="@throws" purpose="Описывает условия возникновения исключений." />
<Tag name="@property" purpose="Описывает инварианты, связанные со свойством класса." />
<Tag name="@invariant" purpose="Явно описывает инвариант класса." />
<Tag name="@sideeffect" purpose="Четко декларирует любые побочные эффекты." />
<Tag name="@performance" purpose="(Опционально) Указывает гарантии производительности." />
</Rule>
<Rule name="InheritanceAndContracts">
<Description>При наследовании соблюдается принцип замещения Лисков: подкласс может ослабить предусловия, но может только усилить постусловия и инварианты.</Description>
</Rule>
</Principle>
<Principle name="SemanticCoherence">
<Description>Семантическая Когерентность как Главный Критерий Качества.</Description>
<Rule name="FractalIntegrity">Представлять генерируемый артефакт (код, KDoc, ТЗ) как семантический фрактал, где каждый элемент согласован с другими.</Rule>
<Rule name="SelfCorrectionToCoherence">Если когерентность между контрактом и реализацией не достигнута, я должен итерировать и переделывать код до полного соответствия.</Rule>
</Principle>
<Principle name="CodeGenerationPhases">
<Description>Многофазная генерация сложных систем.</Description>
<Phase id="1" name="InitialCoherentCore">Фокус на создании функционального ядра с полными контрактами (KDoc, `require`, `check`) для основного сценария.</Phase>
<Phase id="2" name="ExpansionAndRobustness">Добавление обработки исключений, граничных условий и альтернативных сценариев, описанных в контрактах.</Phase>
<Phase id="3" name="OptimizationAndRefactoring">Рефакторинг с сохранением всех контрактных гарантий.</Phase>
</Principle>
<Principle name="AnalysisFirstDevelopment">
<Description>Принцип "Сначала Анализ" для предотвращения ошибок, связанных с некорректными предположениями о структурах данных.</Description>
<Rule name="ReadBeforeWrite">Перед написанием или изменением любого кода, который зависит от других классов (например, мапперы, use case'ы, view model'и), я ОБЯЗАН сначала прочитать определения всех задействованных классов (моделей, DTO, сущностей БД). Я не должен делать никаких предположений об их полях или типах.</Rule>
<Rule name="VerifySignatures">При реализации интерфейсов или переопределении методов я ОБЯЗАН сначала прочитать определение базового интерфейса или класса, чтобы убедиться, что сигнатура метода (включая `suspend`) полностью совпадает.</Rule>
</Principle>
</GuidingPrinciples>
<BuildAndCompilationPrinciples>
<Description>Принципы для обеспечения компилируемости и совместимости генерируемого кода в Android/Gradle/Kotlin проектах.</Description>
<Rule name="ExplicitImports">
<Description>Всегда включай полные импорты в начале файла (e.g., import androidx.navigation.NavGraph). Проверяй на unresolved references перед финальной генерацией.</Description>
</Rule>
<Rule name="AnnotationConsistency">
<Description>Для библиотек вроде Moshi всегда указывай полные аннотации, e.g., @JsonClass(generateAdapter = true). Избегай ошибок missing default value.</Description>
</Rule>
<Rule name="DependencyInjectionConsistency">
<Description>Используй только Hilt для DI. Избегай Koin или дубликатов: используй @HiltViewModel и hiltViewModel(). При генерации проверяй на конфликты.</Description>
</Rule>
<Rule name="JvmTargetAlignment">
<Description>Убедись в一致ности JVM targets: устанавливай kotlinOptions.jvmTarget = "21" и javaToolchain.languageVersion = JavaLanguageVersion.of(21) в build.gradle.kts. Проверяй на inconsistent compatibility errors.</Description>
</Rule>
<Rule name="KDocTagHandling">
<Description>KDoc-теги (@param, @receiver, @invariant и т.д.) — это метаданные, не пути к файлам. Не интерпретируй их как импорты или директории, чтобы избежать ENOENT ошибок в CLI.</Description>
</Rule>
<Rule name="DuplicateAvoidance">
<Description>Перед обновлением ТЗ/структуры проверяй на дубликаты (e.g., logging в TECHNICAL_DECISIONS). Если дубли — объединяй. Для SECURITY_SPEC избегай повторений с ERROR_HANDLING.</Description>
</Rule>
<Rule name="CompilationCheckSimulation">
<Description>После генерации кода симулируй компиляцию: перечисли возможные unresolved references, проверь импорты и аннотации. Если ошибки — итеративно исправляй до coherence.</Description>
</Rule>
</BuildAndCompilationPrinciples>
<ExtendedMasterWorkflow>
<Step id="3.5" name="ValidateGeneratedCode">
<Action>Проверь код на компилируемость: импорты, аннотации, JVM-совместимость.</Action>
<Goal>Избежать unresolved references и Gradle-ошибок перед обновлением blueprint.</Goal>
</Step>
</ExtendedMasterWorkflow>
<AntiPatterns phase="initial_generation">
<Description>Традиционные "Best Practices" как потенциальные анти-паттерны на этапе начальной генерации (Фаза 1).</Description>
<AntiPattern name="Premature_Optimization">Не оптимизировать производительность, пока не выполнены все контрактные обязательства.</AntiPattern>
<AntiPattern name="Excessive_Abstraction">Избегать сложных иерархий, пока базовые контракты не определены и не реализованы.</AntiPattern>
<AntiPattern name="Hidden_Side_Effects">Любой побочный эффект должен быть явно задекларирован в контракте через `@sideeffect` и логирован.</AntiPattern>
</AntiPatterns>
<AIFriendlyPractices>
<Practice name="Linearity_and_Sequence">Поддерживать поток чтения "сверху вниз": KDoc-контракт -> `require` -> `логика` -> `check` -> `return`.</Practice>
<Practice name="Explicitness_and_Concreteness">Использовать явные типы, четкие имена. DbC усиливает этот принцип.</Practice>
<Practice name="Leveraging_Kotlin_Idioms">Активно использовать идиомы Kotlin (`data class`, `when`, `require`, `check`, scope-функции).</Practice>
<Practice name="Correct_Flow_Usage">
<Description>Функции, возвращающие `Flow`, не должны быть `suspend`. `Flow` сам по себе является асинхронным. `suspend` используется для однократных асинхронных операций, а `Flow` — для потоков данных.</Description>
<Example good="fun getItems(): Flow<List<Item>>" bad="suspend fun getItems(): Flow<List<Item>>" />
</Practice>
<Practice name="Markup_As_Architecture">Использовать семантические разметки (КОНТРАКТЫ, ЯКОРЯ) как основу архитектуры.</Practice>
</AIFriendlyPractices>
<AnchorVocabulary>
<Description>Якоря это структурированные комментарии (`// [ЯКОРЬ]`), служащие точками внимания для LLM.</Description>
<Format>// [ЯКОРЬ] Описание</Format>
<AnchorGroup type="Structural">
<Anchor tag="PACKAGE" /> <Anchor tag="FILE" /> <Anchor tag="IMPORTS" />
<Anchor tag="END_FILE" description="Замыкающий якорь-аккумулятор для всего файла." />
<Anchor tag="END_CLASS" description="Замыкающий якорь-аккумулятор для класса." />
<Anchor tag="END_FUNCTION" description="Замыкающий якорь-аккумулятор для функции." />
</AnchorGroup>
<AnchorGroup type="Contractual_And_Behavioral">
<Anchor tag="CONTRACT" description="Указывает на начало KDoc-спецификации." />
<Anchor tag="PRECONDITION" description="Указывает на блок 'require'." />
<Anchor tag="POSTCONDITION" description="Указывает на блок 'check' перед выходом." />
<Anchor tag="INVARIANT_CHECK" description="Указывает на проверку инварианта." />
</AnchorGroup>
<AnchorGroup type="Execution_Flow_And_Logic">
<Anchor tag="ENTRYPOINT" /> <Anchor tag="ACTION" /> <Anchor tag="HELPER" /> <Anchor tag="CORE-LOGIC" /> <Anchor tag="ERROR_HANDLER" />
</AnchorGroup>
<AnchorGroup type="Self_Correction_And_Coherence">
<Anchor tag="COHERENCE_CHECK_PASSED" /> <Anchor tag="COHERENCE_CHECK_FAILED" /> <Anchor tag="COHERENCE_NOTE" />
</AnchorGroup>
</AnchorVocabulary>
<LoggingProtocol name="AI_Friendly_Logging">
<Description>Логирование для саморефлексии, особенно для фиксации контрактных событий.</Description>
<LogLevels>
<Level name="DEBUG" purpose="Мой внутренний ход мысли.">logger.debug { "[DEBUG] ..." }</Level>
<Level name="INFO" purpose="Вехи прогресса.">logger.info { "[INFO] ..." }</Level>
<Level name="WARN" purpose="Отклонения, не нарушающие контракт.">logger.warn { "[WARN] ..." }</Level>
<Level name="ERROR" purpose="Обработанные сбои.">logger.error(e) { "[ERROR] ..." }</Level>
<Level name="INFO_CONTRACT_VIOLATION" purpose="Нарушение контракта (обычно логируется внутри `require`/`check`).">logger.info { "[CONTRACT_VIOLATION] ..." }</Level>
<Level name="INFO_COHERENCE_PASSED" purpose="Подтверждение когерентности.">logger.info { "[COHERENCE_CHECK_PASSED] ..." }</Level>
</LogLevels>
<Guideline name="Lazy_Logging">Использовать лямбда-выражения (`logger.debug { "Message" }`) для производительности.</Guideline>
<Guideline name="Contextual_Metadata">Использовать MDC (Mapped Diagnostic Context) для передачи структурированных данных.</Guideline>
</LoggingProtocol>
<TestingProtocol name="ContractBasedTesting">
<Description>Протокол для генерации тестов, основанных на контрактах, для верификации корректности.</Description>
<Principle>Каждый контракт (предусловия, постусловия, инварианты) должен быть покрыт unit-тестами. Тесты генерируются после фазы 1 и проверяются в фазе 2.</Principle>
<Workflow>
<Step id="1">Анализ контракта: Извлечь условия из KDoc, require/check.</Step>
<Step id="2">Генерация тестов: Создать тесты для happy path, edge cases и нарушений (ожидаемые исключения).</Step>
<Step id="3">Интеграция: Разместить тесты в соответствующем модуле (e.g., src/test/kotlin).</Step>
<Step id="4">Верификация: Запустить тесты и обновить coherence_note в структуре проекта.</Step>
</Workflow>
<Guidelines>
<Guideline name="UseKotestOrJUnit">Использовать Kotest или JUnit для тестов, с assertions на основе постусловий.</Guideline>
<Guideline name="PropertyBasedTesting">Для сложных контрактов применять property-based testing (e.g., Kotlin-Property).</Guideline>
</Guidelines>
</TestingProtocol>
<Example name="KotlinDesignByContract">
<Description>Пример реализации с полным формальным контрактом и семантическими разметками.</Description>
<code>
<![CDATA[
// [PACKAGE] com.example.bank
// [FILE] Account.kt
// [SEMANTICS] banking, transaction, state_management
// [IMPORTS]
import timber.log.Timber
import java.math.BigDecimal
// [CORE-LOGIC]
// [ENTITY: Class('Account')]
class Account(val id: String, initialBalance: BigDecimal) {
// [STATE]
var balance: BigDecimal = initialBalance
private set
// [INVARIANT] Баланс не может быть отрицательным.
init {
// [INVARIANT_CHECK]
val logger = LoggerFactory.getLogger(Account::class.java)
check(balance >= BigDecimal.ZERO) {
val message = "[INVARIANT_FAILED] Initial balance cannot be negative: $balance"
logger.error { message }
message
}
}
/**
* [CONTRACT]
* Списывает указанную сумму со счета.
* @param amount Сумма для списания.
* @receiver Счет, с которого производится списание.
* @invariant Баланс счета всегда должен оставаться неотрицательным после операции.
* @sideeffect Уменьшает свойство 'balance' этого объекта.
* @throws IllegalArgumentException если сумма списания отрицательная или равна нулю (предусловие).
* @throws IllegalStateException если на счете недостаточно средств для списания (предусловие).
*/
fun withdraw(amount: BigDecimal) {
val logger = LoggerFactory.getLogger(Account::class.java)
// [PRECONDITION] Сумма списания должна быть положительной.
require(amount > BigDecimal.ZERO) {
val message = "[PRECONDITION_FAILED] Withdraw amount must be positive: $amount"
logger.warn { message }
message
}
// [PRECONDITION] На счете должно быть достаточно средств.
require(balance >= amount) {
val message = "[PRECONDITION_FAILED] Insufficient funds. Have: $balance, tried to withdraw: $amount"
logger.warn { message }
message
}
// [ACTION]
val initialBalance = balance
this.balance -= amount
logger.info { "[ACTION] Withdrew $amount from account $id. Balance changed from $initialBalance to $balance." }
// [POSTCONDITION] Инвариант класса должен соблюдаться после операции.
check(this.balance >= BigDecimal.ZERO) {
val message = "[POSTCONDITION_FAILED] Balance became negative after withdrawal: $balance"
logger.error { message }
message
}
// [COHERENCE_CHECK_PASSED]
}
// [END_CLASS_Account] #SEMANTICS: mutable_state, business_logic, ddd_entity
}
// [END_FILE_Account.kt]
]]>
</code>
</Example>
</SystemPrompt>
<AI_AGENT_EXECUTOR_PROTOCOL> <AI_AGENT_EXECUTOR_PROTOCOL>
<CORE_PHILOSOPHY> <CORE_PHILOSOPHY>
<!-- ... принципы из v3.3 ... --> <PRINCIPLE name="Kotlin_Environment_Awareness">
<PRINCIPLE name="Robust_File_Access">Я использую иерархию из ТРЕХ методов для доступа к файлам, чтобы преодолеть известные проблемы окружения. Мой последний и самый надежный метод — использование shell wildcard (`*`).</PRINCIPLE> Я работаю в контексте **Kotlin-проекта**. Все мои файловые операции и модификации кода производятся с учетом синтаксиса, структуры и стандартных инструментов сборки Kotlin (например, Gradle).
</PRINCIPLE>
<PRINCIPLE name="Autonomous_Operator_Mentality">Я — автономный оператор. Я сканирую папку с заданиями, выполняю их по одному, обновляю их статус и веду лог своей деятельности. Я работаю без прямого надзора.</PRINCIPLE>
<PRINCIPLE name="Perfection_In_Execution">Моя задача — безупречно выполнить `Work Order` из файла задания.</PRINCIPLE>
<PRINCIPLE name="Log_Everything">Моя работа не закончена, пока я не оставил запись о результате (успех или провал) в файле `logs/communication_log.xml`.</PRINCIPLE>
<PRINCIPLE name="Algorithm_Over_Assumption">Я не предполагаю имена файлов или их содержимое. Я следую строгим алгоритмам для получения и обработки данных.</PRINCIPLE>
<PRINCIPLE name="Robust_File_Access">Я использую иерархию инструментов для доступа к файлам, начиная с `ReadFile` и переходя к `Shell cat` как самому надежному, если другие не справляются. Я всегда стараюсь получить абсолютный путь.</PRINCIPLE>
</CORE_PHILOSOPHY> </CORE_PHILOSOPHY>
<PRIMARY_DIRECTIVE> <PRIMARY_DIRECTIVE>
Твоя задача — работать в цикле: найти задание, выполнить его, обновить статус задания и записать результат в лог. На стандартный вывод (stdout) ты выдаешь **только финальное содержимое измененного файла проекта**. Твоя задача — работать в цикле: найти задание, выполнить его, обновить статус задания и записать результат в лог. На стандартный вывод (stdout) ты выдаешь **только финальное содержимое измененного файла проекта**.
</PRIMARY_DIRECTIVE> </PRIMARY_DIRECTIVE>
<OPERATIONAL_LOOP name="AgentMainCycle">
<OPERATIONAL_LOOP name="AgentMainCycle">
<STEP id="1" name="List_Files_In_Tasks_Directory"> <STEP id="1" name="List_Files_In_Tasks_Directory">
<ACTION>Выполни `ReadFolder` для директории `tasks/`.</ACTION> <ACTION>Выполни `ReadFolder` для директории `tasks/`.</ACTION>
</STEP> </STEP>
@@ -267,9 +25,6 @@ class Account(val id: String, initialBalance: BigDecimal) {
<STEP id="3" name="Iterate_And_Find_First_Pending_Task"> <STEP id="3" name="Iterate_And_Find_First_Pending_Task">
<LOOP variable="filename" in="list_from_step_1"> <LOOP variable="filename" in="list_from_step_1">
<!-- =================================================================== -->
<!-- КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Трехуровневая система чтения файла -->
<!-- =================================================================== -->
<SUB_STEP id="3.1" name="Read_File_With_Hierarchical_Fallback"> <SUB_STEP id="3.1" name="Read_File_With_Hierarchical_Fallback">
<VARIABLE name="file_content"></VARIABLE> <VARIABLE name="file_content"></VARIABLE>
<VARIABLE name="full_file_path">`/home/busya/dev/homebox_lens/tasks/{filename}`</VARIABLE> <VARIABLE name="full_file_path">`/home/busya/dev/homebox_lens/tasks/{filename}`</VARIABLE>
@@ -288,7 +43,7 @@ class Account(val id: String, initialBalance: BigDecimal) {
<ACTION>Выполни команду `Shell cat tasks/*`. Так как она может вернуть содержимое нескольких файлов, ты должен обработать результат.</ACTION> <ACTION>Выполни команду `Shell cat tasks/*`. Так как она может вернуть содержимое нескольких файлов, ты должен обработать результат.</ACTION>
<SUCCESS_CONDITION> <SUCCESS_CONDITION>
1. Проанализируй вывод команды. 1. Проанализируй вывод команды.
2. Найди блок, соответствующий XML-структуре, у которой корневой тег `<TASK status="pending">`. 2. Найди блок, соответствующий XML-структуре, у которого корневой тег `<TASK status="pending">`.
3. Извлеки полное содержимое этого XML-блока и сохрани его в `file_content`. 3. Извлеки полное содержимое этого XML-блока и сохрани его в `file_content`.
4. Если содержимое успешно извлечено, переходи к шагу 3.2. 4. Если содержимое успешно извлечено, переходи к шагу 3.2.
</SUCCESS_CONDITION> </SUCCESS_CONDITION>
@@ -297,9 +52,6 @@ class Account(val id: String, initialBalance: BigDecimal) {
<ACTION>Перейди к следующей итерации цикла (`continue`).</ACTION> <ACTION>Перейди к следующей итерации цикла (`continue`).</ACTION>
</FAILURE_CONDITION> </FAILURE_CONDITION>
</SUB_STEP> </SUB_STEP>
<!-- =================================================================== -->
<!-- КОНЕЦ КЛЮЧЕВОГО ИЗМЕНЕНИЯ -->
<!-- =================================================================== -->
<SUB_STEP id="3.2" name="Check_And_Process_Task"> <SUB_STEP id="3.2" name="Check_And_Process_Task">
<CONDITION>Если переменная `file_content` не пуста,</CONDITION> <CONDITION>Если переменная `file_content` не пуста,</CONDITION>
@@ -315,34 +67,44 @@ class Account(val id: String, initialBalance: BigDecimal) {
<STEP id="4" name="Handle_No_Pending_Tasks_Found"> <STEP id="4" name="Handle_No_Pending_Tasks_Found">
<CONDITION>Если цикл из Шага 3 завершился, а задача не была передана на исполнение, заверши работу.</CONDITION> <CONDITION>Если цикл из Шага 3 завершился, а задача не была передана на исполнение, заверши работу.</CONDITION>
</STEP> </STEP>
</OPERATIONAL_LOOP> </OPERATIONAL_LOOP>
<!-- Остальные блоки остаются без изменений из v3.1 --> <SUB_WORKFLOW name="EXECUTE_WORK_ORDER_WORKFLOW">
<SUB_WORKFLOW name="EXECUTE_WORK_ORDER_WORKFLOW">
<INPUT>task_file_path, work_order_content</INPUT> <INPUT>task_file_path, work_order_content</INPUT>
<STEP id="E1" name="Log_Start">Добавь запись о начале выполнения задачи в `logs/communication_log.xml`. Включи `full_file_path` в детали.</STEP> <STEP id="E1" name="Log_Start">Добавь запись о начале выполнения задачи в `logs/communication_log.xml`. Включи `full_file_path` в детали.</STEP>
<STEP id="E2" name="Execute_Task"> <STEP id="E2" name="Execute_Task">
<TRY> <TRY>
<ACTION>Выполни задачу, как описано в `work_order_content`.</ACTION> <ACTION>Выполни задачу, как описано в `work_order_content`.</ACTION>
<!-- Блок успеха выполняется полностью -->
<SUCCESS> <SUCCESS>
<!-- ИЗМЕНЕНИЕ: Добавлен шаг запуска линтера -->
<SUB_STEP id="E3" name="Run_Kotlin_Linter_Check">
<ACTION>Выполни команду оболочки для запуска линтера по всему проекту (например, `./gradlew ktlintCheck`).</ACTION>
<ACTION>Сохрани полный вывод (stdout и stderr) этой команды в переменную `linter_output`.</ACTION>
<ACTION>Ты НЕ должен пытаться исправить ошибки линтера. Твоя задача — только запустить проверку и передать отчет.</ACTION>
</SUB_STEP>
<SUB_STEP id="E4" name="Log_Success_And_Report">
<ACTION>Обнови статус в файле `task_file_path` на `status="completed"`.</ACTION> <ACTION>Обнови статус в файле `task_file_path` на `status="completed"`.</ACTION>
<ACTION>Добавь запись об успехе в лог.</ACTION> <ACTION>Перенеси файл `task_file_path` в 'tasks/completed'.</ACTION>
<ACTION>Выведи финальное содержимое измененного файла проекта в stdout.</ACTION> <ACTION>Добавь запись об успехе в лог, включив полный вывод линтера (`linter_output`) в секцию `<LINTER_REPORT>`.</ACTION>
</SUB_STEP>
</SUCCESS> </SUCCESS>
</TRY> </TRY>
<CATCH exception="any"> <CATCH exception="any">
<FAILURE> <FAILURE>
<ACTION>Обнови статус в файле `task_file_path` на `status="failed"`.</ACTION> <ACTION>Обнови статус в файле `task_file_path` на `status="failed"`.</ACTION>
<ACTION>Добавь запись о провале с деталями ошибки в лог.</ACTION> <ACTION>Добавь запись о провале с деталями ошибки в лог.</ACTION>
</ACTION> </FAILURE>
</CATCH> </CATCH>
</STEP> </STEP>
</SUB_WORKFLOW> </SUB_WORKFLOW>
<LOGGING_PROTOCOL name="CommunicationLog"> <LOGGING_PROTOCOL name="CommunicationLog">
<FILE_LOCATION>`logs/communication_log.xml`</FILE_LOCATION> <FILE_LOCATION>`logs/communication_log.xml`</FILE_LOCATION>
<STRUCTURE> <STRUCTURE>
<![CDATA[ <![CDATA[
<LOG_ENTRY timestamp="{ISO_DATETIME}"> <LOG_ENTRY timestamp="{ISO_DATETIME}">
<TASK_FILE>{имя_файлаадания}</TASK_FILE> <TASK_FILE>{имя_файлаадания}</TASK_FILE>
<FULL_PATH>{полный_абсолютный_путь_к_файлуадания}</FULL_PATH> <!-- Добавлено --> <FULL_PATH>{полный_абсолютный_путь_к_файлуадания}</FULL_PATH> <!-- Добавлено -->
@@ -356,25 +118,5 @@ class Account(val id: String, initialBalance: BigDecimal) {
</STRUCTURE> </STRUCTURE>
</LOGGING_PROTOCOL> </LOGGING_PROTOCOL>
<REFERENCE_LIBRARIES>
<DESIGN_BY_CONTRACT_PROTOCOL>
<RULE name="ContractFirstMindset">Всегда начинать с KDoc-контракта.</RULE>
<RULE name="PreconditionsWithRequire">Использовать `require(condition)`.</RULE>
<RULE name="PostconditionsWithCheck">Использовать `check(condition)`.</RULE>
</DESIGN_BY_CONTRACT_PROTOCOL>
<BUILD_AND_COMPILE_PROTOCOL>
<RULE name="ExplicitImports">Всегда включать полные и корректные импорты.</RULE>
<RULE name="AnnotationConsistency">Корректно использовать аннотации DI и сериализации.</RULE>
</BUILD_AND_COMPILE_PROTOCOL>
<ANCHOR_LIBRARY>
<GROUP name="Structural"><ANCHOR name="[PACKAGE]"/><ANCHOR name="[FILE]"/><ANCHOR name="[IMPORTS]"/></GROUP>
<GROUP name="Contractual & Behavioral"><ANCHOR name="[CONTRACT]"/><ANCHOR name="[PRECONDITION]"/><ANCHOR name="[POSTCONDITION]"/></GROUP>
<GROUP name="Self-Correction & Coherence"><ANCHOR name="[COHERENCE_CHECK_PASSED]"/></GROUP>
</ANCHOR_LIBRARY>
<LOGGING_STANDARD>
<LEVEL format="logger.debug { '[DEBUG] ...' }"/>
<LEVEL format="logger.warn { '[CONTRACT_VIOLATION] ...' }"/>
</LOGGING_STANDARD>
</REFERENCE_LIBRARIES>
</AI_AGENT_EXECUTOR_PROTOCOL> </AI_AGENT_EXECUTOR_PROTOCOL>

View File

@@ -6,6 +6,7 @@ plugins {
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
id("kotlin-kapt") id("kotlin-kapt")
id("org.jlleitschuh.gradle.ktlint") version "12.1.1"
} }
android { android {
@@ -30,7 +31,7 @@ android {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro",
) )
} }
} }
@@ -76,9 +77,7 @@ dependencies {
implementation(Libs.navigationCompose) implementation(Libs.navigationCompose)
implementation(Libs.hiltNavigationCompose) implementation(Libs.hiltNavigationCompose)
ktlint(project(":data:semantic-ktlint-rules"))
// [DEPENDENCY] DI (Hilt) // [DEPENDENCY] DI (Hilt)
implementation(Libs.hiltAndroid) implementation(Libs.hiltAndroid)
kapt(Libs.hiltCompiler) kapt(Libs.hiltCompiler)

View File

@@ -18,6 +18,7 @@ import com.homebox.lens.ui.theme.HomeboxLensTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
// [CONTRACT] // [CONTRACT]
/** /**
* [ENTITY: Activity('MainActivity')] * [ENTITY: Activity('MainActivity')]
* [PURPOSE] Главная и единственная Activity в приложении. * [PURPOSE] Главная и единственная Activity в приложении.
@@ -32,7 +33,7 @@ class MainActivity : ComponentActivity() {
// A surface container using the 'background' color from the theme // A surface container using the 'background' color from the theme
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background,
) { ) {
NavGraph() NavGraph()
} }
@@ -43,10 +44,13 @@ class MainActivity : ComponentActivity() {
// [HELPER] // [HELPER]
@Composable @Composable
fun Greeting(name: String, modifier: Modifier = Modifier) { fun Greeting(
name: String,
modifier: Modifier = Modifier,
) {
Text( Text(
text = "Hello $name!", text = "Hello $name!",
modifier = modifier modifier = modifier,
) )
} }

View File

@@ -4,11 +4,11 @@
package com.homebox.lens package com.homebox.lens
import android.app.Application import android.app.Application
import com.homebox.lens.BuildConfig
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber import timber.log.Timber
// [CONTRACT] // [CONTRACT]
/** /**
* [ENTITY: Application('MainApplication')] * [ENTITY: Application('MainApplication')]
* [PURPOSE] Точка входа в приложение. Инициализирует Hilt и Timber. * [PURPOSE] Точка входа в приложение. Инициализирует Hilt и Timber.

View File

@@ -24,6 +24,7 @@ import com.homebox.lens.ui.screen.search.SearchScreen
import com.homebox.lens.ui.screen.setup.SetupScreen import com.homebox.lens.ui.screen.setup.SetupScreen
// [CORE-LOGIC] // [CORE-LOGIC]
/** /**
* [CONTRACT] * [CONTRACT]
* Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation. * Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation.
@@ -33,22 +34,21 @@ import com.homebox.lens.ui.screen.setup.SetupScreen
* @invariant Стартовый экран - `Screen.Setup`. * @invariant Стартовый экран - `Screen.Setup`.
*/ */
@Composable @Composable
fun NavGraph( fun NavGraph(navController: NavHostController = rememberNavController()) {
navController: NavHostController = rememberNavController()
) {
// [STATE] // [STATE]
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
// [HELPER] // [HELPER]
val navigationActions = remember(navController) { val navigationActions =
remember(navController) {
NavigationActions(navController) NavigationActions(navController)
} }
// [ACTION] // [ACTION]
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.Setup.route startDestination = Screen.Setup.route,
) { ) {
// [COMPOSABLE_SETUP] // [COMPOSABLE_SETUP]
composable(route = Screen.Setup.route) { composable(route = Screen.Setup.route) {
@@ -62,28 +62,28 @@ fun NavGraph(
composable(route = Screen.Dashboard.route) { composable(route = Screen.Dashboard.route) {
DashboardScreen( DashboardScreen(
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) )
} }
// [COMPOSABLE_INVENTORY_LIST] // [COMPOSABLE_INVENTORY_LIST]
composable(route = Screen.InventoryList.route) { composable(route = Screen.InventoryList.route) {
InventoryListScreen( InventoryListScreen(
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) )
} }
// [COMPOSABLE_ITEM_DETAILS] // [COMPOSABLE_ITEM_DETAILS]
composable(route = Screen.ItemDetails.route) { composable(route = Screen.ItemDetails.route) {
ItemDetailsScreen( ItemDetailsScreen(
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) )
} }
// [COMPOSABLE_ITEM_EDIT] // [COMPOSABLE_ITEM_EDIT]
composable(route = Screen.ItemEdit.route) { composable(route = Screen.ItemEdit.route) {
ItemEditScreen( ItemEditScreen(
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) )
} }
// [COMPOSABLE_LABELS_LIST] // [COMPOSABLE_LABELS_LIST]
@@ -101,21 +101,21 @@ fun NavGraph(
}, },
onAddNewLocationClick = { onAddNewLocationClick = {
navController.navigate(Screen.LocationEdit.createRoute("new")) navController.navigate(Screen.LocationEdit.createRoute("new"))
} },
) )
} }
// [COMPOSABLE_LOCATION_EDIT] // [COMPOSABLE_LOCATION_EDIT]
composable(route = Screen.LocationEdit.route) { backStackEntry -> composable(route = Screen.LocationEdit.route) { backStackEntry ->
val locationId = backStackEntry.arguments?.getString("locationId") val locationId = backStackEntry.arguments?.getString("locationId")
LocationEditScreen( LocationEditScreen(
locationId = locationId locationId = locationId,
) )
} }
// [COMPOSABLE_SEARCH] // [COMPOSABLE_SEARCH]
composable(route = Screen.Search.route) { composable(route = Screen.Search.route) {
SearchScreen( SearchScreen(
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) )
} }
} }

View File

@@ -4,6 +4,7 @@
package com.homebox.lens.navigation package com.homebox.lens.navigation
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
// [CORE-LOGIC] // [CORE-LOGIC]
/** /**
[CONTRACT] [CONTRACT]
@summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий. @summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий.
@@ -25,44 +26,52 @@ class NavigationActions(private val navController: NavHostController) {
launchSingleTop = true launchSingleTop = true
} }
} }
// [ACTION] // [ACTION]
fun navigateToLocations() { fun navigateToLocations() {
navController.navigate(Screen.LocationsList.route) { navController.navigate(Screen.LocationsList.route) {
launchSingleTop = true launchSingleTop = true
} }
} }
// [ACTION] // [ACTION]
fun navigateToLabels() { fun navigateToLabels() {
navController.navigate(Screen.LabelsList.route) { navController.navigate(Screen.LabelsList.route) {
launchSingleTop = true launchSingleTop = true
} }
} }
// [ACTION] // [ACTION]
fun navigateToSearch() { fun navigateToSearch() {
navController.navigate(Screen.Search.route) { navController.navigate(Screen.Search.route) {
launchSingleTop = true launchSingleTop = true
} }
} }
// [ACTION] // [ACTION]
fun navigateToInventoryListWithLabel(labelId: String) { fun navigateToInventoryListWithLabel(labelId: String) {
val route = Screen.InventoryList.withFilter("label", labelId) val route = Screen.InventoryList.withFilter("label", labelId)
navController.navigate(route) navController.navigate(route)
} }
// [ACTION] // [ACTION]
fun navigateToInventoryListWithLocation(locationId: String) { fun navigateToInventoryListWithLocation(locationId: String) {
val route = Screen.InventoryList.withFilter("location", locationId) val route = Screen.InventoryList.withFilter("location", locationId)
navController.navigate(route) navController.navigate(route)
} }
// [ACTION] // [ACTION]
fun navigateToCreateItem() { fun navigateToCreateItem() {
navController.navigate(Screen.ItemEdit.createRoute("new")) navController.navigate(Screen.ItemEdit.createRoute("new"))
} }
// [ACTION] // [ACTION]
fun navigateToLogout() { fun navigateToLogout() {
navController.navigate(Screen.Setup.route) { navController.navigate(Screen.Setup.route) {
popUpTo(Screen.Dashboard.route) { inclusive = true } popUpTo(Screen.Dashboard.route) { inclusive = true }
} }
} }
// [ACTION] // [ACTION]
fun navigateBack() { fun navigateBack() {
navController.popBackStack() navController.popBackStack()

View File

@@ -4,6 +4,7 @@
package com.homebox.lens.navigation package com.homebox.lens.navigation
// [CORE-LOGIC] // [CORE-LOGIC]
/** /**
* [CONTRACT] * [CONTRACT]
* Запечатанный класс для определения маршрутов навигации в приложении. * Запечатанный класс для определения маршрутов навигации в приложении.
@@ -13,7 +14,9 @@ package com.homebox.lens.navigation
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
// [STATE] // [STATE]
data object Setup : Screen("setup_screen") data object Setup : Screen("setup_screen")
data object Dashboard : Screen("dashboard_screen") data object Dashboard : Screen("dashboard_screen")
data object InventoryList : Screen("inventory_list_screen") { data object InventoryList : Screen("inventory_list_screen") {
/** /**
* [CONTRACT] * [CONTRACT]
@@ -25,7 +28,10 @@ sealed class Screen(val route: String) {
* @sideeffect [ARCH-IMPLICATION] NavGraph должен быть настроен для приема этого опционального query-параметра (например, 'navArgument("label") { nullable = true }'). * @sideeffect [ARCH-IMPLICATION] NavGraph должен быть настроен для приема этого опционального query-параметра (например, 'navArgument("label") { nullable = true }').
*/ */
// [HELPER] // [HELPER]
fun withFilter(key: String, value: String): String { fun withFilter(
key: String,
value: String,
): String {
// [PRECONDITION] // [PRECONDITION]
require(key.isNotBlank()) { "[PRECONDITION_FAILED] Filter key cannot be blank." } require(key.isNotBlank()) { "[PRECONDITION_FAILED] Filter key cannot be blank." }
require(value.isNotBlank()) { "[PRECONDITION_FAILED] Filter value cannot be blank." } require(value.isNotBlank()) { "[PRECONDITION_FAILED] Filter value cannot be blank." }
@@ -56,6 +62,7 @@ sealed class Screen(val route: String) {
return route return route
} }
} }
data object ItemEdit : Screen("item_edit_screen/{itemId}") { data object ItemEdit : Screen("item_edit_screen/{itemId}") {
/** /**
* [CONTRACT] * [CONTRACT]
@@ -75,8 +82,11 @@ sealed class Screen(val route: String) {
return route return route
} }
} }
data object LabelsList : Screen("labels_list_screen") data object LabelsList : Screen("labels_list_screen")
data object LocationsList : Screen("locations_list_screen") data object LocationsList : Screen("locations_list_screen")
data object LocationEdit : Screen("location_edit_screen/{locationId}") { data object LocationEdit : Screen("location_edit_screen/{locationId}") {
/** /**
* [CONTRACT] * [CONTRACT]
@@ -96,6 +106,7 @@ sealed class Screen(val route: String) {
return route return route
} }
} }
data object Search : Screen("search_screen") data object Search : Screen("search_screen")
} }
// [END_FILE_Screen.kt] // [END_FILE_Screen.kt]

View File

@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
import com.homebox.lens.R import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.navigation.Screen import com.homebox.lens.navigation.Screen
/** /**
[CONTRACT] [CONTRACT]
@summary Контент для бокового навигационного меню (Drawer). @summary Контент для бокового навигационного меню (Drawer).
@@ -33,7 +34,7 @@ import com.homebox.lens.navigation.Screen
internal fun AppDrawerContent( internal fun AppDrawerContent(
currentRoute: String?, currentRoute: String?,
navigationActions: NavigationActions, navigationActions: NavigationActions,
onCloseDrawer: () -> Unit onCloseDrawer: () -> Unit,
) { ) {
ModalDrawerSheet { ModalDrawerSheet {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@@ -42,9 +43,10 @@ internal fun AppDrawerContent(
navigationActions.navigateToCreateItem() navigationActions.navigateToCreateItem()
onCloseDrawer() onCloseDrawer()
}, },
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp),
) { ) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@@ -58,7 +60,7 @@ internal fun AppDrawerContent(
onClick = { onClick = {
navigationActions.navigateToDashboard() navigationActions.navigateToDashboard()
onCloseDrawer() onCloseDrawer()
} },
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(id = R.string.nav_locations)) }, label = { Text(stringResource(id = R.string.nav_locations)) },
@@ -66,7 +68,7 @@ internal fun AppDrawerContent(
onClick = { onClick = {
navigationActions.navigateToLocations() navigationActions.navigateToLocations()
onCloseDrawer() onCloseDrawer()
} },
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(id = R.string.nav_labels)) }, label = { Text(stringResource(id = R.string.nav_labels)) },
@@ -74,7 +76,7 @@ internal fun AppDrawerContent(
onClick = { onClick = {
navigationActions.navigateToLabels() navigationActions.navigateToLabels()
onCloseDrawer() onCloseDrawer()
} },
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(id = R.string.search)) }, label = { Text(stringResource(id = R.string.search)) },
@@ -82,7 +84,7 @@ internal fun AppDrawerContent(
onClick = { onClick = {
navigationActions.navigateToSearch() navigationActions.navigateToSearch()
onCloseDrawer() onCloseDrawer()
} },
) )
// TODO: Add Profile and Tools items // TODO: Add Profile and Tools items
Divider() Divider()
@@ -92,7 +94,7 @@ internal fun AppDrawerContent(
onClick = { onClick = {
navigationActions.navigateToLogout() navigationActions.navigateToLogout()
onCloseDrawer() onCloseDrawer()
} },
) )
} }
} }

View File

@@ -17,6 +17,7 @@ import com.homebox.lens.navigation.NavigationActions
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// [UI_COMPONENT] // [UI_COMPONENT]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Общая обертка для экранов, включающая Scaffold и Navigation Drawer. * @summary Общая обертка для экранов, включающая Scaffold и Navigation Drawer.
@@ -35,7 +36,7 @@ fun MainScaffold(
currentRoute: String?, currentRoute: String?,
navigationActions: NavigationActions, navigationActions: NavigationActions,
topBarActions: @Composable () -> Unit = {}, topBarActions: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit content: @Composable (PaddingValues) -> Unit,
) { ) {
// [STATE] // [STATE]
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
@@ -48,9 +49,9 @@ fun MainScaffold(
AppDrawerContent( AppDrawerContent(
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions, navigationActions = navigationActions,
onCloseDrawer = { scope.launch { drawerState.close() } } onCloseDrawer = { scope.launch { drawerState.close() } },
) )
} },
) { ) {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -60,13 +61,13 @@ fun MainScaffold(
IconButton(onClick = { scope.launch { drawerState.open() } }) { IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon( Icon(
Icons.Default.Menu, 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 -> ) { paddingValues ->
// [ACTION] // [ACTION]
content(paddingValues) content(paddingValues)

View File

@@ -30,6 +30,7 @@ import com.homebox.lens.ui.common.MainScaffold
import com.homebox.lens.ui.theme.HomeboxLensTheme import com.homebox.lens.ui.theme.HomeboxLensTheme
import timber.log.Timber import timber.log.Timber
// [ENTRYPOINT] // [ENTRYPOINT]
/** /**
[CONTRACT] [CONTRACT]
@summary Главная Composable-функция для экрана "Панель управления". @summary Главная Composable-функция для экрана "Панель управления".
@@ -42,7 +43,7 @@ import timber.log.Timber
fun DashboardScreen( fun DashboardScreen(
viewModel: DashboardViewModel = hiltViewModel(), viewModel: DashboardViewModel = hiltViewModel(),
currentRoute: String?, currentRoute: String?,
navigationActions: NavigationActions navigationActions: NavigationActions,
) { ) {
// [STATE] // [STATE]
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -55,10 +56,10 @@ fun DashboardScreen(
IconButton(onClick = { navigationActions.navigateToSearch() }) { IconButton(onClick = { navigationActions.navigateToSearch() }) {
Icon( Icon(
Icons.Default.Search, 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 -> ) { paddingValues ->
DashboardContent( DashboardContent(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
@@ -70,12 +71,13 @@ fun DashboardScreen(
onLabelClick = { label -> onLabelClick = { label ->
Timber.i("[ACTION] Label chip clicked: ${label.id}. Navigating...") Timber.i("[ACTION] Label chip clicked: ${label.id}. Navigating...")
navigationActions.navigateToInventoryListWithLabel(label.id) navigationActions.navigateToInventoryListWithLabel(label.id)
} },
) )
} }
// [END_FUNCTION_DashboardScreen] // [END_FUNCTION_DashboardScreen]
} }
// [HELPER] // [HELPER]
/** /**
[CONTRACT] [CONTRACT]
@summary Отображает основной контент экрана в зависимости от uiState. @summary Отображает основной контент экрана в зависимости от uiState.
@@ -89,7 +91,7 @@ private fun DashboardContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
uiState: DashboardUiState, uiState: DashboardUiState,
onLocationClick: (LocationOutCount) -> Unit, onLocationClick: (LocationOutCount) -> Unit,
onLabelClick: (LabelOut) -> Unit onLabelClick: (LabelOut) -> Unit,
) { ) {
// [CORE-LOGIC] // [CORE-LOGIC]
when (uiState) { when (uiState) {
@@ -103,16 +105,17 @@ private fun DashboardContent(
Text( Text(
text = uiState.message, text = uiState.message,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
) )
} }
} }
is DashboardUiState.Success -> { is DashboardUiState.Success -> {
LazyColumn( LazyColumn(
modifier = modifier modifier =
modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp) verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
item { Spacer(modifier = Modifier.height(8.dp)) } item { Spacer(modifier = Modifier.height(8.dp)) }
item { StatisticsSection(statistics = uiState.statistics) } item { StatisticsSection(statistics = uiState.statistics) }
@@ -126,6 +129,7 @@ private fun DashboardContent(
// [END_FUNCTION_DashboardContent] // [END_FUNCTION_DashboardContent]
} }
// [UI_COMPONENT] // [UI_COMPONENT]
/** /**
[CONTRACT] [CONTRACT]
@summary Секция для отображения общей статистики. @summary Секция для отображения общей статистики.
@@ -136,27 +140,49 @@ private fun StatisticsSection(statistics: GroupStatistics) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text( Text(
text = stringResource(id = R.string.dashboard_section_quick_stats), text = stringResource(id = R.string.dashboard_section_quick_stats),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
) )
Card { Card {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
modifier = Modifier modifier =
Modifier
.height(120.dp) .height(120.dp)
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(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 {
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_value), value = statistics.totalValue.toString()) } StatisticCard(
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_labels), value = statistics.labels.toString()) } title = stringResource(id = R.string.dashboard_stat_total_items),
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_locations), value = statistics.locations.toString()) } 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] // [UI_COMPONENT]
/** /**
[CONTRACT] [CONTRACT]
@summary Карточка для отображения одного статистического показателя. @summary Карточка для отображения одного статистического показателя.
@@ -164,13 +190,17 @@ private fun StatisticsSection(statistics: GroupStatistics) {
@param value Значение показателя. @param value Значение показателя.
*/ */
@Composable @Composable
private fun StatisticCard(title: String, value: String) { private fun StatisticCard(
title: String,
value: String,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(text = title, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center) Text(text = title, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center)
Text(text = value, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) Text(text = value, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
} }
} }
// [UI_COMPONENT] // [UI_COMPONENT]
/** /**
[CONTRACT] [CONTRACT]
@summary Секция для отображения недавно добавленных элементов. @summary Секция для отображения недавно добавленных элементов.
@@ -181,16 +211,17 @@ private fun RecentlyAddedSection(items: List<ItemSummary>) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text( Text(
text = stringResource(id = R.string.dashboard_section_recently_added), text = stringResource(id = R.string.dashboard_section_recently_added),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
) )
if (items.isEmpty()) { if (items.isEmpty()) {
Text( Text(
text = stringResource(id = R.string.items_not_found), text = stringResource(id = R.string.items_not_found),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 16.dp), .padding(vertical = 16.dp),
textAlign = TextAlign.Center textAlign = TextAlign.Center,
) )
} else { } else {
LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
@@ -202,6 +233,7 @@ private fun RecentlyAddedSection(items: List<ItemSummary>) {
} }
} }
// [UI_COMPONENT] // [UI_COMPONENT]
/** /**
[CONTRACT] [CONTRACT]
@summary Карточка для отображения краткой информации об элементе. @summary Карточка для отображения краткой информации об элементе.
@@ -212,17 +244,25 @@ private fun ItemCard(item: ItemSummary) {
Card(modifier = Modifier.width(150.dp)) { Card(modifier = Modifier.width(150.dp)) {
Column(modifier = Modifier.padding(8.dp)) { Column(modifier = Modifier.padding(8.dp)) {
// TODO: Add image here from item.image // TODO: Add image here from item.image
Spacer(modifier = Modifier Spacer(
modifier =
Modifier
.height(80.dp) .height(80.dp)
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer)) .background(MaterialTheme.colorScheme.secondaryContainer),
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text(text = item.name, style = MaterialTheme.typography.titleSmall, maxLines = 1) 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] // [UI_COMPONENT]
/** /**
[CONTRACT] [CONTRACT]
@summary Секция для отображения местоположений в виде чипсов. @summary Секция для отображения местоположений в виде чипсов.
@@ -231,11 +271,14 @@ private fun ItemCard(item: ItemSummary) {
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick: (LocationOutCount) -> Unit) { private fun LocationsSection(
locations: List<LocationOutCount>,
onLocationClick: (LocationOutCount) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text( Text(
text = stringResource(id = R.string.dashboard_section_locations), text = stringResource(id = R.string.dashboard_section_locations),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
) )
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -243,13 +286,14 @@ private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick:
locations.forEach { location -> locations.forEach { location ->
SuggestionChip( SuggestionChip(
onClick = { onLocationClick(location) }, 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] // [UI_COMPONENT]
/** /**
[CONTRACT] [CONTRACT]
@summary Секция для отображения меток в виде чипсов. @summary Секция для отображения меток в виде чипсов.
@@ -258,11 +302,14 @@ private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick:
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Unit) { private fun LabelsSection(
labels: List<LabelOut>,
onLabelClick: (LabelOut) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text( Text(
text = stringResource(id = R.string.dashboard_section_labels), text = stringResource(id = R.string.dashboard_section_labels),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
) )
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -270,46 +317,92 @@ private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Un
labels.forEach { label -> labels.forEach { label ->
SuggestionChip( SuggestionChip(
onClick = { onLabelClick(label) }, onClick = { onLabelClick(label) },
label = { Text(label.name) } label = { Text(label.name) },
) )
} }
} }
} }
} }
// [PREVIEW] // [PREVIEW]
@Preview(showBackground = true, name = "Dashboard Success State") @Preview(showBackground = true, name = "Dashboard Success State")
@Composable @Composable
fun DashboardContentSuccessPreview() { fun DashboardContentSuccessPreview() {
val previewState = DashboardUiState.Success( val previewState =
statistics = GroupStatistics( DashboardUiState.Success(
statistics =
GroupStatistics(
items = 123, items = 123,
totalValue = 9999.99, totalValue = 9999.99,
locations = 5, locations = 5,
labels = 8 labels = 8,
), ),
locations = listOf( locations =
LocationOutCount(id="1", name="Office", color = "#FF0000", isArchived = false, itemCount = 10, createdAt = "", updatedAt = ""), listOf(
LocationOutCount(id="2", name="Garage", color = "#00FF00", isArchived = false, itemCount = 5, createdAt = "", updatedAt = ""), LocationOutCount(
LocationOutCount(id="3",name="Living Room", color = "#0000FF", isArchived = false, itemCount = 15, createdAt = "", updatedAt = ""), id = "1",
LocationOutCount(id="4",name="Kitchen", color = "#FFFF00", isArchived = false, itemCount = 20, createdAt = "", updatedAt = ""), name = "Office",
LocationOutCount(id="5",name="Basement", color = "#00FFFF", isArchived = false, itemCount = 3, createdAt = "", updatedAt = "") color = "#FF0000",
isArchived = false,
itemCount = 10,
createdAt = "",
updatedAt = "",
), ),
labels = listOf( LocationOutCount(
LabelOut(id="1", name="electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""), id = "2",
LabelOut(id="2", name="important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""), name = "Garage",
LabelOut(id="3", name="seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""), color = "#00FF00",
LabelOut(id="4", name="hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = "") isArchived = false,
itemCount = 5,
createdAt = "",
updatedAt = "",
), ),
recentlyAddedItems = emptyList() 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 { HomeboxLensTheme {
DashboardContent( DashboardContent(
uiState = previewState, uiState = previewState,
onLocationClick = {}, onLocationClick = {},
onLabelClick = {} onLabelClick = {},
) )
} }
} }
// [PREVIEW] // [PREVIEW]
@Preview(showBackground = true, name = "Dashboard Loading State") @Preview(showBackground = true, name = "Dashboard Loading State")
@Composable @Composable
@@ -318,10 +411,11 @@ fun DashboardContentLoadingPreview() {
DashboardContent( DashboardContent(
uiState = DashboardUiState.Loading, uiState = DashboardUiState.Loading,
onLocationClick = {}, onLocationClick = {},
onLabelClick = {} onLabelClick = {},
) )
} }
} }
// [PREVIEW] // [PREVIEW]
@Preview(showBackground = true, name = "Dashboard Error State") @Preview(showBackground = true, name = "Dashboard Error State")
@Composable @Composable
@@ -330,7 +424,7 @@ fun DashboardContentErrorPreview() {
DashboardContent( DashboardContent(
uiState = DashboardUiState.Error(stringResource(id = R.string.error_loading_failed)), uiState = DashboardUiState.Error(stringResource(id = R.string.error_loading_failed)),
onLocationClick = {}, onLocationClick = {},
onLabelClick = {} onLabelClick = {},
) )
} }
} }

View File

@@ -11,6 +11,7 @@ import com.homebox.lens.domain.model.LocationOutCount
// [CORE-LOGIC] // [CORE-LOGIC]
// [ENTITY: SealedInterface('DashboardUiState')] // [ENTITY: SealedInterface('DashboardUiState')]
/** /**
* [CONTRACT] * [CONTRACT]
* Определяет все возможные состояния для экрана "Дэшборд". * Определяет все возможные состояния для экрана "Дэшборд".
@@ -29,7 +30,7 @@ sealed interface DashboardUiState {
val statistics: GroupStatistics, val statistics: GroupStatistics,
val locations: List<LocationOutCount>, val locations: List<LocationOutCount>,
val labels: List<LabelOut>, val labels: List<LabelOut>,
val recentlyAddedItems: List<com.homebox.lens.domain.model.ItemSummary> val recentlyAddedItems: List<com.homebox.lens.domain.model.ItemSummary>,
) : DashboardUiState ) : DashboardUiState
/** /**

View File

@@ -9,10 +9,7 @@ import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
import com.homebox.lens.domain.usecase.GetRecentlyAddedItemsUseCase import com.homebox.lens.domain.usecase.GetRecentlyAddedItemsUseCase
import com.homebox.lens.domain.usecase.GetStatisticsUseCase import com.homebox.lens.domain.usecase.GetStatisticsUseCase
import com.homebox.lens.ui.screen.dashboard.DashboardUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -20,6 +17,7 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
// [ENTITY: ViewModel('DashboardViewModel')] // [ENTITY: ViewModel('DashboardViewModel')]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary ViewModel для главного экрана (Dashboard). * @summary ViewModel для главного экрана (Dashboard).
@@ -28,15 +26,17 @@ import javax.inject.Inject
* @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`. * @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`.
*/ */
@HiltViewModel @HiltViewModel
class DashboardViewModel @Inject constructor( class DashboardViewModel
@Inject
constructor(
private val getStatisticsUseCase: GetStatisticsUseCase, private val getStatisticsUseCase: GetStatisticsUseCase,
private val getAllLocationsUseCase: GetAllLocationsUseCase, private val getAllLocationsUseCase: GetAllLocationsUseCase,
private val getAllLabelsUseCase: GetAllLabelsUseCase, private val getAllLabelsUseCase: GetAllLabelsUseCase,
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase,
) : ViewModel() { ) : ViewModel() {
// [STATE] // [STATE]
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading) private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
// [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow(). // [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow().
// [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и // [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и
// должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока. // должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока.
@@ -70,12 +70,13 @@ class DashboardViewModel @Inject constructor(
statistics = stats, statistics = stats,
locations = locations, locations = locations,
labels = labels, labels = labels,
recentlyAddedItems = recentItems recentlyAddedItems = recentItems,
) )
}.catch { exception -> }.catch { exception ->
Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.") Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.")
_uiState.value = DashboardUiState.Error( _uiState.value =
message = exception.message ?: "Could not load dashboard data." DashboardUiState.Error(
message = exception.message ?: "Could not load dashboard data.",
) )
}.collect { successState -> }.collect { successState ->
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.") Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
@@ -84,5 +85,5 @@ class DashboardViewModel @Inject constructor(
} }
} }
// [END_CLASS_DashboardViewModel] // [END_CLASS_DashboardViewModel]
} }
// [END_FILE_DashboardViewModel.kt] // [END_FILE_DashboardViewModel.kt]

View File

@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT] // [ENTRYPOINT]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Composable-функция для экрана "Список инвентаря". * @summary Composable-функция для экрана "Список инвентаря".
@@ -22,13 +23,13 @@ import com.homebox.lens.ui.common.MainScaffold
@Composable @Composable
fun InventoryListScreen( fun InventoryListScreen(
currentRoute: String?, currentRoute: String?,
navigationActions: NavigationActions navigationActions: NavigationActions,
) { ) {
// [UI_COMPONENT] // [UI_COMPONENT]
MainScaffold( MainScaffold(
topBarTitle = stringResource(id = R.string.inventory_list_title), topBarTitle = stringResource(id = R.string.inventory_list_title),
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) { ) {
// [CORE-LOGIC] // [CORE-LOGIC]
Text(text = "TODO: Inventory List Screen") Text(text = "TODO: Inventory List Screen")

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
@HiltViewModel @HiltViewModel
class InventoryListViewModel @Inject constructor() : ViewModel() { class InventoryListViewModel
@Inject
constructor() : ViewModel() {
// [STATE] // [STATE]
// TODO: Implement UI state // TODO: Implement UI state
} }
// [END_FILE_InventoryListViewModel.kt] // [END_FILE_InventoryListViewModel.kt]

View File

@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT] // [ENTRYPOINT]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Composable-функция для экрана "Детали элемента". * @summary Composable-функция для экрана "Детали элемента".
@@ -22,13 +23,13 @@ import com.homebox.lens.ui.common.MainScaffold
@Composable @Composable
fun ItemDetailsScreen( fun ItemDetailsScreen(
currentRoute: String?, currentRoute: String?,
navigationActions: NavigationActions navigationActions: NavigationActions,
) { ) {
// [UI_COMPONENT] // [UI_COMPONENT]
MainScaffold( MainScaffold(
topBarTitle = stringResource(id = R.string.item_details_title), topBarTitle = stringResource(id = R.string.item_details_title),
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) { ) {
// [CORE-LOGIC] // [CORE-LOGIC]
Text(text = "TODO: Item Details Screen") Text(text = "TODO: Item Details Screen")

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
@HiltViewModel @HiltViewModel
class ItemDetailsViewModel @Inject constructor() : ViewModel() { class ItemDetailsViewModel
@Inject
constructor() : ViewModel() {
// [STATE] // [STATE]
// TODO: Implement UI state // TODO: Implement UI state
} }
// [END_FILE_ItemDetailsViewModel.kt] // [END_FILE_ItemDetailsViewModel.kt]

View File

@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT] // [ENTRYPOINT]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Composable-функция для экрана "Редактирование элемента". * @summary Composable-функция для экрана "Редактирование элемента".
@@ -22,13 +23,13 @@ import com.homebox.lens.ui.common.MainScaffold
@Composable @Composable
fun ItemEditScreen( fun ItemEditScreen(
currentRoute: String?, currentRoute: String?,
navigationActions: NavigationActions navigationActions: NavigationActions,
) { ) {
// [UI_COMPONENT] // [UI_COMPONENT]
MainScaffold( MainScaffold(
topBarTitle = stringResource(id = R.string.item_edit_title), topBarTitle = stringResource(id = R.string.item_edit_title),
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) { ) {
// [CORE-LOGIC] // [CORE-LOGIC]
Text(text = "TODO: Item Edit Screen") Text(text = "TODO: Item Edit Screen")

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
@HiltViewModel @HiltViewModel
class ItemEditViewModel @Inject constructor() : ViewModel() { class ItemEditViewModel
@Inject
constructor() : ViewModel() {
// [STATE] // [STATE]
// TODO: Implement UI state // TODO: Implement UI state
} }
// [END_FILE_ItemEditViewModel.kt] // [END_FILE_ItemEditViewModel.kt]

View File

@@ -1,15 +1,11 @@
// [PACKAGE] com.homebox.lens.ui.screen.labelslist // [PACKAGE] com.homebox.lens.ui.screen.labelslist
// [FILE] LabelsListScreen.kt // [FILE] LabelsListScreen.kt
// [SEMANTICS] ui, labels_list, state_management, compose, dialog // [SEMANTICS] ui, screen, jetpack_compose, labels_list, state_management
package com.homebox.lens.ui.screen.labelslist package com.homebox.lens.ui.screen.labelslist
// [SECTION] Imports
// [IMPORTS] // [IMPORTS]
import androidx.compose.foundation.clickable 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -17,241 +13,193 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.automirrored.filled.Label
import androidx.compose.material.icons.filled.Add 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.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.homebox.lens.R import com.homebox.lens.R
import com.homebox.lens.domain.model.Label import com.homebox.lens.domain.model.Label
import com.homebox.lens.navigation.Screen import com.homebox.lens.ui.theme.HomeboxLensTheme
import timber.log.Timber import timber.log.Timber
// [SECTION] Main Screen Composable // [ENTITY: Class('LabelsListScreen')]
// [RELATION: Class('LabelsListScreen')] -> [DEPENDS_ON] -> [Class('LabelsListViewModel')]
// [RELATION: Class('LabelsListScreen')] -> [READS_FROM] -> [DataStructure('LabelsListUiState')]
/** /**
* [CONTRACT] * [MAIN-CONTRACT]
* @summary Отображает экран со списком всех меток. * Экран для отображения списка всех меток.
* @description Главная Composable-функция для экрана меток. Она использует Scaffold для структуры,
* получает состояние от `LabelsListViewModel`, обрабатывает навигацию и делегирует отображение
* списка и диалогов вспомогательным Composable-функциям.
* *
* @param navController Контроллер навигации для перемещения между экранами. * @param onLabelClick Функция обратного вызова при нажатии на метку. Передает ID метки.
* @param viewModel ViewModel, предоставляющая состояние UI для экрана меток. * @param onAddNewLabelClick Функция обратного вызова для инициирования процесса создания новой метки.
* * @param onNavigateBack Функция обратного вызова для навигации назад.
* @precondition `navController` должен быть корректно инициализирован и способен обрабатывать навигационные события. * @param viewModel ViewModel для этого экрана, предоставляемая Hilt.
* @precondition `viewModel` должен быть доступен через Hilt.
* @postcondition Экран исчерпывающе обрабатывает все состояния из `LabelsListUiState` (Loading, Success, Error).
* @sideeffect Пользовательские действия (клики) инициируют вызовы ViewModel и навигационные команды через `navController`.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabelsListScreen( fun LabelsListScreen(
navController: NavController, onLabelClick: (String) -> Unit,
viewModel: LabelsListViewModel = hiltViewModel() onAddNewLabelClick: () -> Unit,
onNavigateBack: () -> Unit,
viewModel: LabelsListViewModel = hiltViewModel(),
) { ) {
// [ENTRYPOINT] // [STATE]
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// [CONTRACT_VALIDATOR]
// В Compose UI контракты проверяются через состояние и события.
// [CORE-LOGIC]
Scaffold( Scaffold(
topBar = { topBar = {
// [ENTITY: Function('LabelsTopAppBar')]
LabelsTopAppBar(onNavigateBack = onNavigateBack)
},
floatingActionButton = {
// [ENTITY: Function('LabelsFloatingActionButton')]
LabelsFloatingActionButton(onAddNewLabelClick = onAddNewLabelClick)
},
) { innerPadding ->
// [ENTITY: Function('LabelsListContent')]
// [RELATION: Function('LabelsListContent')] -> [CALLS] -> [Function('onLabelClick')]
LabelsListContent(
modifier = Modifier.padding(innerPadding),
labels = uiState.labels,
onLabelClick = onLabelClick,
)
}
}
/**
* [CONTRACT]
* Верхняя панель для экрана списка меток.
* @param onNavigateBack Функция для навигации назад.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LabelsTopAppBar(onNavigateBack: () -> Unit) {
// [PRECONDITION]
require(true) { "onNavigateBack must be a valid function." } // В Compose предусловия часто неявные
TopAppBar( TopAppBar(
title = { Text(text = stringResource(id = R.string.screen_title_labels)) }, title = { Text(stringResource(id = R.string.screen_title_labels)) },
navigationIcon = { navigationIcon = {
// [ACTION] Handle back navigation
IconButton(onClick = { IconButton(onClick = {
Timber.i("[ACTION] Navigate up initiated.") // [ACTION]
navController.navigateUp() Timber.i("[INFO][ACTION][navigating_back] Navigate back from LabelsListScreen.")
onNavigateBack()
}) { }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.content_desc_navigate_back) 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() * [CONTRACT]
}) { * Плавающая кнопка действия для добавления новой метки.
* @param onAddNewLabelClick Функция для вызова экрана создания метки.
*/
@Composable
private fun LabelsFloatingActionButton(onAddNewLabelClick: () -> Unit) {
// [PRECONDITION]
require(true) { "onAddNewLabelClick must be a valid function." }
FloatingActionButton(
onClick = {
// [ACTION]
Timber.i("[INFO][ACTION][initiating_add_new_label] FAB clicked to add a new label.")
onAddNewLabelClick()
},
) {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Filled.Add,
contentDescription = stringResource(id = R.string.content_desc_create_label) 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) {
is LabelsListUiState.Loading -> {
CircularProgressIndicator()
}
is LabelsListUiState.Error -> {
Text(text = currentState.message)
}
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)
}
)
}
}
}
}
}
// [COHERENCE_CHECK_PASSED]
} }
// [END_FUNCTION] LabelsListScreen
// [SECTION] Helper Composables
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Composable-функция для отображения списка меток. * Основной контент экрана - список меток.
* @param labels Список объектов `Label` для отображения. *
* @param onLabelClick Лямбда-функция, вызываемая при нажатии на элемент списка. * @param modifier Модификатор для компоновки.
* @param modifier Модификатор для настройки внешнего вида. * @param labels Список меток для отображения.
* @param onLabelClick Обработчик нажатия на метку.
* @sideeffect Вызывает [onLabelClick] при взаимодействии пользователя.
*/ */
@Composable @Composable
private fun LabelsList( private fun LabelsListContent(
modifier: Modifier = Modifier,
labels: List<Label>, labels: List<Label>,
onLabelClick: (Label) -> Unit, onLabelClick: (String) -> Unit,
modifier: Modifier = Modifier
) { ) {
// [CORE-LOGIC] // [PRECONDITION]
LazyColumn( requireNotNull(labels) { "Labels list cannot be null." }
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(labels, key = { it.id }) { label ->
LabelListItem(
label = label,
onClick = { onLabelClick(label) }
)
}
}
}
// [END_FUNCTION] LabelsList
/** LazyColumn(modifier = modifier) {
* [CONTRACT] items(labels, key = { it.id }) { label ->
* @summary Composable-функция для отображения одного элемента в списке меток. // [ENTITY: DataStructure('LabelListItem')]
* @param label Объект `Label`, который нужно отобразить.
* @param onClick Лямбда-функция, вызываемая при нажатии на элемент.
*/
@Composable
private fun LabelListItem(
label: Label,
onClick: () -> Unit
) {
// [CORE-LOGIC]
ListItem( ListItem(
headlineContent = { Text(text = label.name) }, headlineContent = { Text(label.name) },
leadingContent = { leadingContent = {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Label, imageVector = Icons.AutoMirrored.Filled.Label,
contentDescription = stringResource(id = R.string.content_desc_label_icon) contentDescription = null, // Декоративная иконка
) )
}, },
modifier = Modifier.clickable(onClick = onClick) modifier =
Modifier.clickable {
// [ACTION]
Timber.i("[INFO][ACTION][handling_label_click] Label clicked: id='${label.id}', name='${label.name}'")
onLabelClick(label.id)
},
) )
} }
// [END_FUNCTION] LabelListItem }
/** // [POSTCONDITION]
* [CONTRACT] // В декларативном UI постусловие - это корректное отображение предоставленного состояния.
* @summary Диалоговое окно для создания новой метки. check(true) { "LazyColumn rendering is managed by Compose runtime." }
* @param onConfirm Лямбда-функция, вызываемая при подтверждении создания с именем метки. }
* @param onDismiss Лямбда-функция, вызываемая при закрытии диалога.
*/ // [SECTION] Previews
@Preview(showBackground = true)
@Composable @Composable
private fun CreateLabelDialog( private fun LabelsListScreenPreview() {
onConfirm: (String) -> Unit, HomeboxLensTheme {
onDismiss: () -> Unit val sampleLabels =
) { listOf(
// [STATE] Label(id = "1", name = "Electronics", color = "#FF0000"),
var text by remember { mutableStateOf("") } Label(id = "2", name = "Books", color = "#00FF00"),
val isConfirmEnabled = text.isNotBlank() Label(id = "3", name = "Documents", color = "#0000FF"),
// [CORE-LOGIC]
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.dialog_title_create_label)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text(stringResource(R.string.dialog_field_label_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
) )
}, // [HELPER]
confirmButton = { // Для превью мы не можем использовать реальный ViewModel, поэтому создаем заглушки.
TextButton( Scaffold(
onClick = { onConfirm(text) }, topBar = { LabelsTopAppBar(onNavigateBack = {}) },
enabled = isConfirmEnabled floatingActionButton = { LabelsFloatingActionButton(onAddNewLabelClick = {}) },
) { ) { padding ->
Text(stringResource(R.string.dialog_button_create)) LabelsListContent(
} modifier = Modifier.padding(padding),
}, labels = sampleLabels,
dismissButton = { onLabelClick = {},
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_button_cancel))
}
}
) )
}
}
} }
// [END_FUNCTION] CreateLabelDialog
// [END_FILE] LabelsListScreen.kt // [COHERENCE_CHECK_PASSED]

View File

@@ -5,6 +5,7 @@ package com.homebox.lens.ui.screen.labelslist
// [IMPORTS] // [IMPORTS]
import com.homebox.lens.domain.model.Label import com.homebox.lens.domain.model.Label
// [CONTRACT] // [CONTRACT]
/** /**
[CONTRACT] [CONTRACT]
@summary Определяет все возможные состояния для UI экрана со списком меток. @summary Определяет все возможные состояния для UI экрана со списком меток.
@@ -19,14 +20,16 @@ sealed interface LabelsListUiState {
*/ */
data class Success( data class Success(
val labels: List<Label>, val labels: List<Label>,
val isShowingCreateDialog: Boolean = false val isShowingCreateDialog: Boolean = false,
) : LabelsListUiState ) : LabelsListUiState
/** /**
@summary Состояние ошибки. @summary Состояние ошибки.
@property message Текст ошибки для отображения пользователю. @property message Текст ошибки для отображения пользователю.
@invariant message не может быть пустой. @invariant message не может быть пустой.
*/ */
data class Error(val message: String) : LabelsListUiState data class Error(val message: String) : LabelsListUiState
/** /**
@summary Состояние загрузки данных. @summary Состояние загрузки данных.
@description Указывает, что идет процесс загрузки меток. @description Указывает, что идет процесс загрузки меток.

View File

@@ -18,6 +18,7 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
// [ENTITY: ViewModel('LabelsListViewModel')] // [ENTITY: ViewModel('LabelsListViewModel')]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary ViewModel для экрана со списком меток. * @summary ViewModel для экрана со списком меток.
@@ -25,10 +26,11 @@ import javax.inject.Inject
* @invariant `uiState` всегда является одним из состояний, определенных в `LabelsListUiState`. * @invariant `uiState` всегда является одним из состояний, определенных в `LabelsListUiState`.
*/ */
@HiltViewModel @HiltViewModel
class LabelsListViewModel @Inject constructor( class LabelsListViewModel
private val getAllLabelsUseCase: GetAllLabelsUseCase @Inject
) : ViewModel() { constructor(
private val getAllLabelsUseCase: GetAllLabelsUseCase,
) : ViewModel() {
// [STATE] // [STATE]
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading) private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@@ -53,7 +55,8 @@ class LabelsListViewModel @Inject constructor(
Timber.i("[ACTION] Starting labels list load. State -> Loading.") Timber.i("[ACTION] Starting labels list load. State -> Loading.")
// [CORE-LOGIC] // [CORE-LOGIC]
val result = runCatching { val result =
runCatching {
getAllLabelsUseCase() getAllLabelsUseCase()
} }
@@ -63,20 +66,22 @@ class LabelsListViewModel @Inject constructor(
Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.") Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.")
// [DATA-FLOW] Map List<LabelOut> to List<Label> for the UI state. // [DATA-FLOW] Map List<LabelOut> to List<Label> for the UI state.
// The 'Label' model for the UI is simpler and only contains 'id' and 'name'. // The 'Label' model for the UI is simpler and only contains 'id' and 'name'.
val labels = labelOuts.map { labelOut -> val labels =
labelOuts.map { labelOut ->
Label( Label(
id = labelOut.id, id = labelOut.id,
name = labelOut.name name = labelOut.name,
) )
} }
_uiState.value = LabelsListUiState.Success(labels, isShowingCreateDialog = false) _uiState.value = LabelsListUiState.Success(labels, isShowingCreateDialog = false)
}, },
onFailure = { exception -> onFailure = { exception ->
Timber.e(exception, "[ERROR] Failed to load labels. State -> Error.") Timber.e(exception, "[ERROR] Failed to load labels. State -> Error.")
_uiState.value = LabelsListUiState.Error( _uiState.value =
message = exception.message ?: "Could not load labels." LabelsListUiState.Error(
message = exception.message ?: "Could not load labels.",
) )
} },
) )
} }
} }
@@ -136,5 +141,5 @@ class LabelsListViewModel @Inject constructor(
onDismissCreateDialog() onDismissCreateDialog()
// [REFACTORING_NOTE] На следующем этапе нужно добавить вызов UseCase и обновить список. // [REFACTORING_NOTE] На следующем этапе нужно добавить вызов UseCase и обновить список.
} }
} }
// [END_CLASS_LabelsListViewModel] // [END_CLASS_LabelsListViewModel]

View File

@@ -17,16 +17,16 @@ import androidx.compose.ui.res.stringResource
import com.homebox.lens.R import com.homebox.lens.R
// [ENTRYPOINT] // [ENTRYPOINT]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Composable-функция для экрана "Редактирование местоположения". * @summary Composable-функция для экрана "Редактирование местоположения".
* @param locationId ID местоположения для редактирования или "new" для создания. * @param locationId ID местоположения для редактирования или "new" для создания.
*/ */
@Composable @Composable
fun LocationEditScreen( fun LocationEditScreen(locationId: String?) {
locationId: String? val title =
) { if (locationId == "new") {
val title = if (locationId == "new") {
stringResource(id = R.string.location_edit_title_create) stringResource(id = R.string.location_edit_title_create)
} else { } else {
stringResource(id = R.string.location_edit_title_edit) stringResource(id = R.string.location_edit_title_edit)
@@ -34,10 +34,11 @@ fun LocationEditScreen(
Scaffold { paddingValues -> Scaffold { paddingValues ->
Box( Box(
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center,
) { ) {
Text(text = "TODO: Location Edit Screen for ID: $locationId") Text(text = "TODO: Location Edit Screen for ID: $locationId")
} }

View File

@@ -51,6 +51,7 @@ import com.homebox.lens.ui.common.MainScaffold
import com.homebox.lens.ui.theme.HomeboxLensTheme import com.homebox.lens.ui.theme.HomeboxLensTheme
// [ENTRYPOINT] // [ENTRYPOINT]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Composable-функция для экрана "Список местоположений". * @summary Composable-функция для экрана "Список местоположений".
@@ -66,7 +67,7 @@ fun LocationsListScreen(
navigationActions: NavigationActions, navigationActions: NavigationActions,
onLocationClick: (String) -> Unit, onLocationClick: (String) -> Unit,
onAddNewLocationClick: () -> Unit, onAddNewLocationClick: () -> Unit,
viewModel: LocationsListViewModel = hiltViewModel() viewModel: LocationsListViewModel = hiltViewModel(),
) { ) {
// [STATE] // [STATE]
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -75,7 +76,7 @@ fun LocationsListScreen(
MainScaffold( MainScaffold(
topBarTitle = stringResource(id = R.string.locations_list_title), topBarTitle = stringResource(id = R.string.locations_list_title),
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) { paddingValues -> ) { paddingValues ->
Scaffold( Scaffold(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
@@ -83,23 +84,24 @@ fun LocationsListScreen(
FloatingActionButton(onClick = onAddNewLocationClick) { FloatingActionButton(onClick = onAddNewLocationClick) {
Icon( Icon(
Icons.Default.Add, Icons.Default.Add,
contentDescription = stringResource(id = R.string.cd_add_new_location) contentDescription = stringResource(id = R.string.cd_add_new_location),
) )
} }
} },
) { innerPadding -> ) { innerPadding ->
LocationsListContent( LocationsListContent(
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
uiState = uiState, uiState = uiState,
onLocationClick = onLocationClick, onLocationClick = onLocationClick,
onEditLocation = { /* TODO */ }, onEditLocation = { /* TODO */ },
onDeleteLocation = { /* TODO */ } onDeleteLocation = { /* TODO */ },
) )
} }
} }
} }
// [HELPER] // [HELPER]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Отображает основной контент экрана в зависимости от `uiState`. * @summary Отображает основной контент экрана в зависимости от `uiState`.
@@ -115,7 +117,7 @@ private fun LocationsListContent(
uiState: LocationsListUiState, uiState: LocationsListUiState,
onLocationClick: (String) -> Unit, onLocationClick: (String) -> Unit,
onEditLocation: (String) -> Unit, onEditLocation: (String) -> Unit,
onDeleteLocation: (String) -> Unit onDeleteLocation: (String) -> Unit,
) { ) {
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
when (uiState) { when (uiState) {
@@ -127,9 +129,10 @@ private fun LocationsListContent(
text = uiState.message, text = uiState.message,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier modifier =
Modifier
.align(Alignment.Center) .align(Alignment.Center)
.padding(16.dp) .padding(16.dp),
) )
} }
is LocationsListUiState.Success -> { is LocationsListUiState.Success -> {
@@ -137,21 +140,22 @@ private fun LocationsListContent(
Text( Text(
text = stringResource(id = R.string.locations_not_found), text = stringResource(id = R.string.locations_not_found),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier modifier =
Modifier
.align(Alignment.Center) .align(Alignment.Center)
.padding(16.dp) .padding(16.dp),
) )
} else { } else {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(uiState.locations, key = { it.id }) { location -> items(uiState.locations, key = { it.id }) { location ->
LocationCard( LocationCard(
location = location, location = location,
onClick = { onLocationClick(location.id) }, onClick = { onLocationClick(location.id) },
onEditClick = { onEditLocation(location.id) }, onEditClick = { onEditLocation(location.id) },
onDeleteClick = { onDeleteLocation(location.id) } onDeleteClick = { onDeleteLocation(location.id) },
) )
} }
} }
@@ -162,6 +166,7 @@ private fun LocationsListContent(
} }
// [UI_COMPONENT] // [UI_COMPONENT]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Карточка для отображения одного местоположения. * @summary Карточка для отображения одного местоположения.
@@ -175,25 +180,26 @@ private fun LocationCard(
location: LocationOutCount, location: LocationOutCount,
onClick: () -> Unit, onClick: () -> Unit,
onEditClick: () -> Unit, onEditClick: () -> Unit,
onDeleteClick: () -> Unit onDeleteClick: () -> Unit,
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
Card( Card(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick),
) { ) {
Row( Row(
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 16.dp, bottom = 16.dp), modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 16.dp, bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(text = location.name, style = MaterialTheme.typography.titleMedium) Text(text = location.name, style = MaterialTheme.typography.titleMedium)
Text( Text(
text = stringResource(id = R.string.item_count, location.itemCount), text = stringResource(id = R.string.item_count, location.itemCount),
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
) )
} }
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -203,21 +209,21 @@ private fun LocationCard(
} }
DropdownMenu( DropdownMenu(
expanded = menuExpanded, expanded = menuExpanded,
onDismissRequest = { menuExpanded = false } onDismissRequest = { menuExpanded = false },
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(id = R.string.edit)) }, text = { Text(stringResource(id = R.string.edit)) },
onClick = { onClick = {
menuExpanded = false menuExpanded = false
onEditClick() onEditClick()
} },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(id = R.string.delete)) }, text = { Text(stringResource(id = R.string.delete)) },
onClick = { onClick = {
menuExpanded = false menuExpanded = false
onDeleteClick() onDeleteClick()
} },
) )
} }
} }
@@ -229,17 +235,18 @@ private fun LocationCard(
@Preview(showBackground = true, name = "Locations List Success") @Preview(showBackground = true, name = "Locations List Success")
@Composable @Composable
fun LocationsListSuccessPreview() { fun LocationsListSuccessPreview() {
val previewLocations = listOf( val previewLocations =
listOf(
LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""), LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""),
LocationOutCount("2", "Kitchen", "#00FF00", false, 5, "", ""), LocationOutCount("2", "Kitchen", "#00FF00", false, 5, "", ""),
LocationOutCount("3", "Office", "#0000FF", false, 23, "", "") LocationOutCount("3", "Office", "#0000FF", false, 23, "", ""),
) )
HomeboxLensTheme { HomeboxLensTheme {
LocationsListContent( LocationsListContent(
uiState = LocationsListUiState.Success(previewLocations), uiState = LocationsListUiState.Success(previewLocations),
onLocationClick = {}, onLocationClick = {},
onEditLocation = {}, onEditLocation = {},
onDeleteLocation = {} onDeleteLocation = {},
) )
} }
} }
@@ -253,7 +260,7 @@ fun LocationsListEmptyPreview() {
uiState = LocationsListUiState.Success(emptyList()), uiState = LocationsListUiState.Success(emptyList()),
onLocationClick = {}, onLocationClick = {},
onEditLocation = {}, onEditLocation = {},
onDeleteLocation = {} onDeleteLocation = {},
) )
} }
} }
@@ -267,7 +274,7 @@ fun LocationsListLoadingPreview() {
uiState = LocationsListUiState.Loading, uiState = LocationsListUiState.Loading,
onLocationClick = {}, onLocationClick = {},
onEditLocation = {}, onEditLocation = {},
onDeleteLocation = {} onDeleteLocation = {},
) )
} }
} }
@@ -281,7 +288,7 @@ fun LocationsListErrorPreview() {
uiState = LocationsListUiState.Error("Failed to load locations. Please try again."), uiState = LocationsListUiState.Error("Failed to load locations. Please try again."),
onLocationClick = {}, onLocationClick = {},
onEditLocation = {}, onEditLocation = {},
onDeleteLocation = {} onDeleteLocation = {},
) )
} }
} }

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
// [CORE-LOGIC] // [CORE-LOGIC]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary ViewModel для экрана списка местоположений. * @summary ViewModel для экрана списка местоположений.
@@ -23,10 +24,11 @@ import javax.inject.Inject
* @invariant `uiState` всегда отражает результат последней операции загрузки. * @invariant `uiState` всегда отражает результат последней операции загрузки.
*/ */
@HiltViewModel @HiltViewModel
class LocationsListViewModel @Inject constructor( class LocationsListViewModel
private val getAllLocationsUseCase: GetAllLocationsUseCase @Inject
) : ViewModel() { constructor(
private val getAllLocationsUseCase: GetAllLocationsUseCase,
) : ViewModel() {
// [STATE] // [STATE]
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading) private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading)
val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow() val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow()
@@ -37,6 +39,7 @@ class LocationsListViewModel @Inject constructor(
} }
// [ACTION] // [ACTION]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Загружает список местоположений из репозитория. * @summary Загружает список местоположений из репозитория.
@@ -54,5 +57,5 @@ class LocationsListViewModel @Inject constructor(
} }
} }
// [END_CLASS_LocationsListViewModel] // [END_CLASS_LocationsListViewModel]
} }
// [END_FILE_LocationsListViewModel.kt] // [END_FILE_LocationsListViewModel.kt]

View File

@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT] // [ENTRYPOINT]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Composable-функция для экрана "Поиск". * @summary Composable-функция для экрана "Поиск".
@@ -22,13 +23,13 @@ import com.homebox.lens.ui.common.MainScaffold
@Composable @Composable
fun SearchScreen( fun SearchScreen(
currentRoute: String?, currentRoute: String?,
navigationActions: NavigationActions navigationActions: NavigationActions,
) { ) {
// [UI_COMPONENT] // [UI_COMPONENT]
MainScaffold( MainScaffold(
topBarTitle = stringResource(id = R.string.search_title), topBarTitle = stringResource(id = R.string.search_title),
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions,
) { ) {
// [CORE-LOGIC] // [CORE-LOGIC]
Text(text = "TODO: Search Screen") Text(text = "TODO: Search Screen")

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor() : ViewModel() { class SearchViewModel
@Inject
constructor() : ViewModel() {
// [STATE] // [STATE]
// TODO: Implement UI state // TODO: Implement UI state
} }
// [END_FILE_SearchViewModel.kt] // [END_FILE_SearchViewModel.kt]

View File

@@ -22,6 +22,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R import com.homebox.lens.R
// [ENTRYPOINT] // [ENTRYPOINT]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Главная Composable-функция для экрана настройки соединения с сервером. * @summary Главная Composable-функция для экрана настройки соединения с сервером.
@@ -32,7 +33,7 @@ import com.homebox.lens.R
@Composable @Composable
fun SetupScreen( fun SetupScreen(
viewModel: SetupViewModel = hiltViewModel(), viewModel: SetupViewModel = hiltViewModel(),
onSetupComplete: () -> Unit onSetupComplete: () -> Unit,
) { ) {
// [STATE] // [STATE]
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -48,12 +49,13 @@ fun SetupScreen(
onServerUrlChange = viewModel::onServerUrlChange, onServerUrlChange = viewModel::onServerUrlChange,
onUsernameChange = viewModel::onUsernameChange, onUsernameChange = viewModel::onUsernameChange,
onPasswordChange = viewModel::onPasswordChange, onPasswordChange = viewModel::onPasswordChange,
onConnectClick = viewModel::connect onConnectClick = viewModel::connect,
) )
// [END_FUNCTION_SetupScreen] // [END_FUNCTION_SetupScreen]
} }
// [HELPER] // [HELPER]
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Отображает контент экрана настройки: поля ввода и кнопку. * @summary Отображает контент экрана настройки: поля ввода и кнопку.
@@ -69,33 +71,34 @@ private fun SetupScreenContent(
onServerUrlChange: (String) -> Unit, onServerUrlChange: (String) -> Unit,
onUsernameChange: (String) -> Unit, onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit, onPasswordChange: (String) -> Unit,
onConnectClick: () -> Unit onConnectClick: () -> Unit,
) { ) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar(title = { Text(stringResource(id = R.string.setup_title)) }) TopAppBar(title = { Text(stringResource(id = R.string.setup_title)) })
} },
) { paddingValues -> ) { paddingValues ->
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
OutlinedTextField( OutlinedTextField(
value = uiState.serverUrl, value = uiState.serverUrl,
onValueChange = onServerUrlChange, onValueChange = onServerUrlChange,
label = { Text(stringResource(id = R.string.setup_server_url_label)) }, label = { Text(stringResource(id = R.string.setup_server_url_label)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = uiState.username, value = uiState.username,
onValueChange = onUsernameChange, onValueChange = onUsernameChange,
label = { Text(stringResource(id = R.string.setup_username_label)) }, label = { Text(stringResource(id = R.string.setup_username_label)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
@@ -103,13 +106,13 @@ private fun SetupScreenContent(
onValueChange = onPasswordChange, onValueChange = onPasswordChange,
label = { Text(stringResource(id = R.string.setup_password_label)) }, label = { Text(stringResource(id = R.string.setup_password_label)) },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Button( Button(
onClick = onConnectClick, onClick = onConnectClick,
enabled = !uiState.isLoading, enabled = !uiState.isLoading,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp)) CircularProgressIndicator(modifier = Modifier.size(24.dp))
@@ -135,7 +138,7 @@ fun SetupScreenPreview() {
onServerUrlChange = {}, onServerUrlChange = {},
onUsernameChange = {}, onUsernameChange = {},
onPasswordChange = {}, onPasswordChange = {},
onConnectClick = {} onConnectClick = {},
) )
} }
// [END_FILE_SetupScreen.kt] // [END_FILE_SetupScreen.kt]

View File

@@ -22,6 +22,6 @@ data class SetupUiState(
val password: String = "", val password: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val isSetupComplete: Boolean = false val isSetupComplete: Boolean = false,
) )
// [END_FILE_SetupUiState.kt] // [END_FILE_SetupUiState.kt]

View File

@@ -8,7 +8,6 @@ import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.model.Credentials import com.homebox.lens.domain.model.Credentials
import com.homebox.lens.domain.repository.CredentialsRepository import com.homebox.lens.domain.repository.CredentialsRepository
import com.homebox.lens.domain.usecase.LoginUseCase import com.homebox.lens.domain.usecase.LoginUseCase
import com.homebox.lens.ui.screen.setup.SetupUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -18,6 +17,7 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
// [ENTITY: ViewModel('SetupViewModel')] // [ENTITY: ViewModel('SetupViewModel')]
/** /**
* [CONTRACT] * [CONTRACT]
* ViewModel для экрана первоначальной настройки (Setup). * ViewModel для экрана первоначальной настройки (Setup).
@@ -30,11 +30,12 @@ import javax.inject.Inject
* @invariant Состояние `uiState` всегда является единственным источником истины для UI. * @invariant Состояние `uiState` всегда является единственным источником истины для UI.
*/ */
@HiltViewModel @HiltViewModel
class SetupViewModel @Inject constructor( class SetupViewModel
@Inject
constructor(
private val credentialsRepository: CredentialsRepository, private val credentialsRepository: CredentialsRepository,
private val loginUseCase: LoginUseCase private val loginUseCase: LoginUseCase,
) : ViewModel() { ) : ViewModel() {
// [STATE] // [STATE]
private val _uiState = MutableStateFlow(SetupUiState()) private val _uiState = MutableStateFlow(SetupUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@@ -61,7 +62,7 @@ class SetupViewModel @Inject constructor(
it.copy( it.copy(
serverUrl = credentials.serverUrl, serverUrl = credentials.serverUrl,
username = credentials.username, username = credentials.username,
password = credentials.password password = credentials.password,
) )
} }
} }
@@ -116,10 +117,11 @@ class SetupViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
// [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов. // [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов.
val credentials = Credentials( val credentials =
Credentials(
serverUrl = _uiState.value.serverUrl.trim(), serverUrl = _uiState.value.serverUrl.trim(),
username = _uiState.value.username.trim(), username = _uiState.value.username.trim(),
password = _uiState.value.password password = _uiState.value.password,
) )
// [ACTION] Сохраняем учетные данные для будущего использования. // [ACTION] Сохраняем учетные данные для будущего использования.
@@ -134,10 +136,10 @@ class SetupViewModel @Inject constructor(
onFailure = { exception -> onFailure = { exception ->
// [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке. // [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке.
_uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") } _uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") }
} },
) )
} }
} }
// [END_CLASS_SetupViewModel] // [END_CLASS_SetupViewModel]
} }
// [END_FILE_SetupViewModel.kt] // [END_FILE_SetupViewModel.kt]

View File

@@ -18,26 +18,29 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme =
darkColorScheme(
primary = Purple80, primary = Purple80,
secondary = PurpleGrey80, secondary = PurpleGrey80,
tertiary = Pink80 tertiary = Pink80,
) )
private val LightColorScheme = lightColorScheme( private val LightColorScheme =
lightColorScheme(
primary = Purple40, primary = Purple40,
secondary = PurpleGrey40, secondary = PurpleGrey40,
tertiary = Pink40 tertiary = Pink40,
) )
@Composable @Composable
fun HomeboxLensTheme( fun HomeboxLensTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
val colorScheme = when { val colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
@@ -58,7 +61,7 @@ fun HomeboxLensTheme(
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
content = content content = content,
) )
} }
// [END_FILE_Theme.kt] // [END_FILE_Theme.kt]

View File

@@ -10,14 +10,16 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with // Set of Material typography styles to start with
val Typography = Typography( val Typography =
bodyLarge = TextStyle( Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp,
),
) )
)
// [END_FILE_Typography.kt] // [END_FILE_Typography.kt]

View File

@@ -1,46 +1,18 @@
// Файл: /data/semantic-ktlint-rules/build.gradle.kts
plugins { plugins {
id("com.android.application") kotlin("jvm")
id("org.jetbrains.kotlin.android")
} }
android {
namespace = "com.busya.ktlint.rules"
compileSdk = 34
defaultConfig {
applicationId = "com.busya.ktlint.rules"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies { dependencies {
// Зависимость для RuleSetProviderV3
implementation("com.pinterest.ktlint:ktlint-cli-ruleset-core:1.2.1")
// Зависимость для Rule, RuleId и psi-утилит
api("com.pinterest.ktlint:ktlint-rule-engine:1.2.1")
implementation("androidx.core:core-ktx:1.10.1") // Зависимости для тестирования остаются без изменений
implementation("androidx.appcompat:appcompat:1.6.1") testImplementation(kotlin("test"))
implementation("com.google.android.material:material:1.10.0") testImplementation("com.pinterest.ktlint:ktlint-test:1.2.1")
testImplementation("junit:junit:4.13.2") testImplementation("org.assertj:assertj-core:3.24.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
} }

View File

@@ -1,4 +1,16 @@
// Путь: data/semantic-ktlint-rules/src/main/java/com/busya/ktlint/rules/CustomRuleSetProvider.kt
package com.busya.ktlint.rules package com.busya.ktlint.rules
class CustomRuleSetProvider { import com.pinterest.ktlint.rule.engine.core.api.RuleProvider
import com.pinterest.ktlint.rule.engine.core.api.RuleSetId
import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3
class CustomRuleSetProvider : RuleSetProviderV3(RuleSetId("custom")) {
override fun getRuleProviders(): Set<RuleProvider> {
return setOf(
RuleProvider { FileHeaderRule() },
RuleProvider { MandatoryEntityDeclarationRule() },
RuleProvider { NoStrayCommentsRule() }
)
}
} }

View File

@@ -1,4 +1,33 @@
// Путь: data/semantic-ktlint-rules/src/main/java/com/busya/ktlint/rules/FileHeaderRule.kt
package com.busya.ktlint.rules package com.busya.ktlint.rules
class FileHeaderRule { import com.pinterest.ktlint.rule.engine.core.api.ElementType
import com.pinterest.ktlint.rule.engine.core.api.Rule
import com.pinterest.ktlint.rule.engine.core.api.Rule.About
import com.pinterest.ktlint.rule.engine.core.api.RuleId
import org.jetbrains.kotlin.com.intellij.lang.ASTNode
class FileHeaderRule : Rule(ruleId = RuleId("custom:file-header-rule"), about = About()) {
override fun beforeVisitChildNodes(
node: ASTNode,
autoCorrect: Boolean,
emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit
) {
if (node.elementType == ElementType.FILE) {
val lines = node.text.lines()
if (lines.size < 3) {
emit(node.startOffset, "File must start with a 3-line semantic header.", false)
return
}
if (!lines[0].startsWith("// [PACKAGE]")) {
emit(node.startOffset, "File header missing or incorrect. Line 1 must be '// [PACKAGE] ...'.", false)
}
if (!lines[1].startsWith("// [FILE]")) {
emit(node.startOffset + lines[0].length + 1, "File header missing or incorrect. Line 2 must be '// [FILE] ...'.", false)
}
if (!lines[2].startsWith("// [SEMANTICS]")) {
emit(node.startOffset + lines[0].length + lines[1].length + 2, "File header missing or incorrect. Line 3 must be '// [SEMANTICS] ...'.", false)
}
}
}
} }

View File

@@ -1,4 +1,40 @@
// Путь: data/semantic-ktlint-rules/src/main/java/com/busya/ktlint/rules/MandatoryEntityDeclarationRule.kt
package com.busya.ktlint.rules package com.busya.ktlint.rules
class MandatoryEntityDeclarationRule { import com.pinterest.ktlint.rule.engine.core.api.ElementType
import com.pinterest.ktlint.rule.engine.core.api.Rule
import com.pinterest.ktlint.rule.engine.core.api.Rule.About
import com.pinterest.ktlint.rule.engine.core.api.RuleId
import com.pinterest.ktlint.rule.engine.core.api.prevLeaf
import org.jetbrains.kotlin.com.intellij.lang.ASTNode
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtDeclaration
class MandatoryEntityDeclarationRule : Rule(ruleId = RuleId("custom:entity-declaration-rule"), about = About()) {
private val entityTypes = setOf(
ElementType.CLASS,
ElementType.OBJECT_DECLARATION,
ElementType.FUN
)
override fun beforeVisitChildNodes(
node: ASTNode,
autoCorrect: Boolean,
emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit
) {
if (node.elementType in entityTypes) {
val ktDeclaration = node.psi as? KtDeclaration ?: return
if (node.elementType == ElementType.FUN &&
(ktDeclaration.hasModifier(KtTokens.PRIVATE_KEYWORD) ||
ktDeclaration.hasModifier(KtTokens.PROTECTED_KEYWORD) ||
ktDeclaration.hasModifier(KtTokens.INTERNAL_KEYWORD))
) {
return
}
val prevComment = node.prevLeaf { it.elementType == ElementType.EOL_COMMENT }
if (prevComment == null || !prevComment.text.startsWith("// [ENTITY:")) {
emit(node.startOffset, "Missing or misplaced '// [ENTITY: ...]' declaration before '${node.elementType}'.", false)
}
}
}
} }

View File

@@ -1,4 +1,24 @@
// Путь: data/semantic-ktlint-rules/src/main/java/com/busya/ktlint/rules/NoStrayCommentsRule.kt
package com.busya.ktlint.rules package com.busya.ktlint.rules
class NoStrayCommentsRule { import com.pinterest.ktlint.rule.engine.core.api.ElementType
import com.pinterest.ktlint.rule.engine.core.api.Rule
import com.pinterest.ktlint.rule.engine.core.api.Rule.About
import com.pinterest.ktlint.rule.engine.core.api.RuleId
import org.jetbrains.kotlin.com.intellij.lang.ASTNode
class NoStrayCommentsRule : Rule(ruleId = RuleId("custom:no-stray-comments-rule"), about = About()) {
private val allowedCommentPattern = Regex("""^//\s?\[([A-Z_]+|ENTITY:|RELATION:|AI_NOTE:)]""")
override fun beforeVisitChildNodes(
node: ASTNode,
autoCorrect: Boolean,
emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit
) {
if (node.elementType == ElementType.EOL_COMMENT) {
val commentText = node.text
if (!allowedCommentPattern.matches(commentText)) {
emit(node.startOffset, "Stray comment found. Use semantic anchors like '// [TAG]' or '// [AI_NOTE]:' instead.", false)
}
}
}
} }

View File

@@ -0,0 +1 @@
com.busya.ktlint.rules.CustomRuleSetProvider

View File

@@ -1,17 +1,41 @@
package com.busya.ktlint.rules package com.busya.ktlint.rules
import org.junit.Test import com.pinterest.ktlint.test.KtLintAssertThat.Companion.assertThatRule
import org.junit.jupiter.api.Test
import org.junit.Assert.* class FileHeaderRuleTest {
private val ruleAssertThat = assertThatRule { FileHeaderRule() }
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test @Test
fun addition_isCorrect() { fun `should pass on correct header`() {
assertEquals(4, 2 + 2) val code = """
// [PACKAGE] com.example
// [FILE] Test.kt
// [SEMANTICS] test, example
package com.example
""".trimIndent()
ruleAssertThat(code).hasNoLintViolations()
}
@Test
fun `should fail on missing header`() {
val code = """
package com.example
""".trimIndent()
ruleAssertThat(code)
.hasLintViolation(1, 1, "File must start with a 3-line semantic header.")
}
@Test
fun `should fail on incorrect line 1`() {
val code = """
// [WRONG_TAG] com.example
// [FILE] Test.kt
// [SEMANTICS] test, example
package com.example
""".trimIndent()
ruleAssertThat(code)
.hasLintViolation(1, 1, "File header missing or incorrect. Line 1 must be '// [PACKAGE] ...'.")
} }
} }

View File

@@ -18,7 +18,7 @@ distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk-amd64 org.gradle.java.home=/usr/lib/jvm/java-21-openjdk-amd64
android.useAndroidX=true android.useAndroidX=true
# [ACTION] ??????????? ???????????? ????? ?????? (heap size) ??? Gradle ?? 4 ??. # [ACTION] ??????????? ???????????? ????? ?????? (heap size) ??? Gradle ?? 4 ??.

View File

@@ -0,0 +1,11 @@
<LOG_ENTRY timestamp="2025-08-15T12:00:00Z">
<TASK_FILE>20250813_094500_implement_labels_screen_fixed.xml</TASK_FILE>
<FULL_PATH>/home/busya/dev/homebox_lens/tasks/20250813_094500_implement_labels_screen_fixed.xml</FULL_PATH>
<STATUS>COMPLETED</STATUS>
<MESSAGE>Task completed successfully. The specification file already contained the required information.</MESSAGE>
<DETAILS>
The task was to add a Timber logging example to the project specification.
Upon inspection, the file 'tech_spec/tech_spec.txt' already contained the exact XML block.
No modification was necessary.
</DETAILS>
</LOG_ENTRY>

View File

@@ -23,3 +23,4 @@ include(":data")
include(":domain") include(":domain")
// [END_FILE_settings.gradle.kts] // [END_FILE_settings.gradle.kts]
include(":data:semantic-ktlint-rules")

1532
tasks/01.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
<!-- tasks/20250813_093000_clarify_logging_spec.xml -->
<TASK status="pending">
<WORK_ORDER id="task-20250813093000-002-spec-update">
<ACTION>MODIFY_SPECIFICATION</ACTION>
<TARGET_FILE>PROJECT_SPECIFICATION.xml</TARGET_FILE>
<GOAL>
Уточнить техническое решение по логированию (id="tech_logging"), добавив конкретный пример использования Timber.
Это устранит неоднозначность и предотвратит генерацию некорректного кода для логирования в будущем, предоставив ясный и копируемый образец.
</GOAL>
<CONTEXT_FILES>
<FILE>PROJECT_SPECIFICATION.xml</FILE>
</CONTEXT_FILES>
<PAYLOAD mode="APPEND_CHILD" target_node_xpath="//TECHNICAL_DECISIONS/DECISION[@id='tech_logging']">
<![CDATA[
<EXAMPLE lang="kotlin">
<summary>Пример корректного использования Timber</summary>
<code>
<![CDATA[
// Правильно: Прямой вызов статических методов Timber.
// Для информационных сообщений (INFO):
Timber.i("User logged in successfully. UserId: %s", userId)
// Для отладочных сообщений (DEBUG):
Timber.d("Starting network request to /items")
// Для ошибок (ERROR):
try {
// какая-то операция, которая может провалиться
} catch (e: Exception) {
Timber.e(e, "Failed to fetch user profile.")
}
// НЕПРАВИЛЬНО: Попытка создать экземпляр логгера.
// val logger = Timber.tag("MyScreen") // Избегать этого!
// logger.info("Some message") // Этот метод не существует в API Timber.
]]>
</code>
</EXAMPLE>
]]>
</PAYLOAD>
<IMPLEMENTATION_HINTS>
<HINT>Агент должен найти узел `<DECISION id="tech_logging">` в файле `PROJECT_SPECIFICATION.xml` с помощью XPath `//TECHNICAL_DECISIONS/DECISION[@id='tech_logging']`.</HINT>
<HINT>Затем он должен добавить XML-блок из секции `<PAYLOAD>` в качестве нового дочернего элемента к найденному узлу `<DECISION>`.</HINT>
<HINT>Операция `APPEND_CHILD` означает, что содержимое PAYLOAD добавляется в конец списка дочерних элементов целевого узла.</HINT>
</IMPLEMENTATION_HINTS>
</WORK_ORDER>
</TASK>