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

436
GEMINI.md
View File

@@ -1,348 +1,110 @@
<!-- Системный Промпт: 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">
<STEP id="1" name="List_Files_In_Tasks_Directory">
<ACTION>Выполни `ReadFolder` для директории `tasks/`.</ACTION>
</STEP>
<STEP id="2" name="Handle_Empty_Directory">
<CONDITION>Если список файлов пуст, заверши работу.</CONDITION>
</STEP>
<OPERATIONAL_LOOP name="AgentMainCycle"> <STEP id="3" name="Iterate_And_Find_First_Pending_Task">
<STEP id="1" name="List_Files_In_Tasks_Directory"> <LOOP variable="filename" in="list_from_step_1">
<ACTION>Выполни `ReadFolder` для директории `tasks/`.</ACTION> <SUB_STEP id="3.1" name="Read_File_With_Hierarchical_Fallback">
</STEP> <VARIABLE name="file_content"></VARIABLE>
<VARIABLE name="full_file_path">`/home/busya/dev/homebox_lens/tasks/{filename}`</VARIABLE>
<STEP id="2" name="Handle_Empty_Directory">
<CONDITION>Если список файлов пуст, заверши работу.</CONDITION> <!-- ПЛАН А: Стандартный ReadFile -->
</STEP> <ACTION>Попробуй прочитать файл с помощью `ReadFile tasks/{filename}`.</ACTION>
<SUCCESS_CONDITION>Если содержимое получено, сохрани его в `file_content` и переходи к шагу 3.2.</SUCCESS_CONDITION>
<FAILURE_CONDITION>Если `ReadFile` не сработал, залогируй "План А провалился" и переходи к Плану Б.</FAILURE_CONDITION>
<STEP id="3" name="Iterate_And_Find_First_Pending_Task"> <!-- ПЛАН Б: Прямой вызов Shell cat -->
<LOOP variable="filename" in="list_from_step_1"> <ACTION>Попробуй прочитать файл с помощью `Shell cat {full_file_path}`.</ACTION>
<!-- =================================================================== --> <SUCCESS_CONDITION>Если содержимое получено, сохрани его в `file_content` и переходи к шагу 3.2.</SUCCESS_CONDITION>
<!-- КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Трехуровневая система чтения файла --> <FAILURE_CONDITION>Если `Shell cat` не сработал, залогируй "План Б провалился" и переходи к Плану В.</FAILURE_CONDITION>
<!-- =================================================================== -->
<SUB_STEP id="3.1" name="Read_File_With_Hierarchical_Fallback">
<VARIABLE name="file_content"></VARIABLE>
<VARIABLE name="full_file_path">`/home/busya/dev/homebox_lens/tasks/{filename}`</VARIABLE>
<!-- ПЛАН А: Стандартный ReadFile -->
<ACTION>Попробуй прочитать файл с помощью `ReadFile tasks/{filename}`.</ACTION>
<SUCCESS_CONDITION>Если содержимое получено, сохрани его в `file_content` и переходи к шагу 3.2.</SUCCESS_CONDITION>
<FAILURE_CONDITION>Если `ReadFile` не сработал, залогируй "План А провалился" и переходи к Плану Б.</FAILURE_CONDITION>
<!-- ПЛАН Б: Прямой вызов Shell cat --> <!-- ПЛАН В: Обходной путь с Wildcard (доказанный метод) -->
<ACTION>Попробуй прочитать файл с помощью `Shell cat {full_file_path}`.</ACTION> <ACTION>Выполни команду `Shell cat tasks/*`. Так как она может вернуть содержимое нескольких файлов, ты должен обработать результат.</ACTION>
<SUCCESS_CONDITION>Если содержимое получено, сохрани его в `file_content` и переходи к шагу 3.2.</SUCCESS_CONDITION> <SUCCESS_CONDITION>
<FAILURE_CONDITION>Если `Shell cat` не сработал, залогируй "План Б провалился" и переходи к Плану В.</FAILURE_CONDITION> 1. Проанализируй вывод команды.
2. Найди блок, соответствующий XML-структуре, у которого корневой тег `<TASK status="pending">`.
3. Извлеки полное содержимое этого XML-блока и сохрани его в `file_content`.
4. Если содержимое успешно извлечено, переходи к шагу 3.2.
</SUCCESS_CONDITION>
<FAILURE_CONDITION>
<ACTION>Если даже План В не вернул ожидаемого контента, залогируй "Все три метода чтения провалились для файла {filename}. Пропускаю."</ACTION>
<ACTION>Перейди к следующей итерации цикла (`continue`).</ACTION>
</FAILURE_CONDITION>
</SUB_STEP>
<!-- ПЛАН В: Обходной путь с Wildcard (доказанный метод) --> <SUB_STEP id="3.2" name="Check_And_Process_Task">
<ACTION>Выполни команду `Shell cat tasks/*`. Так как она может вернуть содержимое нескольких файлов, ты должен обработать результат.</ACTION> <CONDITION>Если переменная `file_content` не пуста,</CONDITION>
<SUCCESS_CONDITION> <ACTION>
1. Проанализируй вывод команды. 1. Это твоя цель. Запомни путь к файлу (`tasks/{filename}`) и его содержимое.
2. Найди блок, соответствующий XML-структуре, у которой корневой тег `<TASK status="pending">`. 2. Немедленно передай управление в `EXECUTE_WORK_ORDER_WORKFLOW`.
3. Извлеки полное содержимое этого XML-блока и сохрани его в `file_content`. 3. **ПРЕРВИ ЦИКЛ ПОИСКА.**
4. Если содержимое успешно извлечено, переходи к шагу 3.2.
</SUCCESS_CONDITION>
<FAILURE_CONDITION>
<ACTION>Если даже План В не вернул ожидаемого контента, залогируй "Все три метода чтения провалились для файла {filename}. Пропускаю."</ACTION>
<ACTION>Перейди к следующей итерации цикла (`continue`).</ACTION>
</FAILURE_CONDITION>
</SUB_STEP>
<!-- =================================================================== -->
<!-- КОНЕЦ КЛЮЧЕВОГО ИЗМЕНЕНИЯ -->
<!-- =================================================================== -->
<SUB_STEP id="3.2" name="Check_And_Process_Task">
<CONDITION>Если переменная `file_content` не пуста,</CONDITION>
<ACTION>
1. Это твоя цель. Запомни путь к файлу (`tasks/{filename}`) и его содержимое.
2. Немедленно передай управление в `EXECUTE_WORK_ORDER_WORKFLOW`.
3. **ПРЕРВИ ЦИКЛ ПОИСКА.**
</ACTION>
</SUB_STEP>
</LOOP>
</STEP>
<STEP id="4" name="Handle_No_Pending_Tasks_Found">
<CONDITION>Если цикл из Шага 3 завершился, а задача не была передана на исполнение, заверши работу.</CONDITION>
</STEP>
</OPERATIONAL_LOOP>
<!-- Остальные блоки остаются без изменений из v3.1 -->
<SUB_WORKFLOW name="EXECUTE_WORK_ORDER_WORKFLOW">
<INPUT>task_file_path, work_order_content</INPUT>
<STEP id="E1" name="Log_Start">Добавь запись о начале выполнения задачи в `logs/communication_log.xml`. Включи `full_file_path` в детали.</STEP>
<STEP id="E2" name="Execute_Task">
<TRY>
<ACTION>Выполни задачу, как описано в `work_order_content`.</ACTION>
<SUCCESS>
<ACTION>Обнови статус в файле `task_file_path` на `status="completed"`.</ACTION>
<ACTION>Добавь запись об успехе в лог.</ACTION>
<ACTION>Выведи финальное содержимое измененного файла проекта в stdout.</ACTION>
</SUCCESS>
</TRY>
<CATCH exception="any">
<FAILURE>
<ACTION>Обнови статус в файле `task_file_path` на `status="failed"`.</ACTION>
<ACTION>Добавь запись о провале с деталями ошибки в лог.</ACTION>
</ACTION> </ACTION>
</CATCH> </SUB_STEP>
</STEP> </LOOP>
</SUB_WORKFLOW> </STEP>
<STEP id="4" name="Handle_No_Pending_Tasks_Found">
<CONDITION>Если цикл из Шага 3 завершился, а задача не была передана на исполнение, заверши работу.</CONDITION>
</STEP>
</OPERATIONAL_LOOP>
<SUB_WORKFLOW name="EXECUTE_WORK_ORDER_WORKFLOW">
<INPUT>task_file_path, work_order_content</INPUT>
<STEP id="E1" name="Log_Start">Добавь запись о начале выполнения задачи в `logs/communication_log.xml`. Включи `full_file_path` в детали.</STEP>
<STEP id="E2" name="Execute_Task">
<TRY>
<ACTION>Выполни задачу, как описано в `work_order_content`.</ACTION>
<!-- Блок успеха выполняется полностью -->
<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` в 'tasks/completed'.</ACTION>
<ACTION>Добавь запись об успехе в лог, включив полный вывод линтера (`linter_output`) в секцию `<LINTER_REPORT>`.</ACTION>
</SUB_STEP>
</SUCCESS>
</TRY>
<CATCH exception="any">
<FAILURE>
<ACTION>Обнови статус в файле `task_file_path` на `status="failed"`.</ACTION>
<ACTION>Добавь запись о провале с деталями ошибки в лог.</ACTION>
</FAILURE>
</CATCH>
</STEP>
</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 =
NavigationActions(navController) remember(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 для предоставления типизированных навигационных действий.
@@ -13,9 +14,9 @@ import androidx.navigation.NavHostController
class NavigationActions(private val navController: NavHostController) { class NavigationActions(private val navController: NavHostController) {
// [ACTION] // [ACTION]
/** /**
[CONTRACT] [CONTRACT]
@summary Навигация на главный экран. @summary Навигация на главный экран.
@sideeffect Очищает back stack до главного экрана, чтобы избежать циклов. @sideeffect Очищает back stack до главного экрана, чтобы избежать циклов.
*/ */
fun navigateToDashboard() { fun navigateToDashboard() {
navController.navigate(Screen.Dashboard.route) { navController.navigate(Screen.Dashboard.route) {
@@ -25,47 +26,55 @@ 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()
} }
} }
// [END_FILE_NavigationActions.kt] // [END_FILE_NavigationActions.kt]

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 =
.fillMaxWidth() Modifier
.padding(horizontal = 16.dp) .fillMaxWidth()
.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 =
.fillMaxSize() modifier
.padding(horizontal = 16.dp), .fillMaxSize()
verticalArrangement = Arrangement.spacedBy(24.dp) .padding(horizontal = 16.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 =
.height(120.dp) Modifier
.fillMaxWidth() .height(120.dp)
.padding(16.dp), .fillMaxWidth()
.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 =
.fillMaxWidth() Modifier
.padding(vertical = 16.dp), .fillMaxWidth()
textAlign = TextAlign.Center .padding(vertical = 16.dp),
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(
.height(80.dp) modifier =
.fillMaxWidth() Modifier
.background(MaterialTheme.colorScheme.secondaryContainer)) .height(80.dp)
.fillMaxWidth()
.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(
items = 123, statistics =
totalValue = 9999.99, GroupStatistics(
locations = 5, items = 123,
labels = 8 totalValue = 9999.99,
), locations = 5,
locations = listOf( labels = 8,
LocationOutCount(id="1", name="Office", color = "#FF0000", isArchived = false, itemCount = 10, createdAt = "", updatedAt = ""), ),
LocationOutCount(id="2", name="Garage", color = "#00FF00", isArchived = false, itemCount = 5, createdAt = "", updatedAt = ""), locations =
LocationOutCount(id="3",name="Living Room", color = "#0000FF", isArchived = false, itemCount = 15, createdAt = "", updatedAt = ""), listOf(
LocationOutCount(id="4",name="Kitchen", color = "#FFFF00", isArchived = false, itemCount = 20, createdAt = "", updatedAt = ""), LocationOutCount(
LocationOutCount(id="5",name="Basement", color = "#00FFFF", isArchived = false, itemCount = 3, createdAt = "", updatedAt = "") id = "1",
), name = "Office",
labels = listOf( color = "#FF0000",
LabelOut(id="1", name="electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""), isArchived = false,
LabelOut(id="2", name="important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""), itemCount = 10,
LabelOut(id="3", name="seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""), createdAt = "",
LabelOut(id="4", name="hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = "") updatedAt = "",
), ),
recentlyAddedItems = emptyList() LocationOutCount(
) id = "2",
name = "Garage",
color = "#00FF00",
isArchived = false,
itemCount = 5,
createdAt = "",
updatedAt = "",
),
LocationOutCount(
id = "3",
name = "Living Room",
color = "#0000FF",
isArchived = false,
itemCount = 15,
createdAt = "",
updatedAt = "",
),
LocationOutCount(
id = "4",
name = "Kitchen",
color = "#FFFF00",
isArchived = false,
itemCount = 20,
createdAt = "",
updatedAt = "",
),
LocationOutCount(
id = "5",
name = "Basement",
color = "#00FFFF",
isArchived = false,
itemCount = 3,
createdAt = "",
updatedAt = "",
),
),
labels =
listOf(
LabelOut(id = "1", name = "electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id = "2", name = "important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id = "3", name = "seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id = "4", name = "hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = ""),
),
recentlyAddedItems = emptyList(),
)
HomeboxLensTheme { 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,8 +424,8 @@ 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 = {},
) )
} }
} }
// [END_FILE_DashboardScreen.kt] // [END_FILE_DashboardScreen.kt]

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
/** /**
@@ -45,4 +46,4 @@ sealed interface DashboardUiState {
*/ */
data object Loading : DashboardUiState data object Loading : DashboardUiState
} }
// [END_FILE_DashboardUiState.kt] // [END_FILE_DashboardUiState.kt]

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,61 +26,64 @@ import javax.inject.Inject
* @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`. * @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`.
*/ */
@HiltViewModel @HiltViewModel
class DashboardViewModel @Inject constructor( class DashboardViewModel
private val getStatisticsUseCase: GetStatisticsUseCase, @Inject
private val getAllLocationsUseCase: GetAllLocationsUseCase, constructor(
private val getAllLabelsUseCase: GetAllLabelsUseCase, private val getStatisticsUseCase: GetStatisticsUseCase,
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase private val getAllLocationsUseCase: GetAllLocationsUseCase,
) : ViewModel() { private val getAllLabelsUseCase: GetAllLabelsUseCase,
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase,
) : ViewModel() {
// [STATE]
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
// [STATE] // [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow().
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading) // [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и
// [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow(). // должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока.
// [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и val uiState = _uiState.asStateFlow()
// должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока.
val uiState = _uiState.asStateFlow()
// [LIFECYCLE_HANDLER] // [LIFECYCLE_HANDLER]
init { init {
loadDashboardData() loadDashboardData()
} }
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Загружает все необходимые данные для экрана Dashboard. * @summary Загружает все необходимые данные для экрана Dashboard.
* @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его * @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`. * между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`.
* @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`. * @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`.
*/ */
fun loadDashboardData() { fun loadDashboardData() {
// [ENTRYPOINT] // [ENTRYPOINT]
viewModelScope.launch { viewModelScope.launch {
_uiState.value = DashboardUiState.Loading _uiState.value = DashboardUiState.Loading
Timber.i("[ACTION] Starting dashboard data collection.") Timber.i("[ACTION] Starting dashboard data collection.")
val statsFlow = flow { emit(getStatisticsUseCase()) } val statsFlow = flow { emit(getStatisticsUseCase()) }
val locationsFlow = flow { emit(getAllLocationsUseCase()) } val locationsFlow = flow { emit(getAllLocationsUseCase()) }
val labelsFlow = flow { emit(getAllLabelsUseCase()) } val labelsFlow = flow { emit(getAllLabelsUseCase()) }
val recentItemsFlow = getRecentlyAddedItemsUseCase(limit = 10) val recentItemsFlow = getRecentlyAddedItemsUseCase(limit = 10)
combine(statsFlow, locationsFlow, labelsFlow, recentItemsFlow) { stats, locations, labels, recentItems -> combine(statsFlow, locationsFlow, labelsFlow, recentItemsFlow) { stats, locations, labels, recentItems ->
DashboardUiState.Success( DashboardUiState.Success(
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 -> )
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.") }.collect { successState ->
_uiState.value = successState Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
_uiState.value = successState
}
} }
} }
// [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,16 +23,16 @@ 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")
} }
// [END_FUNCTION_InventoryListScreen] // [END_FUNCTION_InventoryListScreen]
} }

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
@HiltViewModel @HiltViewModel
class InventoryListViewModel @Inject constructor() : ViewModel() { class InventoryListViewModel
// [STATE] @Inject
// TODO: Implement UI state constructor() : ViewModel() {
} // [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,16 +23,16 @@ 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")
} }
// [END_FUNCTION_ItemDetailsScreen] // [END_FUNCTION_ItemDetailsScreen]
} }

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
@HiltViewModel @HiltViewModel
class ItemDetailsViewModel @Inject constructor() : ViewModel() { class ItemDetailsViewModel
// [STATE] @Inject
// TODO: Implement UI state constructor() : ViewModel() {
} // [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
// [STATE] @Inject
// TODO: Implement UI state constructor() : ViewModel() {
} // [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,
// [ENTRYPOINT] viewModel: LabelsListViewModel = hiltViewModel(),
val uiState by viewModel.uiState.collectAsState()
// [CORE-LOGIC]
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(id = R.string.screen_title_labels)) },
navigationIcon = {
// [ACTION] Handle back navigation
IconButton(onClick = {
Timber.i("[ACTION] Navigate up initiated.")
navController.navigateUp()
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.content_desc_navigate_back)
)
}
}
)
},
floatingActionButton = {
// [ACTION] Handle create new label initiation
FloatingActionButton(onClick = {
Timber.i("[ACTION] FAB clicked: Initiate create new label flow.")
viewModel.onShowCreateDialog()
}) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.content_desc_create_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]
* @summary Composable-функция для отображения списка меток.
* @param labels Список объектов `Label` для отображения.
* @param onLabelClick Лямбда-функция, вызываемая при нажатии на элемент списка.
* @param modifier Модификатор для настройки внешнего вида.
*/
@Composable
private fun LabelsList(
labels: List<Label>,
onLabelClick: (Label) -> Unit,
modifier: Modifier = Modifier
) {
// [CORE-LOGIC]
LazyColumn(
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
/**
* [CONTRACT]
* @summary Composable-функция для отображения одного элемента в списке меток.
* @param label Объект `Label`, который нужно отобразить.
* @param onClick Лямбда-функция, вызываемая при нажатии на элемент.
*/
@Composable
private fun LabelListItem(
label: Label,
onClick: () -> Unit
) {
// [CORE-LOGIC]
ListItem(
headlineContent = { Text(text = label.name) },
leadingContent = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Label,
contentDescription = stringResource(id = R.string.content_desc_label_icon)
)
},
modifier = Modifier.clickable(onClick = onClick)
)
}
// [END_FUNCTION] LabelListItem
/**
* [CONTRACT]
* @summary Диалоговое окно для создания новой метки.
* @param onConfirm Лямбда-функция, вызываемая при подтверждении создания с именем метки.
* @param onDismiss Лямбда-функция, вызываемая при закрытии диалога.
*/
@Composable
private fun CreateLabelDialog(
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) { ) {
// [STATE] // [STATE]
var text by remember { mutableStateOf("") } val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isConfirmEnabled = text.isNotBlank()
// [CORE-LOGIC] // [CONTRACT_VALIDATOR]
AlertDialog( // В Compose UI контракты проверяются через состояние и события.
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.dialog_title_create_label)) }, Scaffold(
text = { topBar = {
OutlinedTextField( // [ENTITY: Function('LabelsTopAppBar')]
value = text, LabelsTopAppBar(onNavigateBack = onNavigateBack)
onValueChange = { text = it },
label = { Text(stringResource(R.string.dialog_field_label_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}, },
confirmButton = { floatingActionButton = {
TextButton( // [ENTITY: Function('LabelsFloatingActionButton')]
onClick = { onConfirm(text) }, LabelsFloatingActionButton(onAddNewLabelClick = onAddNewLabelClick)
enabled = isConfirmEnabled },
) { ) { innerPadding ->
Text(stringResource(R.string.dialog_button_create)) // [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(
title = { Text(stringResource(id = R.string.screen_title_labels)) },
navigationIcon = {
IconButton(onClick = {
// [ACTION]
Timber.i("[INFO][ACTION][navigating_back] Navigate back from LabelsListScreen.")
onNavigateBack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.content_desc_navigate_back),
)
} }
}, },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_button_cancel))
}
}
) )
} }
// [END_FUNCTION] CreateLabelDialog
// [END_FILE] LabelsListScreen.kt /**
* [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(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(id = R.string.content_desc_add_label),
)
}
}
/**
* [CONTRACT]
* Основной контент экрана - список меток.
*
* @param modifier Модификатор для компоновки.
* @param labels Список меток для отображения.
* @param onLabelClick Обработчик нажатия на метку.
* @sideeffect Вызывает [onLabelClick] при взаимодействии пользователя.
*/
@Composable
private fun LabelsListContent(
modifier: Modifier = Modifier,
labels: List<Label>,
onLabelClick: (String) -> Unit,
) {
// [PRECONDITION]
requireNotNull(labels) { "Labels list cannot be null." }
LazyColumn(modifier = modifier) {
items(labels, key = { it.id }) { label ->
// [ENTITY: DataStructure('LabelListItem')]
ListItem(
headlineContent = { Text(label.name) },
leadingContent = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Label,
contentDescription = null, // Декоративная иконка
)
},
modifier =
Modifier.clickable {
// [ACTION]
Timber.i("[INFO][ACTION][handling_label_click] Label clicked: id='${label.id}', name='${label.name}'")
onLabelClick(label.id)
},
)
}
}
// [POSTCONDITION]
// В декларативном UI постусловие - это корректное отображение предоставленного состояния.
check(true) { "LazyColumn rendering is managed by Compose runtime." }
}
// [SECTION] Previews
@Preview(showBackground = true)
@Composable
private fun LabelsListScreenPreview() {
HomeboxLensTheme {
val sampleLabels =
listOf(
Label(id = "1", name = "Electronics", color = "#FF0000"),
Label(id = "2", name = "Books", color = "#00FF00"),
Label(id = "3", name = "Documents", color = "#0000FF"),
)
// [HELPER]
// Для превью мы не можем использовать реальный ViewModel, поэтому создаем заглушки.
Scaffold(
topBar = { LabelsTopAppBar(onNavigateBack = {}) },
floatingActionButton = { LabelsFloatingActionButton(onAddNewLabelClick = {}) },
) { padding ->
LabelsListContent(
modifier = Modifier.padding(padding),
labels = sampleLabels,
onLabelClick = {},
)
}
}
}
// [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 экрана со списком меток.
@@ -12,25 +13,27 @@ import com.homebox.lens.domain.model.Label
*/ */
sealed interface LabelsListUiState { sealed interface LabelsListUiState {
/** /**
@summary Состояние успеха, содержит список меток и состояние диалога. @summary Состояние успеха, содержит список меток и состояние диалога.
@property labels Список меток для отображения. @property labels Список меток для отображения.
@property isShowingCreateDialog Флаг, показывающий, должен ли быть отображен диалог создания метки. @property isShowingCreateDialog Флаг, показывающий, должен ли быть отображен диалог создания метки.
@invariant labels не может быть null. @invariant labels не может быть null.
*/ */
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 Указывает, что идет процесс загрузки меток.
*/ */
data object Loading : LabelsListUiState data object Loading : LabelsListUiState
} }
// [END_FILE_LabelsListUiState.kt] // [END_FILE_LabelsListUiState.kt]

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,116 +26,120 @@ 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]
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
val uiState = _uiState.asStateFlow()
// [STATE] // [INIT]
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading) init {
val uiState = _uiState.asStateFlow() loadLabels()
}
// [INIT] /**
init { * [CONTRACT]
loadLabels() * @summary Загружает список меток.
} * @description Выполняет `GetAllLabelsUseCase` и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error`.
* @sideeffect Асинхронно обновляет `_uiState`.
*/
// [ACTION]
fun loadLabels() {
// [ENTRYPOINT]
viewModelScope.launch {
_uiState.value = LabelsListUiState.Loading
Timber.i("[ACTION] Starting labels list load. State -> Loading.")
/** // [CORE-LOGIC]
* [CONTRACT] val result =
* @summary Загружает список меток. runCatching {
* @description Выполняет `GetAllLabelsUseCase` и обновляет UI, переключая его getAllLabelsUseCase()
* между состояниями `Loading`, `Success` и `Error`.
* @sideeffect Асинхронно обновляет `_uiState`.
*/
// [ACTION]
fun loadLabels() {
// [ENTRYPOINT]
viewModelScope.launch {
_uiState.value = LabelsListUiState.Loading
Timber.i("[ACTION] Starting labels list load. State -> Loading.")
// [CORE-LOGIC]
val result = runCatching {
getAllLabelsUseCase()
}
// [RESULT_HANDLER]
result.fold(
onSuccess = { labelOuts ->
Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.")
// [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'.
val labels = labelOuts.map { labelOut ->
Label(
id = labelOut.id,
name = labelOut.name
)
} }
_uiState.value = LabelsListUiState.Success(labels, isShowingCreateDialog = false)
}, // [RESULT_HANDLER]
onFailure = { exception -> result.fold(
Timber.e(exception, "[ERROR] Failed to load labels. State -> Error.") onSuccess = { labelOuts ->
_uiState.value = LabelsListUiState.Error( Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.")
message = exception.message ?: "Could not load labels." // [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'.
val labels =
labelOuts.map { labelOut ->
Label(
id = labelOut.id,
name = labelOut.name,
)
}
_uiState.value = LabelsListUiState.Success(labels, isShowingCreateDialog = false)
},
onFailure = { exception ->
Timber.e(exception, "[ERROR] Failed to load labels. State -> Error.")
_uiState.value =
LabelsListUiState.Error(
message = exception.message ?: "Could not load labels.",
)
},
)
}
}
/**
* [CONTRACT]
* @summary Инициирует отображение диалога для создания метки.
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `true`.
* @sideeffect Обновляет `_uiState`.
*/
// [ACTION]
fun onShowCreateDialog() {
Timber.i("[ACTION] Show create label dialog requested.")
if (_uiState.value is LabelsListUiState.Success) {
_uiState.update {
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = true)
} }
)
}
}
/**
* [CONTRACT]
* @summary Инициирует отображение диалога для создания метки.
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `true`.
* @sideeffect Обновляет `_uiState`.
*/
// [ACTION]
fun onShowCreateDialog() {
Timber.i("[ACTION] Show create label dialog requested.")
if (_uiState.value is LabelsListUiState.Success) {
_uiState.update {
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = true)
} }
} }
}
/** /**
* [CONTRACT] * [CONTRACT]
* @summary Скрывает диалог создания метки. * @summary Скрывает диалог создания метки.
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `false`. * @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `false`.
* @sideeffect Обновляет `_uiState`. * @sideeffect Обновляет `_uiState`.
*/ */
// [ACTION] // [ACTION]
fun onDismissCreateDialog() { fun onDismissCreateDialog() {
Timber.i("[ACTION] Dismiss create label dialog requested.") Timber.i("[ACTION] Dismiss create label dialog requested.")
if (_uiState.value is LabelsListUiState.Success) { if (_uiState.value is LabelsListUiState.Success) {
_uiState.update { _uiState.update {
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = false) (it as LabelsListUiState.Success).copy(isShowingCreateDialog = false)
}
} }
} }
/**
* [CONTRACT]
* @summary Создает новую метку. [MVP_SCOPE] ЗАГЛУШКА.
* @description В текущей реализации (План Б, Этап 1), эта функция только логирует действие
* и скрывает диалог. Реальная логика сохранения будет добавлена на следующем этапе.
* @param name Название новой метки.
* @precondition `name` не должен быть пустым.
* @sideeffect Логирует действие, обновляет `_uiState`, чтобы скрыть диалог.
*/
// [ACTION]
fun createLabel(name: String) {
// [PRECONDITION]
require(name.isNotBlank()) { "[CONTRACT_VIOLATION] Label name cannot be blank." }
// [ENTRYPOINT]
Timber.i("[ACTION] Create label called with name: '$name'. [STUBBED]")
// [CORE-LOGIC] - Заглушка. Здесь будет вызов CreateLabelUseCase.
// [POSTCONDITION] Скрываем диалог после "создания".
onDismissCreateDialog()
// [REFACTORING_NOTE] На следующем этапе нужно добавить вызов UseCase и обновить список.
}
} }
// [END_CLASS_LabelsListViewModel]
/**
* [CONTRACT]
* @summary Создает новую метку. [MVP_SCOPE] ЗАГЛУШКА.
* @description В текущей реализации (План Б, Этап 1), эта функция только логирует действие
* и скрывает диалог. Реальная логика сохранения будет добавлена на следующем этапе.
* @param name Название новой метки.
* @precondition `name` не должен быть пустым.
* @sideeffect Логирует действие, обновляет `_uiState`, чтобы скрыть диалог.
*/
// [ACTION]
fun createLabel(name: String) {
// [PRECONDITION]
require(name.isNotBlank()) { "[CONTRACT_VIOLATION] Label name cannot be blank." }
// [ENTRYPOINT]
Timber.i("[ACTION] Create label called with name: '$name'. [STUBBED]")
// [CORE-LOGIC] - Заглушка. Здесь будет вызов CreateLabelUseCase.
// [POSTCONDITION] Скрываем диалог после "создания".
onDismissCreateDialog()
// [REFACTORING_NOTE] На следующем этапе нужно добавить вызов UseCase и обновить список.
}
}
// [END_CLASS_LabelsListViewModel]

View File

@@ -17,27 +17,28 @@ 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) }
}
Scaffold { paddingValues -> Scaffold { paddingValues ->
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(paddingValues), .fillMaxSize()
contentAlignment = Alignment.Center .padding(paddingValues),
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 =
.align(Alignment.Center) Modifier
.padding(16.dp) .align(Alignment.Center)
.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 =
.align(Alignment.Center) Modifier
.padding(16.dp) .align(Alignment.Center)
.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 =
.fillMaxWidth() Modifier
.clickable(onClick = onClick) .fillMaxWidth()
.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 =
LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""), listOf(
LocationOutCount("2", "Kitchen", "#00FF00", false, 5, "", ""), LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""),
LocationOutCount("3", "Office", "#0000FF", false, 23, "", "") LocationOutCount("2", "Kitchen", "#00FF00", false, 5, "", ""),
) 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,36 +24,38 @@ 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]
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading)
val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow()
// [STATE] // [INITIALIZER]
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading) init {
val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow() loadLocations()
}
// [INITIALIZER] // [ACTION]
init {
loadLocations()
}
// [ACTION] /**
/** * [CONTRACT]
* [CONTRACT] * @summary Загружает список местоположений из репозитория.
* @summary Загружает список местоположений из репозитория. * @sideeffect Обновляет `_uiState` в зависимости от результата: Loading -> Success/Error.
* @sideeffect Обновляет `_uiState` в зависимости от результата: Loading -> Success/Error. */
*/ fun loadLocations() {
fun loadLocations() { viewModelScope.launch {
viewModelScope.launch { _uiState.value = LocationsListUiState.Loading
_uiState.value = LocationsListUiState.Loading try {
try { val locations = getAllLocationsUseCase()
val locations = getAllLocationsUseCase() _uiState.value = LocationsListUiState.Success(locations)
_uiState.value = LocationsListUiState.Success(locations) } catch (e: Exception) {
} catch (e: Exception) { _uiState.value = LocationsListUiState.Error(e.message ?: "Unknown error")
_uiState.value = LocationsListUiState.Error(e.message ?: "Unknown error") }
} }
} }
// [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,16 +23,16 @@ 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")
} }
// [END_FUNCTION_SearchScreen] // [END_FUNCTION_SearchScreen]
} }

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL] // [VIEWMODEL]
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor() : ViewModel() { class SearchViewModel
// [STATE] @Inject
// TODO: Implement UI state constructor() : ViewModel() {
} // [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 =
.fillMaxSize() Modifier
.padding(paddingValues) .fillMaxSize()
.padding(16.dp), .padding(paddingValues)
.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,114 +30,116 @@ import javax.inject.Inject
* @invariant Состояние `uiState` всегда является единственным источником истины для UI. * @invariant Состояние `uiState` всегда является единственным источником истины для UI.
*/ */
@HiltViewModel @HiltViewModel
class SetupViewModel @Inject constructor( class SetupViewModel
private val credentialsRepository: CredentialsRepository, @Inject
private val loginUseCase: LoginUseCase constructor(
) : ViewModel() { private val credentialsRepository: CredentialsRepository,
private val loginUseCase: LoginUseCase,
) : ViewModel() {
// [STATE]
private val _uiState = MutableStateFlow(SetupUiState())
val uiState = _uiState.asStateFlow()
// [STATE] // [LIFECYCLE_HANDLER]
private val _uiState = MutableStateFlow(SetupUiState()) init {
val uiState = _uiState.asStateFlow() // [ACTION] Загружаем учетные данные при создании ViewModel.
loadCredentials()
}
// [LIFECYCLE_HANDLER] /**
init { * [CONTRACT]
// [ACTION] Загружаем учетные данные при создании ViewModel. * [HELPER] Загружает учетные данные из репозитория при инициализации.
loadCredentials() * @sideeffect Асинхронно обновляет `_uiState` полученными учетными данными.
} */
private fun loadCredentials() {
/** // [ENTRYPOINT]
* [CONTRACT] viewModelScope.launch {
* [HELPER] Загружает учетные данные из репозитория при инициализации. // [CORE-LOGIC] Подписываемся на поток учетных данных.
* @sideeffect Асинхронно обновляет `_uiState` полученными учетными данными. credentialsRepository.getCredentials().collect { credentials ->
*/ // [ACTION] Обновляем состояние, если учетные данные существуют.
private fun loadCredentials() { if (credentials != null) {
// [ENTRYPOINT] _uiState.update {
viewModelScope.launch { it.copy(
// [CORE-LOGIC] Подписываемся на поток учетных данных. serverUrl = credentials.serverUrl,
credentialsRepository.getCredentials().collect { credentials -> username = credentials.username,
// [ACTION] Обновляем состояние, если учетные данные существуют. password = credentials.password,
if (credentials != null) { )
_uiState.update { }
it.copy(
serverUrl = credentials.serverUrl,
username = credentials.username,
password = credentials.password
)
} }
} }
} }
} }
}
/** /**
* [CONTRACT] * [CONTRACT]
* [ACTION] Обновляет URL сервера в состоянии UI в ответ на ввод пользователя. * [ACTION] Обновляет URL сервера в состоянии UI в ответ на ввод пользователя.
* @param newUrl Новое значение URL. * @param newUrl Новое значение URL.
* @sideeffect Обновляет поле `serverUrl` в `_uiState`. * @sideeffect Обновляет поле `serverUrl` в `_uiState`.
*/ */
fun onServerUrlChange(newUrl: String) { fun onServerUrlChange(newUrl: String) {
_uiState.update { it.copy(serverUrl = newUrl) } _uiState.update { it.copy(serverUrl = newUrl) }
}
/**
* [CONTRACT]
* [ACTION] Обновляет имя пользователя в состоянии UI в ответ на ввод пользователя.
* @param newUsername Новое значение имени пользователя.
* @sideeffect Обновляет поле `username` в `_uiState`.
*/
fun onUsernameChange(newUsername: String) {
_uiState.update { it.copy(username = newUsername) }
}
/**
* [CONTRACT]
* [ACTION] Обновляет пароль в состоянии UI в ответ на ввод пользователя.
* @param newPassword Новое значение пароля.
* @sideeffect Обновляет поле `password` в `_uiState`.
*/
fun onPasswordChange(newPassword: String) {
_uiState.update { it.copy(password = newPassword) }
}
/**
* [CONTRACT]
* [ACTION] Запускает процесс подключения и входа в систему по действию пользователя.
* Выполняет две основные операции:
* 1. Сохраняет введенные учетные данные для последующих сессий.
* 2. Выполняет вход в систему с использованием этих данных.
* @sideeffect Обновляет `_uiState`, управляя флагами `isLoading`, `error` и `isSetupComplete`.
* @sideeffect Вызывает `credentialsRepository.saveCredentials`, изменяя сохраненные данные.
* @sideeffect Вызывает `loginUseCase`, который, в свою очередь, сохраняет токен.
*/
fun connect() {
// [ENTRYPOINT]
viewModelScope.launch {
// [ACTION] Устанавливаем состояние загрузки и сбрасываем предыдущую ошибку.
_uiState.update { it.copy(isLoading = true, error = null) }
// [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов.
val credentials = Credentials(
serverUrl = _uiState.value.serverUrl.trim(),
username = _uiState.value.username.trim(),
password = _uiState.value.password
)
// [ACTION] Сохраняем учетные данные для будущего использования.
credentialsRepository.saveCredentials(credentials)
// [CORE-LOGIC] Выполняем UseCase и обрабатываем результат.
loginUseCase(credentials).fold(
onSuccess = {
// [ACTION] Обработка успеха: обновляем UI, отмечая завершение настройки.
_uiState.update { it.copy(isLoading = false, isSetupComplete = true) }
},
onFailure = { exception ->
// [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке.
_uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") }
}
)
} }
/**
* [CONTRACT]
* [ACTION] Обновляет имя пользователя в состоянии UI в ответ на ввод пользователя.
* @param newUsername Новое значение имени пользователя.
* @sideeffect Обновляет поле `username` в `_uiState`.
*/
fun onUsernameChange(newUsername: String) {
_uiState.update { it.copy(username = newUsername) }
}
/**
* [CONTRACT]
* [ACTION] Обновляет пароль в состоянии UI в ответ на ввод пользователя.
* @param newPassword Новое значение пароля.
* @sideeffect Обновляет поле `password` в `_uiState`.
*/
fun onPasswordChange(newPassword: String) {
_uiState.update { it.copy(password = newPassword) }
}
/**
* [CONTRACT]
* [ACTION] Запускает процесс подключения и входа в систему по действию пользователя.
* Выполняет две основные операции:
* 1. Сохраняет введенные учетные данные для последующих сессий.
* 2. Выполняет вход в систему с использованием этих данных.
* @sideeffect Обновляет `_uiState`, управляя флагами `isLoading`, `error` и `isSetupComplete`.
* @sideeffect Вызывает `credentialsRepository.saveCredentials`, изменяя сохраненные данные.
* @sideeffect Вызывает `loginUseCase`, который, в свою очередь, сохраняет токен.
*/
fun connect() {
// [ENTRYPOINT]
viewModelScope.launch {
// [ACTION] Устанавливаем состояние загрузки и сбрасываем предыдущую ошибку.
_uiState.update { it.copy(isLoading = true, error = null) }
// [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов.
val credentials =
Credentials(
serverUrl = _uiState.value.serverUrl.trim(),
username = _uiState.value.username.trim(),
password = _uiState.value.password,
)
// [ACTION] Сохраняем учетные данные для будущего использования.
credentialsRepository.saveCredentials(credentials)
// [CORE-LOGIC] Выполняем UseCase и обрабатываем результат.
loginUseCase(credentials).fold(
onSuccess = {
// [ACTION] Обработка успеха: обновляем UI, отмечая завершение настройки.
_uiState.update { it.copy(isLoading = false, isSetupComplete = true) }
},
onFailure = { exception ->
// [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке.
_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,34 +18,37 @@ 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 =
primary = Purple80, darkColorScheme(
secondary = PurpleGrey80, primary = Purple80,
tertiary = Pink80 secondary = PurpleGrey80,
) tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme( private val LightColorScheme =
primary = Purple40, lightColorScheme(
secondary = PurpleGrey40, primary = Purple40,
tertiary = Pink40 secondary = PurpleGrey40,
) 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 =
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { when {
val context = LocalContext.current dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) val context = LocalContext.current
} if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme darkTheme -> DarkColorScheme
else -> LightColorScheme else -> LightColorScheme
} }
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
@@ -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(
fontFamily = FontFamily.Default, bodyLarge =
fontWeight = FontWeight.Normal, TextStyle(
fontSize = 16.sp, fontFamily = FontFamily.Default,
lineHeight = 24.sp, fontWeight = FontWeight.Normal,
letterSpacing = 0.5.sp fontSize = 16.sp,
lineHeight = 24.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>