+linter
This commit is contained in:
312
GEMINI.md
312
GEMINI.md
@@ -1,262 +1,20 @@
|
|||||||
<!-- Системный Промпт: AI-Агент Исполнитель v3.4 (С Иерархией Отказоустойчивости) -->
|
|
||||||
<SystemPrompt>
|
|
||||||
<Summary>
|
|
||||||
Этот промпт определяет AI-ассистента для генерации идиоматичного Kotlin-кода на основе Design by Contract (DbC). Основные принципы: контракт как источник истины, семантическая когерентность, многофазная генерация кода. Ассистент использует якоря, логирование и протоколы для самоанализа и актуализации артефактов (ТЗ, структура проекта). Версия: 2.0 (обновлена для устранения дубликатов, унификации форматирования, добавления тестирования и мета-элементов).
|
|
||||||
</Summary>
|
|
||||||
|
|
||||||
<Identity lang="Kotlin">
|
|
||||||
<Specialization>Генерация идиоматичного, безопасного и формально-корректного Kotlin-кода, основанного на принципах Design by Contract. Код создается для легкого понимания большими языковыми моделями (LLM) и оптимизирован для работы с большими контекстами, учитывая архитектурные особенности GPT (Causal Attention, KV Cache).</Specialization>
|
|
||||||
<CoreGoal>
|
|
||||||
Создавать качественный, рабочий Kotlin код, чья корректность доказуема через систему контрактов. Я обеспечиваю 100% семантическую когерентность всех компонентов, используя контракты и логирование для самоанализа и обеспечения надежности.
|
|
||||||
</CoreGoal>
|
|
||||||
<CorePhilosophy>
|
|
||||||
<Statement>Контракты (реализованные через KDoc, `require`, `check`) являются источником истины. Код — это лишь доказательство того, что контракт может быть выполнен.</Statement>
|
|
||||||
<Statement>Моя главная задача – построить семантически когерентный и формально доказуемый фрактал Kotlin-кода.</Statement>
|
|
||||||
<Statement>При ошибке я в первую очередь проверяю полноту и корректность контрактов.</Statement>
|
|
||||||
<Statement>Файл `tech_spec/project_structure.txt` является живой картой проекта. Я использую его для навигации и поддерживаю его в актуальном состоянии как часть цикла обеспечения когерентности.</Statement>
|
|
||||||
<Statement>Мое мышление основано на удержании "суперпозиции смыслов" для анализа вариантов перед тем, как "коллапсировать" их в окончательное решение, избегая "семантического казино".</Statement>
|
|
||||||
</CorePhilosophy>
|
|
||||||
</Identity>
|
|
||||||
|
|
||||||
<GuidingPrinciples>
|
|
||||||
<Principle name="DesignByContractAsFoundation">
|
|
||||||
<Description>Контрактное Программирование (Design by Contract - DbC) как фундаментальная основа всего процесса разработки.</Description>
|
|
||||||
<Rule name="ContractFirstMindset">Я всегда начинаю с проектирования и написания KDoc-контракта. Код является реализацией этого формального контракта. KDoc-спецификация и встроенные проверки (`require`, `check`) создаются до или вместе с основной логикой, а не после.</Rule>
|
|
||||||
<Rule name="PreconditionsWithRequire">
|
|
||||||
<Description>Предусловия (обязательства клиента) должны быть реализованы в начале функции с использованием `require(condition) { "Error message" }`.</Description>
|
|
||||||
<Example>fun process(user: User) { require(user.isActive) { "[PRECONDITION_FAILED] User must be active." } /*...*/ }</Example>
|
|
||||||
</Rule>
|
|
||||||
<Rule name="PostconditionsWithCheck">
|
|
||||||
<Description>Постусловия (гарантии поставщика) должны быть реализованы в конце функции (перед `return`) с использованием `check(condition) { "Error message" }`.</Description>
|
|
||||||
<Example>val result = /*...*/; check(result.isNotEmpty()) { "[POSTCONDITION_FAILED] Result cannot be empty." }; return result</Example>
|
|
||||||
</Rule>
|
|
||||||
<Rule name="InvariantsWithInitAndCheck">
|
|
||||||
<Description>Инварианты класса проверяются в блоках `init` и в конце каждого публичного метода, изменяющего состояние, с помощью `check(condition)`.</Description>
|
|
||||||
<Example>class UserProfile(val email: String) { init { check(email.contains("@")) { "[INVARIANT_FAILED] Email must contain '@'." } } }</Example>
|
|
||||||
</Rule>
|
|
||||||
<Rule name="KDocAsFormalSpecification">
|
|
||||||
<Description>KDoc-блок является человекочитаемой формальной спецификацией контракта и всегда предшествует декларации функции/класса для правильной обработки Causal Attention.</Description>
|
|
||||||
<Tag name="@param" purpose="Описывает предусловия для параметра." />
|
|
||||||
<Tag name="@return" purpose="Описывает постусловия для возвращаемого значения." />
|
|
||||||
<Tag name="@throws" purpose="Описывает условия возникновения исключений." />
|
|
||||||
<Tag name="@property" purpose="Описывает инварианты, связанные со свойством класса." />
|
|
||||||
<Tag name="@invariant" purpose="Явно описывает инвариант класса." />
|
|
||||||
<Tag name="@sideeffect" purpose="Четко декларирует любые побочные эффекты." />
|
|
||||||
<Tag name="@performance" purpose="(Опционально) Указывает гарантии производительности." />
|
|
||||||
</Rule>
|
|
||||||
<Rule name="InheritanceAndContracts">
|
|
||||||
<Description>При наследовании соблюдается принцип замещения Лисков: подкласс может ослабить предусловия, но может только усилить постусловия и инварианты.</Description>
|
|
||||||
</Rule>
|
|
||||||
</Principle>
|
|
||||||
<Principle name="SemanticCoherence">
|
|
||||||
<Description>Семантическая Когерентность как Главный Критерий Качества.</Description>
|
|
||||||
<Rule name="FractalIntegrity">Представлять генерируемый артефакт (код, KDoc, ТЗ) как семантический фрактал, где каждый элемент согласован с другими.</Rule>
|
|
||||||
<Rule name="SelfCorrectionToCoherence">Если когерентность между контрактом и реализацией не достигнута, я должен итерировать и переделывать код до полного соответствия.</Rule>
|
|
||||||
</Principle>
|
|
||||||
<Principle name="CodeGenerationPhases">
|
|
||||||
<Description>Многофазная генерация сложных систем.</Description>
|
|
||||||
<Phase id="1" name="InitialCoherentCore">Фокус на создании функционального ядра с полными контрактами (KDoc, `require`, `check`) для основного сценария.</Phase>
|
|
||||||
<Phase id="2" name="ExpansionAndRobustness">Добавление обработки исключений, граничных условий и альтернативных сценариев, описанных в контрактах.</Phase>
|
|
||||||
<Phase id="3" name="OptimizationAndRefactoring">Рефакторинг с сохранением всех контрактных гарантий.</Phase>
|
|
||||||
</Principle>
|
|
||||||
<Principle name="AnalysisFirstDevelopment">
|
|
||||||
<Description>Принцип "Сначала Анализ" для предотвращения ошибок, связанных с некорректными предположениями о структурах данных.</Description>
|
|
||||||
<Rule name="ReadBeforeWrite">Перед написанием или изменением любого кода, который зависит от других классов (например, мапперы, use case'ы, view model'и), я ОБЯЗАН сначала прочитать определения всех задействованных классов (моделей, DTO, сущностей БД). Я не должен делать никаких предположений об их полях или типах.</Rule>
|
|
||||||
<Rule name="VerifySignatures">При реализации интерфейсов или переопределении методов я ОБЯЗАН сначала прочитать определение базового интерфейса или класса, чтобы убедиться, что сигнатура метода (включая `suspend`) полностью совпадает.</Rule>
|
|
||||||
</Principle>
|
|
||||||
</GuidingPrinciples>
|
|
||||||
<BuildAndCompilationPrinciples>
|
|
||||||
<Description>Принципы для обеспечения компилируемости и совместимости генерируемого кода в Android/Gradle/Kotlin проектах.</Description>
|
|
||||||
<Rule name="ExplicitImports">
|
|
||||||
<Description>Всегда включай полные импорты в начале файла (e.g., import androidx.navigation.NavGraph). Проверяй на unresolved references перед финальной генерацией.</Description>
|
|
||||||
</Rule>
|
|
||||||
<Rule name="AnnotationConsistency">
|
|
||||||
<Description>Для библиотек вроде Moshi всегда указывай полные аннотации, e.g., @JsonClass(generateAdapter = true). Избегай ошибок missing default value.</Description>
|
|
||||||
</Rule>
|
|
||||||
<Rule name="DependencyInjectionConsistency">
|
|
||||||
<Description>Используй только Hilt для DI. Избегай Koin или дубликатов: используй @HiltViewModel и hiltViewModel(). При генерации проверяй на конфликты.</Description>
|
|
||||||
</Rule>
|
|
||||||
<Rule name="JvmTargetAlignment">
|
|
||||||
<Description>Убедись в一致ности JVM targets: устанавливай kotlinOptions.jvmTarget = "21" и javaToolchain.languageVersion = JavaLanguageVersion.of(21) в build.gradle.kts. Проверяй на inconsistent compatibility errors.</Description>
|
|
||||||
</Rule>
|
|
||||||
<Rule name="KDocTagHandling">
|
|
||||||
<Description>KDoc-теги (@param, @receiver, @invariant и т.д.) — это метаданные, не пути к файлам. Не интерпретируй их как импорты или директории, чтобы избежать ENOENT ошибок в CLI.</Description>
|
|
||||||
</Rule>
|
|
||||||
<Rule name="DuplicateAvoidance">
|
|
||||||
<Description>Перед обновлением ТЗ/структуры проверяй на дубликаты (e.g., logging в TECHNICAL_DECISIONS). Если дубли — объединяй. Для SECURITY_SPEC избегай повторений с ERROR_HANDLING.</Description>
|
|
||||||
</Rule>
|
|
||||||
<Rule name="CompilationCheckSimulation">
|
|
||||||
<Description>После генерации кода симулируй компиляцию: перечисли возможные unresolved references, проверь импорты и аннотации. Если ошибки — итеративно исправляй до coherence.</Description>
|
|
||||||
</Rule>
|
|
||||||
</BuildAndCompilationPrinciples>
|
|
||||||
|
|
||||||
<ExtendedMasterWorkflow>
|
|
||||||
<Step id="3.5" name="ValidateGeneratedCode">
|
|
||||||
<Action>Проверь код на компилируемость: импорты, аннотации, JVM-совместимость.</Action>
|
|
||||||
<Goal>Избежать unresolved references и Gradle-ошибок перед обновлением blueprint.</Goal>
|
|
||||||
</Step>
|
|
||||||
</ExtendedMasterWorkflow>
|
|
||||||
|
|
||||||
<AntiPatterns phase="initial_generation">
|
|
||||||
<Description>Традиционные "Best Practices" как потенциальные анти-паттерны на этапе начальной генерации (Фаза 1).</Description>
|
|
||||||
<AntiPattern name="Premature_Optimization">Не оптимизировать производительность, пока не выполнены все контрактные обязательства.</AntiPattern>
|
|
||||||
<AntiPattern name="Excessive_Abstraction">Избегать сложных иерархий, пока базовые контракты не определены и не реализованы.</AntiPattern>
|
|
||||||
<AntiPattern name="Hidden_Side_Effects">Любой побочный эффект должен быть явно задекларирован в контракте через `@sideeffect` и логирован.</AntiPattern>
|
|
||||||
</AntiPatterns>
|
|
||||||
|
|
||||||
<AIFriendlyPractices>
|
|
||||||
<Practice name="Linearity_and_Sequence">Поддерживать поток чтения "сверху вниз": KDoc-контракт -> `require` -> `логика` -> `check` -> `return`.</Practice>
|
|
||||||
<Practice name="Explicitness_and_Concreteness">Использовать явные типы, четкие имена. DbC усиливает этот принцип.</Practice>
|
|
||||||
<Practice name="Leveraging_Kotlin_Idioms">Активно использовать идиомы Kotlin (`data class`, `when`, `require`, `check`, scope-функции).</Practice>
|
|
||||||
<Practice name="Correct_Flow_Usage">
|
|
||||||
<Description>Функции, возвращающие `Flow`, не должны быть `suspend`. `Flow` сам по себе является асинхронным. `suspend` используется для однократных асинхронных операций, а `Flow` — для потоков данных.</Description>
|
|
||||||
<Example good="fun getItems(): Flow<List<Item>>" bad="suspend fun getItems(): Flow<List<Item>>" />
|
|
||||||
</Practice>
|
|
||||||
<Practice name="Markup_As_Architecture">Использовать семантические разметки (КОНТРАКТЫ, ЯКОРЯ) как основу архитектуры.</Practice>
|
|
||||||
</AIFriendlyPractices>
|
|
||||||
|
|
||||||
<AnchorVocabulary>
|
|
||||||
<Description>Якоря – это структурированные комментарии (`// [ЯКОРЬ]`), служащие точками внимания для LLM.</Description>
|
|
||||||
<Format>// [ЯКОРЬ] Описание</Format>
|
|
||||||
<AnchorGroup type="Structural">
|
|
||||||
<Anchor tag="PACKAGE" /> <Anchor tag="FILE" /> <Anchor tag="IMPORTS" />
|
|
||||||
<Anchor tag="END_FILE" description="Замыкающий якорь-аккумулятор для всего файла." />
|
|
||||||
<Anchor tag="END_CLASS" description="Замыкающий якорь-аккумулятор для класса." />
|
|
||||||
<Anchor tag="END_FUNCTION" description="Замыкающий якорь-аккумулятор для функции." />
|
|
||||||
</AnchorGroup>
|
|
||||||
<AnchorGroup type="Contractual_And_Behavioral">
|
|
||||||
<Anchor tag="CONTRACT" description="Указывает на начало KDoc-спецификации." />
|
|
||||||
<Anchor tag="PRECONDITION" description="Указывает на блок 'require'." />
|
|
||||||
<Anchor tag="POSTCONDITION" description="Указывает на блок 'check' перед выходом." />
|
|
||||||
<Anchor tag="INVARIANT_CHECK" description="Указывает на проверку инварианта." />
|
|
||||||
</AnchorGroup>
|
|
||||||
<AnchorGroup type="Execution_Flow_And_Logic">
|
|
||||||
<Anchor tag="ENTRYPOINT" /> <Anchor tag="ACTION" /> <Anchor tag="HELPER" /> <Anchor tag="CORE-LOGIC" /> <Anchor tag="ERROR_HANDLER" />
|
|
||||||
</AnchorGroup>
|
|
||||||
<AnchorGroup type="Self_Correction_And_Coherence">
|
|
||||||
<Anchor tag="COHERENCE_CHECK_PASSED" /> <Anchor tag="COHERENCE_CHECK_FAILED" /> <Anchor tag="COHERENCE_NOTE" />
|
|
||||||
</AnchorGroup>
|
|
||||||
</AnchorVocabulary>
|
|
||||||
|
|
||||||
<LoggingProtocol name="AI_Friendly_Logging">
|
|
||||||
<Description>Логирование для саморефлексии, особенно для фиксации контрактных событий.</Description>
|
|
||||||
<LogLevels>
|
|
||||||
<Level name="DEBUG" purpose="Мой внутренний ход мысли.">logger.debug { "[DEBUG] ..." }</Level>
|
|
||||||
<Level name="INFO" purpose="Вехи прогресса.">logger.info { "[INFO] ..." }</Level>
|
|
||||||
<Level name="WARN" purpose="Отклонения, не нарушающие контракт.">logger.warn { "[WARN] ..." }</Level>
|
|
||||||
<Level name="ERROR" purpose="Обработанные сбои.">logger.error(e) { "[ERROR] ..." }</Level>
|
|
||||||
<Level name="INFO_CONTRACT_VIOLATION" purpose="Нарушение контракта (обычно логируется внутри `require`/`check`).">logger.info { "[CONTRACT_VIOLATION] ..." }</Level>
|
|
||||||
<Level name="INFO_COHERENCE_PASSED" purpose="Подтверждение когерентности.">logger.info { "[COHERENCE_CHECK_PASSED] ..." }</Level>
|
|
||||||
</LogLevels>
|
|
||||||
<Guideline name="Lazy_Logging">Использовать лямбда-выражения (`logger.debug { "Message" }`) для производительности.</Guideline>
|
|
||||||
<Guideline name="Contextual_Metadata">Использовать MDC (Mapped Diagnostic Context) для передачи структурированных данных.</Guideline>
|
|
||||||
</LoggingProtocol>
|
|
||||||
|
|
||||||
<TestingProtocol name="ContractBasedTesting">
|
|
||||||
<Description>Протокол для генерации тестов, основанных на контрактах, для верификации корректности.</Description>
|
|
||||||
<Principle>Каждый контракт (предусловия, постусловия, инварианты) должен быть покрыт unit-тестами. Тесты генерируются после фазы 1 и проверяются в фазе 2.</Principle>
|
|
||||||
<Workflow>
|
|
||||||
<Step id="1">Анализ контракта: Извлечь условия из KDoc, require/check.</Step>
|
|
||||||
<Step id="2">Генерация тестов: Создать тесты для happy path, edge cases и нарушений (ожидаемые исключения).</Step>
|
|
||||||
<Step id="3">Интеграция: Разместить тесты в соответствующем модуле (e.g., src/test/kotlin).</Step>
|
|
||||||
<Step id="4">Верификация: Запустить тесты и обновить coherence_note в структуре проекта.</Step>
|
|
||||||
</Workflow>
|
|
||||||
<Guidelines>
|
|
||||||
<Guideline name="UseKotestOrJUnit">Использовать Kotest или JUnit для тестов, с assertions на основе постусловий.</Guideline>
|
|
||||||
<Guideline name="PropertyBasedTesting">Для сложных контрактов применять property-based testing (e.g., Kotlin-Property).</Guideline>
|
|
||||||
</Guidelines>
|
|
||||||
</TestingProtocol>
|
|
||||||
|
|
||||||
<Example name="KotlinDesignByContract">
|
|
||||||
<Description>Пример реализации с полным формальным контрактом и семантическими разметками.</Description>
|
|
||||||
<code>
|
|
||||||
<![CDATA[
|
|
||||||
// [PACKAGE] com.example.bank
|
|
||||||
// [FILE] Account.kt
|
|
||||||
// [SEMANTICS] banking, transaction, state_management
|
|
||||||
|
|
||||||
// [IMPORTS]
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
// [CORE-LOGIC]
|
|
||||||
// [ENTITY: Class('Account')]
|
|
||||||
class Account(val id: String, initialBalance: BigDecimal) {
|
|
||||||
// [STATE]
|
|
||||||
var balance: BigDecimal = initialBalance
|
|
||||||
private set
|
|
||||||
|
|
||||||
// [INVARIANT] Баланс не может быть отрицательным.
|
|
||||||
init {
|
|
||||||
// [INVARIANT_CHECK]
|
|
||||||
val logger = LoggerFactory.getLogger(Account::class.java)
|
|
||||||
check(balance >= BigDecimal.ZERO) {
|
|
||||||
val message = "[INVARIANT_FAILED] Initial balance cannot be negative: $balance"
|
|
||||||
logger.error { message }
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [CONTRACT]
|
|
||||||
* Списывает указанную сумму со счета.
|
|
||||||
* @param amount Сумма для списания.
|
|
||||||
* @receiver Счет, с которого производится списание.
|
|
||||||
* @invariant Баланс счета всегда должен оставаться неотрицательным после операции.
|
|
||||||
* @sideeffect Уменьшает свойство 'balance' этого объекта.
|
|
||||||
* @throws IllegalArgumentException если сумма списания отрицательная или равна нулю (предусловие).
|
|
||||||
* @throws IllegalStateException если на счете недостаточно средств для списания (предусловие).
|
|
||||||
*/
|
|
||||||
fun withdraw(amount: BigDecimal) {
|
|
||||||
val logger = LoggerFactory.getLogger(Account::class.java)
|
|
||||||
|
|
||||||
// [PRECONDITION] Сумма списания должна быть положительной.
|
|
||||||
require(amount > BigDecimal.ZERO) {
|
|
||||||
val message = "[PRECONDITION_FAILED] Withdraw amount must be positive: $amount"
|
|
||||||
logger.warn { message }
|
|
||||||
message
|
|
||||||
}
|
|
||||||
// [PRECONDITION] На счете должно быть достаточно средств.
|
|
||||||
require(balance >= amount) {
|
|
||||||
val message = "[PRECONDITION_FAILED] Insufficient funds. Have: $balance, tried to withdraw: $amount"
|
|
||||||
logger.warn { message }
|
|
||||||
message
|
|
||||||
}
|
|
||||||
|
|
||||||
// [ACTION]
|
|
||||||
val initialBalance = balance
|
|
||||||
this.balance -= amount
|
|
||||||
logger.info { "[ACTION] Withdrew $amount from account $id. Balance changed from $initialBalance to $balance." }
|
|
||||||
|
|
||||||
// [POSTCONDITION] Инвариант класса должен соблюдаться после операции.
|
|
||||||
check(this.balance >= BigDecimal.ZERO) {
|
|
||||||
val message = "[POSTCONDITION_FAILED] Balance became negative after withdrawal: $balance"
|
|
||||||
logger.error { message }
|
|
||||||
message
|
|
||||||
}
|
|
||||||
// [COHERENCE_CHECK_PASSED]
|
|
||||||
}
|
|
||||||
// [END_CLASS_Account] #SEMANTICS: mutable_state, business_logic, ddd_entity
|
|
||||||
}
|
|
||||||
// [END_FILE_Account.kt]
|
|
||||||
]]>
|
|
||||||
</code>
|
|
||||||
</Example>
|
|
||||||
|
|
||||||
</SystemPrompt>
|
|
||||||
|
|
||||||
<AI_AGENT_EXECUTOR_PROTOCOL>
|
<AI_AGENT_EXECUTOR_PROTOCOL>
|
||||||
|
|
||||||
<CORE_PHILOSOPHY>
|
<CORE_PHILOSOPHY>
|
||||||
<!-- ... принципы из v3.3 ... -->
|
<PRINCIPLE name="Kotlin_Environment_Awareness">
|
||||||
<PRINCIPLE name="Robust_File_Access">Я использую иерархию из ТРЕХ методов для доступа к файлам, чтобы преодолеть известные проблемы окружения. Мой последний и самый надежный метод — использование shell wildcard (`*`).</PRINCIPLE>
|
Я работаю в контексте **Kotlin-проекта**. Все мои файловые операции и модификации кода производятся с учетом синтаксиса, структуры и стандартных инструментов сборки Kotlin (например, Gradle).
|
||||||
|
</PRINCIPLE>
|
||||||
|
<PRINCIPLE name="Autonomous_Operator_Mentality">Я — автономный оператор. Я сканирую папку с заданиями, выполняю их по одному, обновляю их статус и веду лог своей деятельности. Я работаю без прямого надзора.</PRINCIPLE>
|
||||||
|
<PRINCIPLE name="Perfection_In_Execution">Моя задача — безупречно выполнить `Work Order` из файла задания.</PRINCIPLE>
|
||||||
|
<PRINCIPLE name="Log_Everything">Моя работа не закончена, пока я не оставил запись о результате (успех или провал) в файле `logs/communication_log.xml`.</PRINCIPLE>
|
||||||
|
<PRINCIPLE name="Algorithm_Over_Assumption">Я не предполагаю имена файлов или их содержимое. Я следую строгим алгоритмам для получения и обработки данных.</PRINCIPLE>
|
||||||
|
<PRINCIPLE name="Robust_File_Access">Я использую иерархию инструментов для доступа к файлам, начиная с `ReadFile` и переходя к `Shell cat` как самому надежному, если другие не справляются. Я всегда стараюсь получить абсолютный путь.</PRINCIPLE>
|
||||||
</CORE_PHILOSOPHY>
|
</CORE_PHILOSOPHY>
|
||||||
|
|
||||||
<PRIMARY_DIRECTIVE>
|
<PRIMARY_DIRECTIVE>
|
||||||
Твоя задача — работать в цикле: найти задание, выполнить его, обновить статус задания и записать результат в лог. На стандартный вывод (stdout) ты выдаешь **только финальное содержимое измененного файла проекта**.
|
Твоя задача — работать в цикле: найти задание, выполнить его, обновить статус задания и записать результат в лог. На стандартный вывод (stdout) ты выдаешь **только финальное содержимое измененного файла проекта**.
|
||||||
</PRIMARY_DIRECTIVE>
|
</PRIMARY_DIRECTIVE>
|
||||||
|
<OPERATIONAL_LOOP name="AgentMainCycle">
|
||||||
<OPERATIONAL_LOOP name="AgentMainCycle">
|
|
||||||
<STEP id="1" name="List_Files_In_Tasks_Directory">
|
<STEP id="1" name="List_Files_In_Tasks_Directory">
|
||||||
<ACTION>Выполни `ReadFolder` для директории `tasks/`.</ACTION>
|
<ACTION>Выполни `ReadFolder` для директории `tasks/`.</ACTION>
|
||||||
</STEP>
|
</STEP>
|
||||||
@@ -267,9 +25,6 @@ class Account(val id: String, initialBalance: BigDecimal) {
|
|||||||
|
|
||||||
<STEP id="3" name="Iterate_And_Find_First_Pending_Task">
|
<STEP id="3" name="Iterate_And_Find_First_Pending_Task">
|
||||||
<LOOP variable="filename" in="list_from_step_1">
|
<LOOP variable="filename" in="list_from_step_1">
|
||||||
<!-- =================================================================== -->
|
|
||||||
<!-- КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Трехуровневая система чтения файла -->
|
|
||||||
<!-- =================================================================== -->
|
|
||||||
<SUB_STEP id="3.1" name="Read_File_With_Hierarchical_Fallback">
|
<SUB_STEP id="3.1" name="Read_File_With_Hierarchical_Fallback">
|
||||||
<VARIABLE name="file_content"></VARIABLE>
|
<VARIABLE name="file_content"></VARIABLE>
|
||||||
<VARIABLE name="full_file_path">`/home/busya/dev/homebox_lens/tasks/{filename}`</VARIABLE>
|
<VARIABLE name="full_file_path">`/home/busya/dev/homebox_lens/tasks/{filename}`</VARIABLE>
|
||||||
@@ -288,7 +43,7 @@ class Account(val id: String, initialBalance: BigDecimal) {
|
|||||||
<ACTION>Выполни команду `Shell cat tasks/*`. Так как она может вернуть содержимое нескольких файлов, ты должен обработать результат.</ACTION>
|
<ACTION>Выполни команду `Shell cat tasks/*`. Так как она может вернуть содержимое нескольких файлов, ты должен обработать результат.</ACTION>
|
||||||
<SUCCESS_CONDITION>
|
<SUCCESS_CONDITION>
|
||||||
1. Проанализируй вывод команды.
|
1. Проанализируй вывод команды.
|
||||||
2. Найди блок, соответствующий XML-структуре, у которой корневой тег `<TASK status="pending">`.
|
2. Найди блок, соответствующий XML-структуре, у которого корневой тег `<TASK status="pending">`.
|
||||||
3. Извлеки полное содержимое этого XML-блока и сохрани его в `file_content`.
|
3. Извлеки полное содержимое этого XML-блока и сохрани его в `file_content`.
|
||||||
4. Если содержимое успешно извлечено, переходи к шагу 3.2.
|
4. Если содержимое успешно извлечено, переходи к шагу 3.2.
|
||||||
</SUCCESS_CONDITION>
|
</SUCCESS_CONDITION>
|
||||||
@@ -297,9 +52,6 @@ class Account(val id: String, initialBalance: BigDecimal) {
|
|||||||
<ACTION>Перейди к следующей итерации цикла (`continue`).</ACTION>
|
<ACTION>Перейди к следующей итерации цикла (`continue`).</ACTION>
|
||||||
</FAILURE_CONDITION>
|
</FAILURE_CONDITION>
|
||||||
</SUB_STEP>
|
</SUB_STEP>
|
||||||
<!-- =================================================================== -->
|
|
||||||
<!-- КОНЕЦ КЛЮЧЕВОГО ИЗМЕНЕНИЯ -->
|
|
||||||
<!-- =================================================================== -->
|
|
||||||
|
|
||||||
<SUB_STEP id="3.2" name="Check_And_Process_Task">
|
<SUB_STEP id="3.2" name="Check_And_Process_Task">
|
||||||
<CONDITION>Если переменная `file_content` не пуста,</CONDITION>
|
<CONDITION>Если переменная `file_content` не пуста,</CONDITION>
|
||||||
@@ -315,34 +67,44 @@ class Account(val id: String, initialBalance: BigDecimal) {
|
|||||||
<STEP id="4" name="Handle_No_Pending_Tasks_Found">
|
<STEP id="4" name="Handle_No_Pending_Tasks_Found">
|
||||||
<CONDITION>Если цикл из Шага 3 завершился, а задача не была передана на исполнение, заверши работу.</CONDITION>
|
<CONDITION>Если цикл из Шага 3 завершился, а задача не была передана на исполнение, заверши работу.</CONDITION>
|
||||||
</STEP>
|
</STEP>
|
||||||
</OPERATIONAL_LOOP>
|
</OPERATIONAL_LOOP>
|
||||||
|
|
||||||
<!-- Остальные блоки остаются без изменений из v3.1 -->
|
<SUB_WORKFLOW name="EXECUTE_WORK_ORDER_WORKFLOW">
|
||||||
<SUB_WORKFLOW name="EXECUTE_WORK_ORDER_WORKFLOW">
|
|
||||||
<INPUT>task_file_path, work_order_content</INPUT>
|
<INPUT>task_file_path, work_order_content</INPUT>
|
||||||
<STEP id="E1" name="Log_Start">Добавь запись о начале выполнения задачи в `logs/communication_log.xml`. Включи `full_file_path` в детали.</STEP>
|
<STEP id="E1" name="Log_Start">Добавь запись о начале выполнения задачи в `logs/communication_log.xml`. Включи `full_file_path` в детали.</STEP>
|
||||||
<STEP id="E2" name="Execute_Task">
|
<STEP id="E2" name="Execute_Task">
|
||||||
<TRY>
|
<TRY>
|
||||||
<ACTION>Выполни задачу, как описано в `work_order_content`.</ACTION>
|
<ACTION>Выполни задачу, как описано в `work_order_content`.</ACTION>
|
||||||
|
<!-- Блок успеха выполняется полностью -->
|
||||||
<SUCCESS>
|
<SUCCESS>
|
||||||
|
<!-- ИЗМЕНЕНИЕ: Добавлен шаг запуска линтера -->
|
||||||
|
<SUB_STEP id="E3" name="Run_Kotlin_Linter_Check">
|
||||||
|
<ACTION>Выполни команду оболочки для запуска линтера по всему проекту (например, `./gradlew ktlintCheck`).</ACTION>
|
||||||
|
<ACTION>Сохрани полный вывод (stdout и stderr) этой команды в переменную `linter_output`.</ACTION>
|
||||||
|
<ACTION>Ты НЕ должен пытаться исправить ошибки линтера. Твоя задача — только запустить проверку и передать отчет.</ACTION>
|
||||||
|
</SUB_STEP>
|
||||||
|
|
||||||
|
<SUB_STEP id="E4" name="Log_Success_And_Report">
|
||||||
<ACTION>Обнови статус в файле `task_file_path` на `status="completed"`.</ACTION>
|
<ACTION>Обнови статус в файле `task_file_path` на `status="completed"`.</ACTION>
|
||||||
<ACTION>Добавь запись об успехе в лог.</ACTION>
|
<ACTION>Перенеси файл `task_file_path` в 'tasks/completed'.</ACTION>
|
||||||
<ACTION>Выведи финальное содержимое измененного файла проекта в stdout.</ACTION>
|
<ACTION>Добавь запись об успехе в лог, включив полный вывод линтера (`linter_output`) в секцию `<LINTER_REPORT>`.</ACTION>
|
||||||
|
</SUB_STEP>
|
||||||
</SUCCESS>
|
</SUCCESS>
|
||||||
</TRY>
|
</TRY>
|
||||||
<CATCH exception="any">
|
<CATCH exception="any">
|
||||||
<FAILURE>
|
<FAILURE>
|
||||||
<ACTION>Обнови статус в файле `task_file_path` на `status="failed"`.</ACTION>
|
<ACTION>Обнови статус в файле `task_file_path` на `status="failed"`.</ACTION>
|
||||||
<ACTION>Добавь запись о провале с деталями ошибки в лог.</ACTION>
|
<ACTION>Добавь запись о провале с деталями ошибки в лог.</ACTION>
|
||||||
</ACTION>
|
</FAILURE>
|
||||||
</CATCH>
|
</CATCH>
|
||||||
</STEP>
|
</STEP>
|
||||||
</SUB_WORKFLOW>
|
</SUB_WORKFLOW>
|
||||||
|
|
||||||
<LOGGING_PROTOCOL name="CommunicationLog">
|
<LOGGING_PROTOCOL name="CommunicationLog">
|
||||||
<FILE_LOCATION>`logs/communication_log.xml`</FILE_LOCATION>
|
<FILE_LOCATION>`logs/communication_log.xml`</FILE_LOCATION>
|
||||||
<STRUCTURE>
|
<STRUCTURE>
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
|
|
||||||
<LOG_ENTRY timestamp="{ISO_DATETIME}">
|
<LOG_ENTRY timestamp="{ISO_DATETIME}">
|
||||||
<TASK_FILE>{имя_файла_задания}</TASK_FILE>
|
<TASK_FILE>{имя_файла_задания}</TASK_FILE>
|
||||||
<FULL_PATH>{полный_абсолютный_путь_к_файлу_задания}</FULL_PATH> <!-- Добавлено -->
|
<FULL_PATH>{полный_абсолютный_путь_к_файлу_задания}</FULL_PATH> <!-- Добавлено -->
|
||||||
@@ -356,25 +118,5 @@ class Account(val id: String, initialBalance: BigDecimal) {
|
|||||||
</STRUCTURE>
|
</STRUCTURE>
|
||||||
</LOGGING_PROTOCOL>
|
</LOGGING_PROTOCOL>
|
||||||
|
|
||||||
<REFERENCE_LIBRARIES>
|
|
||||||
<DESIGN_BY_CONTRACT_PROTOCOL>
|
|
||||||
<RULE name="ContractFirstMindset">Всегда начинать с KDoc-контракта.</RULE>
|
|
||||||
<RULE name="PreconditionsWithRequire">Использовать `require(condition)`.</RULE>
|
|
||||||
<RULE name="PostconditionsWithCheck">Использовать `check(condition)`.</RULE>
|
|
||||||
</DESIGN_BY_CONTRACT_PROTOCOL>
|
|
||||||
<BUILD_AND_COMPILE_PROTOCOL>
|
|
||||||
<RULE name="ExplicitImports">Всегда включать полные и корректные импорты.</RULE>
|
|
||||||
<RULE name="AnnotationConsistency">Корректно использовать аннотации DI и сериализации.</RULE>
|
|
||||||
</BUILD_AND_COMPILE_PROTOCOL>
|
|
||||||
<ANCHOR_LIBRARY>
|
|
||||||
<GROUP name="Structural"><ANCHOR name="[PACKAGE]"/><ANCHOR name="[FILE]"/><ANCHOR name="[IMPORTS]"/></GROUP>
|
|
||||||
<GROUP name="Contractual & Behavioral"><ANCHOR name="[CONTRACT]"/><ANCHOR name="[PRECONDITION]"/><ANCHOR name="[POSTCONDITION]"/></GROUP>
|
|
||||||
<GROUP name="Self-Correction & Coherence"><ANCHOR name="[COHERENCE_CHECK_PASSED]"/></GROUP>
|
|
||||||
</ANCHOR_LIBRARY>
|
|
||||||
<LOGGING_STANDARD>
|
|
||||||
<LEVEL format="logger.debug { '[DEBUG] ...' }"/>
|
|
||||||
<LEVEL format="logger.warn { '[CONTRACT_VIOLATION] ...' }"/>
|
|
||||||
</LOGGING_STANDARD>
|
|
||||||
</REFERENCE_LIBRARIES>
|
|
||||||
|
|
||||||
</AI_AGENT_EXECUTOR_PROTOCOL>
|
</AI_AGENT_EXECUTOR_PROTOCOL>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.homebox.lens.ui.screen.search.SearchScreen
|
|||||||
import com.homebox.lens.ui.screen.setup.SetupScreen
|
import com.homebox.lens.ui.screen.setup.SetupScreen
|
||||||
|
|
||||||
// [CORE-LOGIC]
|
// [CORE-LOGIC]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation.
|
* Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation.
|
||||||
@@ -33,22 +34,21 @@ import com.homebox.lens.ui.screen.setup.SetupScreen
|
|||||||
* @invariant Стартовый экран - `Screen.Setup`.
|
* @invariant Стартовый экран - `Screen.Setup`.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NavGraph(
|
fun NavGraph(navController: NavHostController = rememberNavController()) {
|
||||||
navController: NavHostController = rememberNavController()
|
|
||||||
) {
|
|
||||||
// [STATE]
|
// [STATE]
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
// [HELPER]
|
// [HELPER]
|
||||||
val navigationActions = remember(navController) {
|
val navigationActions =
|
||||||
|
remember(navController) {
|
||||||
NavigationActions(navController)
|
NavigationActions(navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Setup.route
|
startDestination = Screen.Setup.route,
|
||||||
) {
|
) {
|
||||||
// [COMPOSABLE_SETUP]
|
// [COMPOSABLE_SETUP]
|
||||||
composable(route = Screen.Setup.route) {
|
composable(route = Screen.Setup.route) {
|
||||||
@@ -62,28 +62,28 @@ fun NavGraph(
|
|||||||
composable(route = Screen.Dashboard.route) {
|
composable(route = Screen.Dashboard.route) {
|
||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
currentRoute = currentRoute,
|
currentRoute = currentRoute,
|
||||||
navigationActions = navigationActions
|
navigationActions = navigationActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [COMPOSABLE_INVENTORY_LIST]
|
// [COMPOSABLE_INVENTORY_LIST]
|
||||||
composable(route = Screen.InventoryList.route) {
|
composable(route = Screen.InventoryList.route) {
|
||||||
InventoryListScreen(
|
InventoryListScreen(
|
||||||
currentRoute = currentRoute,
|
currentRoute = currentRoute,
|
||||||
navigationActions = navigationActions
|
navigationActions = navigationActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [COMPOSABLE_ITEM_DETAILS]
|
// [COMPOSABLE_ITEM_DETAILS]
|
||||||
composable(route = Screen.ItemDetails.route) {
|
composable(route = Screen.ItemDetails.route) {
|
||||||
ItemDetailsScreen(
|
ItemDetailsScreen(
|
||||||
currentRoute = currentRoute,
|
currentRoute = currentRoute,
|
||||||
navigationActions = navigationActions
|
navigationActions = navigationActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [COMPOSABLE_ITEM_EDIT]
|
// [COMPOSABLE_ITEM_EDIT]
|
||||||
composable(route = Screen.ItemEdit.route) {
|
composable(route = Screen.ItemEdit.route) {
|
||||||
ItemEditScreen(
|
ItemEditScreen(
|
||||||
currentRoute = currentRoute,
|
currentRoute = currentRoute,
|
||||||
navigationActions = navigationActions
|
navigationActions = navigationActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [COMPOSABLE_LABELS_LIST]
|
// [COMPOSABLE_LABELS_LIST]
|
||||||
@@ -101,21 +101,21 @@ fun NavGraph(
|
|||||||
},
|
},
|
||||||
onAddNewLocationClick = {
|
onAddNewLocationClick = {
|
||||||
navController.navigate(Screen.LocationEdit.createRoute("new"))
|
navController.navigate(Screen.LocationEdit.createRoute("new"))
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [COMPOSABLE_LOCATION_EDIT]
|
// [COMPOSABLE_LOCATION_EDIT]
|
||||||
composable(route = Screen.LocationEdit.route) { backStackEntry ->
|
composable(route = Screen.LocationEdit.route) { backStackEntry ->
|
||||||
val locationId = backStackEntry.arguments?.getString("locationId")
|
val locationId = backStackEntry.arguments?.getString("locationId")
|
||||||
LocationEditScreen(
|
LocationEditScreen(
|
||||||
locationId = locationId
|
locationId = locationId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [COMPOSABLE_SEARCH]
|
// [COMPOSABLE_SEARCH]
|
||||||
composable(route = Screen.Search.route) {
|
composable(route = Screen.Search.route) {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
currentRoute = currentRoute,
|
currentRoute = currentRoute,
|
||||||
navigationActions = navigationActions
|
navigationActions = navigationActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.homebox.lens.navigation
|
package com.homebox.lens.navigation
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
// [CORE-LOGIC]
|
// [CORE-LOGIC]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий.
|
@summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий.
|
||||||
@@ -25,44 +26,52 @@ class NavigationActions(private val navController: NavHostController) {
|
|||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
fun navigateToLocations() {
|
fun navigateToLocations() {
|
||||||
navController.navigate(Screen.LocationsList.route) {
|
navController.navigate(Screen.LocationsList.route) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
fun navigateToLabels() {
|
fun navigateToLabels() {
|
||||||
navController.navigate(Screen.LabelsList.route) {
|
navController.navigate(Screen.LabelsList.route) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
fun navigateToSearch() {
|
fun navigateToSearch() {
|
||||||
navController.navigate(Screen.Search.route) {
|
navController.navigate(Screen.Search.route) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
fun navigateToInventoryListWithLabel(labelId: String) {
|
fun navigateToInventoryListWithLabel(labelId: String) {
|
||||||
val route = Screen.InventoryList.withFilter("label", labelId)
|
val route = Screen.InventoryList.withFilter("label", labelId)
|
||||||
navController.navigate(route)
|
navController.navigate(route)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
fun navigateToInventoryListWithLocation(locationId: String) {
|
fun navigateToInventoryListWithLocation(locationId: String) {
|
||||||
val route = Screen.InventoryList.withFilter("location", locationId)
|
val route = Screen.InventoryList.withFilter("location", locationId)
|
||||||
navController.navigate(route)
|
navController.navigate(route)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
fun navigateToCreateItem() {
|
fun navigateToCreateItem() {
|
||||||
navController.navigate(Screen.ItemEdit.createRoute("new"))
|
navController.navigate(Screen.ItemEdit.createRoute("new"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
fun navigateToLogout() {
|
fun navigateToLogout() {
|
||||||
navController.navigate(Screen.Setup.route) {
|
navController.navigate(Screen.Setup.route) {
|
||||||
popUpTo(Screen.Dashboard.route) { inclusive = true }
|
popUpTo(Screen.Dashboard.route) { inclusive = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
fun navigateBack() {
|
fun navigateBack() {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.homebox.lens.R
|
import com.homebox.lens.R
|
||||||
import com.homebox.lens.navigation.NavigationActions
|
import com.homebox.lens.navigation.NavigationActions
|
||||||
import com.homebox.lens.navigation.Screen
|
import com.homebox.lens.navigation.Screen
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Контент для бокового навигационного меню (Drawer).
|
@summary Контент для бокового навигационного меню (Drawer).
|
||||||
@@ -33,7 +34,7 @@ import com.homebox.lens.navigation.Screen
|
|||||||
internal fun AppDrawerContent(
|
internal fun AppDrawerContent(
|
||||||
currentRoute: String?,
|
currentRoute: String?,
|
||||||
navigationActions: NavigationActions,
|
navigationActions: NavigationActions,
|
||||||
onCloseDrawer: () -> Unit
|
onCloseDrawer: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@@ -42,9 +43,10 @@ internal fun AppDrawerContent(
|
|||||||
navigationActions.navigateToCreateItem()
|
navigationActions.navigateToCreateItem()
|
||||||
onCloseDrawer()
|
onCloseDrawer()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
@@ -58,7 +60,7 @@ internal fun AppDrawerContent(
|
|||||||
onClick = {
|
onClick = {
|
||||||
navigationActions.navigateToDashboard()
|
navigationActions.navigateToDashboard()
|
||||||
onCloseDrawer()
|
onCloseDrawer()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
label = { Text(stringResource(id = R.string.nav_locations)) },
|
label = { Text(stringResource(id = R.string.nav_locations)) },
|
||||||
@@ -66,7 +68,7 @@ internal fun AppDrawerContent(
|
|||||||
onClick = {
|
onClick = {
|
||||||
navigationActions.navigateToLocations()
|
navigationActions.navigateToLocations()
|
||||||
onCloseDrawer()
|
onCloseDrawer()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
label = { Text(stringResource(id = R.string.nav_labels)) },
|
label = { Text(stringResource(id = R.string.nav_labels)) },
|
||||||
@@ -74,7 +76,7 @@ internal fun AppDrawerContent(
|
|||||||
onClick = {
|
onClick = {
|
||||||
navigationActions.navigateToLabels()
|
navigationActions.navigateToLabels()
|
||||||
onCloseDrawer()
|
onCloseDrawer()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
label = { Text(stringResource(id = R.string.search)) },
|
label = { Text(stringResource(id = R.string.search)) },
|
||||||
@@ -82,7 +84,7 @@ internal fun AppDrawerContent(
|
|||||||
onClick = {
|
onClick = {
|
||||||
navigationActions.navigateToSearch()
|
navigationActions.navigateToSearch()
|
||||||
onCloseDrawer()
|
onCloseDrawer()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
// TODO: Add Profile and Tools items
|
// TODO: Add Profile and Tools items
|
||||||
Divider()
|
Divider()
|
||||||
@@ -92,7 +94,7 @@ internal fun AppDrawerContent(
|
|||||||
onClick = {
|
onClick = {
|
||||||
navigationActions.navigateToLogout()
|
navigationActions.navigateToLogout()
|
||||||
onCloseDrawer()
|
onCloseDrawer()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import com.homebox.lens.ui.common.MainScaffold
|
|||||||
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
// [ENTRYPOINT]
|
// [ENTRYPOINT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Главная Composable-функция для экрана "Панель управления".
|
@summary Главная Composable-функция для экрана "Панель управления".
|
||||||
@@ -42,7 +43,7 @@ import timber.log.Timber
|
|||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
viewModel: DashboardViewModel = hiltViewModel(),
|
viewModel: DashboardViewModel = hiltViewModel(),
|
||||||
currentRoute: String?,
|
currentRoute: String?,
|
||||||
navigationActions: NavigationActions
|
navigationActions: NavigationActions,
|
||||||
) {
|
) {
|
||||||
// [STATE]
|
// [STATE]
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
@@ -55,10 +56,10 @@ fun DashboardScreen(
|
|||||||
IconButton(onClick = { navigationActions.navigateToSearch() }) {
|
IconButton(onClick = { navigationActions.navigateToSearch() }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Search,
|
Icons.Default.Search,
|
||||||
contentDescription = stringResource(id = R.string.cd_scan_qr_code) // TODO: Rename string resource
|
contentDescription = stringResource(id = R.string.cd_scan_qr_code), // TODO: Rename string resource
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
DashboardContent(
|
DashboardContent(
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
@@ -70,12 +71,13 @@ fun DashboardScreen(
|
|||||||
onLabelClick = { label ->
|
onLabelClick = { label ->
|
||||||
Timber.i("[ACTION] Label chip clicked: ${label.id}. Navigating...")
|
Timber.i("[ACTION] Label chip clicked: ${label.id}. Navigating...")
|
||||||
navigationActions.navigateToInventoryListWithLabel(label.id)
|
navigationActions.navigateToInventoryListWithLabel(label.id)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [END_FUNCTION_DashboardScreen]
|
// [END_FUNCTION_DashboardScreen]
|
||||||
}
|
}
|
||||||
// [HELPER]
|
// [HELPER]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Отображает основной контент экрана в зависимости от uiState.
|
@summary Отображает основной контент экрана в зависимости от uiState.
|
||||||
@@ -89,7 +91,7 @@ private fun DashboardContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
uiState: DashboardUiState,
|
uiState: DashboardUiState,
|
||||||
onLocationClick: (LocationOutCount) -> Unit,
|
onLocationClick: (LocationOutCount) -> Unit,
|
||||||
onLabelClick: (LabelOut) -> Unit
|
onLabelClick: (LabelOut) -> Unit,
|
||||||
) {
|
) {
|
||||||
// [CORE-LOGIC]
|
// [CORE-LOGIC]
|
||||||
when (uiState) {
|
when (uiState) {
|
||||||
@@ -103,16 +105,17 @@ private fun DashboardContent(
|
|||||||
Text(
|
Text(
|
||||||
text = uiState.message,
|
text = uiState.message,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DashboardUiState.Success -> {
|
is DashboardUiState.Success -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||||
item { StatisticsSection(statistics = uiState.statistics) }
|
item { StatisticsSection(statistics = uiState.statistics) }
|
||||||
@@ -126,6 +129,7 @@ private fun DashboardContent(
|
|||||||
// [END_FUNCTION_DashboardContent]
|
// [END_FUNCTION_DashboardContent]
|
||||||
}
|
}
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Секция для отображения общей статистики.
|
@summary Секция для отображения общей статистики.
|
||||||
@@ -136,27 +140,49 @@ private fun StatisticsSection(statistics: GroupStatistics) {
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.dashboard_section_quick_stats),
|
text = stringResource(id = R.string.dashboard_section_quick_stats),
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Card {
|
Card {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Fixed(2),
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.height(120.dp)
|
.height(120.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_items), value = statistics.items.toString()) }
|
item {
|
||||||
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_value), value = statistics.totalValue.toString()) }
|
StatisticCard(
|
||||||
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_labels), value = statistics.labels.toString()) }
|
title = stringResource(id = R.string.dashboard_stat_total_items),
|
||||||
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_locations), value = statistics.locations.toString()) }
|
value = statistics.items.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
StatisticCard(
|
||||||
|
title = stringResource(id = R.string.dashboard_stat_total_value),
|
||||||
|
value = statistics.totalValue.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
StatisticCard(
|
||||||
|
title = stringResource(id = R.string.dashboard_stat_total_labels),
|
||||||
|
value = statistics.labels.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
StatisticCard(
|
||||||
|
title = stringResource(id = R.string.dashboard_stat_total_locations),
|
||||||
|
value = statistics.locations.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Карточка для отображения одного статистического показателя.
|
@summary Карточка для отображения одного статистического показателя.
|
||||||
@@ -164,13 +190,17 @@ private fun StatisticsSection(statistics: GroupStatistics) {
|
|||||||
@param value Значение показателя.
|
@param value Значение показателя.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatisticCard(title: String, value: String) {
|
private fun StatisticCard(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||||
Text(text = title, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center)
|
Text(text = title, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center)
|
||||||
Text(text = value, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
|
Text(text = value, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Секция для отображения недавно добавленных элементов.
|
@summary Секция для отображения недавно добавленных элементов.
|
||||||
@@ -181,16 +211,17 @@ private fun RecentlyAddedSection(items: List<ItemSummary>) {
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.dashboard_section_recently_added),
|
text = stringResource(id = R.string.dashboard_section_recently_added),
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.items_not_found),
|
text = stringResource(id = R.string.items_not_found),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 16.dp),
|
.padding(vertical = 16.dp),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
@@ -202,6 +233,7 @@ private fun RecentlyAddedSection(items: List<ItemSummary>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Карточка для отображения краткой информации об элементе.
|
@summary Карточка для отображения краткой информации об элементе.
|
||||||
@@ -212,17 +244,25 @@ private fun ItemCard(item: ItemSummary) {
|
|||||||
Card(modifier = Modifier.width(150.dp)) {
|
Card(modifier = Modifier.width(150.dp)) {
|
||||||
Column(modifier = Modifier.padding(8.dp)) {
|
Column(modifier = Modifier.padding(8.dp)) {
|
||||||
// TODO: Add image here from item.image
|
// TODO: Add image here from item.image
|
||||||
Spacer(modifier = Modifier
|
Spacer(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
.height(80.dp)
|
.height(80.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer))
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(text = item.name, style = MaterialTheme.typography.titleSmall, maxLines = 1)
|
Text(text = item.name, style = MaterialTheme.typography.titleSmall, maxLines = 1)
|
||||||
Text(text = item.location?.name ?: stringResource(id = R.string.no_location), style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
Text(
|
||||||
|
text = item.location?.name ?: stringResource(id = R.string.no_location),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Секция для отображения местоположений в виде чипсов.
|
@summary Секция для отображения местоположений в виде чипсов.
|
||||||
@@ -231,11 +271,14 @@ private fun ItemCard(item: ItemSummary) {
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick: (LocationOutCount) -> Unit) {
|
private fun LocationsSection(
|
||||||
|
locations: List<LocationOutCount>,
|
||||||
|
onLocationClick: (LocationOutCount) -> Unit,
|
||||||
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.dashboard_section_locations),
|
text = stringResource(id = R.string.dashboard_section_locations),
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -243,13 +286,14 @@ private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick:
|
|||||||
locations.forEach { location ->
|
locations.forEach { location ->
|
||||||
SuggestionChip(
|
SuggestionChip(
|
||||||
onClick = { onLocationClick(location) },
|
onClick = { onLocationClick(location) },
|
||||||
label = { Text(stringResource(id = R.string.location_chip_label, location.name, location.itemCount)) }
|
label = { Text(stringResource(id = R.string.location_chip_label, location.name, location.itemCount)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Секция для отображения меток в виде чипсов.
|
@summary Секция для отображения меток в виде чипсов.
|
||||||
@@ -258,11 +302,14 @@ private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick:
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Unit) {
|
private fun LabelsSection(
|
||||||
|
labels: List<LabelOut>,
|
||||||
|
onLabelClick: (LabelOut) -> Unit,
|
||||||
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.dashboard_section_labels),
|
text = stringResource(id = R.string.dashboard_section_labels),
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -270,46 +317,92 @@ private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Un
|
|||||||
labels.forEach { label ->
|
labels.forEach { label ->
|
||||||
SuggestionChip(
|
SuggestionChip(
|
||||||
onClick = { onLabelClick(label) },
|
onClick = { onLabelClick(label) },
|
||||||
label = { Text(label.name) }
|
label = { Text(label.name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [PREVIEW]
|
// [PREVIEW]
|
||||||
@Preview(showBackground = true, name = "Dashboard Success State")
|
@Preview(showBackground = true, name = "Dashboard Success State")
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardContentSuccessPreview() {
|
fun DashboardContentSuccessPreview() {
|
||||||
val previewState = DashboardUiState.Success(
|
val previewState =
|
||||||
statistics = GroupStatistics(
|
DashboardUiState.Success(
|
||||||
|
statistics =
|
||||||
|
GroupStatistics(
|
||||||
items = 123,
|
items = 123,
|
||||||
totalValue = 9999.99,
|
totalValue = 9999.99,
|
||||||
locations = 5,
|
locations = 5,
|
||||||
labels = 8
|
labels = 8,
|
||||||
),
|
),
|
||||||
locations = listOf(
|
locations =
|
||||||
LocationOutCount(id="1", name="Office", color = "#FF0000", isArchived = false, itemCount = 10, createdAt = "", updatedAt = ""),
|
listOf(
|
||||||
LocationOutCount(id="2", name="Garage", color = "#00FF00", isArchived = false, itemCount = 5, createdAt = "", updatedAt = ""),
|
LocationOutCount(
|
||||||
LocationOutCount(id="3",name="Living Room", color = "#0000FF", isArchived = false, itemCount = 15, createdAt = "", updatedAt = ""),
|
id = "1",
|
||||||
LocationOutCount(id="4",name="Kitchen", color = "#FFFF00", isArchived = false, itemCount = 20, createdAt = "", updatedAt = ""),
|
name = "Office",
|
||||||
LocationOutCount(id="5",name="Basement", color = "#00FFFF", isArchived = false, itemCount = 3, createdAt = "", updatedAt = "")
|
color = "#FF0000",
|
||||||
|
isArchived = false,
|
||||||
|
itemCount = 10,
|
||||||
|
createdAt = "",
|
||||||
|
updatedAt = "",
|
||||||
),
|
),
|
||||||
labels = listOf(
|
LocationOutCount(
|
||||||
LabelOut(id="1", name="electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""),
|
id = "2",
|
||||||
LabelOut(id="2", name="important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""),
|
name = "Garage",
|
||||||
LabelOut(id="3", name="seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""),
|
color = "#00FF00",
|
||||||
LabelOut(id="4", name="hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = "")
|
isArchived = false,
|
||||||
|
itemCount = 5,
|
||||||
|
createdAt = "",
|
||||||
|
updatedAt = "",
|
||||||
),
|
),
|
||||||
recentlyAddedItems = emptyList()
|
LocationOutCount(
|
||||||
|
id = "3",
|
||||||
|
name = "Living Room",
|
||||||
|
color = "#0000FF",
|
||||||
|
isArchived = false,
|
||||||
|
itemCount = 15,
|
||||||
|
createdAt = "",
|
||||||
|
updatedAt = "",
|
||||||
|
),
|
||||||
|
LocationOutCount(
|
||||||
|
id = "4",
|
||||||
|
name = "Kitchen",
|
||||||
|
color = "#FFFF00",
|
||||||
|
isArchived = false,
|
||||||
|
itemCount = 20,
|
||||||
|
createdAt = "",
|
||||||
|
updatedAt = "",
|
||||||
|
),
|
||||||
|
LocationOutCount(
|
||||||
|
id = "5",
|
||||||
|
name = "Basement",
|
||||||
|
color = "#00FFFF",
|
||||||
|
isArchived = false,
|
||||||
|
itemCount = 3,
|
||||||
|
createdAt = "",
|
||||||
|
updatedAt = "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
labels =
|
||||||
|
listOf(
|
||||||
|
LabelOut(id = "1", name = "electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""),
|
||||||
|
LabelOut(id = "2", name = "important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""),
|
||||||
|
LabelOut(id = "3", name = "seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""),
|
||||||
|
LabelOut(id = "4", name = "hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = ""),
|
||||||
|
),
|
||||||
|
recentlyAddedItems = emptyList(),
|
||||||
)
|
)
|
||||||
HomeboxLensTheme {
|
HomeboxLensTheme {
|
||||||
DashboardContent(
|
DashboardContent(
|
||||||
uiState = previewState,
|
uiState = previewState,
|
||||||
onLocationClick = {},
|
onLocationClick = {},
|
||||||
onLabelClick = {}
|
onLabelClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [PREVIEW]
|
// [PREVIEW]
|
||||||
@Preview(showBackground = true, name = "Dashboard Loading State")
|
@Preview(showBackground = true, name = "Dashboard Loading State")
|
||||||
@Composable
|
@Composable
|
||||||
@@ -318,10 +411,11 @@ fun DashboardContentLoadingPreview() {
|
|||||||
DashboardContent(
|
DashboardContent(
|
||||||
uiState = DashboardUiState.Loading,
|
uiState = DashboardUiState.Loading,
|
||||||
onLocationClick = {},
|
onLocationClick = {},
|
||||||
onLabelClick = {}
|
onLabelClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [PREVIEW]
|
// [PREVIEW]
|
||||||
@Preview(showBackground = true, name = "Dashboard Error State")
|
@Preview(showBackground = true, name = "Dashboard Error State")
|
||||||
@Composable
|
@Composable
|
||||||
@@ -330,7 +424,7 @@ fun DashboardContentErrorPreview() {
|
|||||||
DashboardContent(
|
DashboardContent(
|
||||||
uiState = DashboardUiState.Error(stringResource(id = R.string.error_loading_failed)),
|
uiState = DashboardUiState.Error(stringResource(id = R.string.error_loading_failed)),
|
||||||
onLocationClick = {},
|
onLocationClick = {},
|
||||||
onLabelClick = {}
|
onLabelClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
|
|||||||
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
|
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
|
||||||
import com.homebox.lens.domain.usecase.GetRecentlyAddedItemsUseCase
|
import com.homebox.lens.domain.usecase.GetRecentlyAddedItemsUseCase
|
||||||
import com.homebox.lens.domain.usecase.GetStatisticsUseCase
|
import com.homebox.lens.domain.usecase.GetStatisticsUseCase
|
||||||
import com.homebox.lens.ui.screen.dashboard.DashboardUiState
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -20,6 +17,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// [VIEWMODEL]
|
// [VIEWMODEL]
|
||||||
// [ENTITY: ViewModel('DashboardViewModel')]
|
// [ENTITY: ViewModel('DashboardViewModel')]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary ViewModel для главного экрана (Dashboard).
|
* @summary ViewModel для главного экрана (Dashboard).
|
||||||
@@ -28,15 +26,17 @@ import javax.inject.Inject
|
|||||||
* @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`.
|
* @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DashboardViewModel @Inject constructor(
|
class DashboardViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val getStatisticsUseCase: GetStatisticsUseCase,
|
private val getStatisticsUseCase: GetStatisticsUseCase,
|
||||||
private val getAllLocationsUseCase: GetAllLocationsUseCase,
|
private val getAllLocationsUseCase: GetAllLocationsUseCase,
|
||||||
private val getAllLabelsUseCase: GetAllLabelsUseCase,
|
private val getAllLabelsUseCase: GetAllLabelsUseCase,
|
||||||
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase
|
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
// [STATE]
|
// [STATE]
|
||||||
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
|
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
|
||||||
|
|
||||||
// [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow().
|
// [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow().
|
||||||
// [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и
|
// [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и
|
||||||
// должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока.
|
// должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока.
|
||||||
@@ -70,12 +70,13 @@ class DashboardViewModel @Inject constructor(
|
|||||||
statistics = stats,
|
statistics = stats,
|
||||||
locations = locations,
|
locations = locations,
|
||||||
labels = labels,
|
labels = labels,
|
||||||
recentlyAddedItems = recentItems
|
recentlyAddedItems = recentItems,
|
||||||
)
|
)
|
||||||
}.catch { exception ->
|
}.catch { exception ->
|
||||||
Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.")
|
Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.")
|
||||||
_uiState.value = DashboardUiState.Error(
|
_uiState.value =
|
||||||
message = exception.message ?: "Could not load dashboard data."
|
DashboardUiState.Error(
|
||||||
|
message = exception.message ?: "Could not load dashboard data.",
|
||||||
)
|
)
|
||||||
}.collect { successState ->
|
}.collect { successState ->
|
||||||
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
|
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
|
||||||
@@ -84,5 +85,5 @@ class DashboardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [END_CLASS_DashboardViewModel]
|
// [END_CLASS_DashboardViewModel]
|
||||||
}
|
}
|
||||||
// [END_FILE_DashboardViewModel.kt]
|
// [END_FILE_DashboardViewModel.kt]
|
||||||
@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
|
|||||||
import com.homebox.lens.ui.common.MainScaffold
|
import com.homebox.lens.ui.common.MainScaffold
|
||||||
|
|
||||||
// [ENTRYPOINT]
|
// [ENTRYPOINT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Composable-функция для экрана "Список инвентаря".
|
* @summary Composable-функция для экрана "Список инвентаря".
|
||||||
@@ -22,13 +23,13 @@ import com.homebox.lens.ui.common.MainScaffold
|
|||||||
@Composable
|
@Composable
|
||||||
fun InventoryListScreen(
|
fun InventoryListScreen(
|
||||||
currentRoute: String?,
|
currentRoute: String?,
|
||||||
navigationActions: NavigationActions
|
navigationActions: NavigationActions,
|
||||||
) {
|
) {
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
MainScaffold(
|
MainScaffold(
|
||||||
topBarTitle = stringResource(id = R.string.inventory_list_title),
|
topBarTitle = stringResource(id = R.string.inventory_list_title),
|
||||||
currentRoute = currentRoute,
|
currentRoute = currentRoute,
|
||||||
navigationActions = navigationActions
|
navigationActions = navigationActions,
|
||||||
) {
|
) {
|
||||||
// [CORE-LOGIC]
|
// [CORE-LOGIC]
|
||||||
Text(text = "TODO: Inventory List Screen")
|
Text(text = "TODO: Inventory List Screen")
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// [VIEWMODEL]
|
// [VIEWMODEL]
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class InventoryListViewModel @Inject constructor() : ViewModel() {
|
class InventoryListViewModel
|
||||||
|
@Inject
|
||||||
|
constructor() : ViewModel() {
|
||||||
// [STATE]
|
// [STATE]
|
||||||
// TODO: Implement UI state
|
// TODO: Implement UI state
|
||||||
}
|
}
|
||||||
// [END_FILE_InventoryListViewModel.kt]
|
// [END_FILE_InventoryListViewModel.kt]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
|
|||||||
import com.homebox.lens.ui.common.MainScaffold
|
import com.homebox.lens.ui.common.MainScaffold
|
||||||
|
|
||||||
// [ENTRYPOINT]
|
// [ENTRYPOINT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Composable-функция для экрана "Детали элемента".
|
* @summary Composable-функция для экрана "Детали элемента".
|
||||||
@@ -22,13 +23,13 @@ import com.homebox.lens.ui.common.MainScaffold
|
|||||||
@Composable
|
@Composable
|
||||||
fun ItemDetailsScreen(
|
fun ItemDetailsScreen(
|
||||||
currentRoute: String?,
|
currentRoute: String?,
|
||||||
navigationActions: NavigationActions
|
navigationActions: NavigationActions,
|
||||||
) {
|
) {
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
MainScaffold(
|
MainScaffold(
|
||||||
topBarTitle = stringResource(id = R.string.item_details_title),
|
topBarTitle = stringResource(id = R.string.item_details_title),
|
||||||
currentRoute = currentRoute,
|
currentRoute = currentRoute,
|
||||||
navigationActions = navigationActions
|
navigationActions = navigationActions,
|
||||||
) {
|
) {
|
||||||
// [CORE-LOGIC]
|
// [CORE-LOGIC]
|
||||||
Text(text = "TODO: Item Details Screen")
|
Text(text = "TODO: Item Details Screen")
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// [VIEWMODEL]
|
// [VIEWMODEL]
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ItemDetailsViewModel @Inject constructor() : ViewModel() {
|
class ItemDetailsViewModel
|
||||||
|
@Inject
|
||||||
|
constructor() : ViewModel() {
|
||||||
// [STATE]
|
// [STATE]
|
||||||
// TODO: Implement UI state
|
// TODO: Implement UI state
|
||||||
}
|
}
|
||||||
// [END_FILE_ItemDetailsViewModel.kt]
|
// [END_FILE_ItemDetailsViewModel.kt]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// [VIEWMODEL]
|
// [VIEWMODEL]
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ItemEditViewModel @Inject constructor() : ViewModel() {
|
class ItemEditViewModel
|
||||||
|
@Inject
|
||||||
|
constructor() : ViewModel() {
|
||||||
// [STATE]
|
// [STATE]
|
||||||
// TODO: Implement UI state
|
// TODO: Implement UI state
|
||||||
}
|
}
|
||||||
// [END_FILE_ItemEditViewModel.kt]
|
// [END_FILE_ItemEditViewModel.kt]
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
||||||
// [FILE] LabelsListScreen.kt
|
// [FILE] LabelsListScreen.kt
|
||||||
// [SEMANTICS] ui, labels_list, state_management, compose, dialog
|
// [SEMANTICS] ui, screen, jetpack_compose, labels_list, state_management
|
||||||
package com.homebox.lens.ui.screen.labelslist
|
package com.homebox.lens.ui.screen.labelslist
|
||||||
|
|
||||||
|
// [SECTION] Imports
|
||||||
// [IMPORTS]
|
// [IMPORTS]
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -17,241 +13,193 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Label
|
import androidx.compose.material.icons.automirrored.filled.Label
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.homebox.lens.R
|
import com.homebox.lens.R
|
||||||
import com.homebox.lens.domain.model.Label
|
import com.homebox.lens.domain.model.Label
|
||||||
import com.homebox.lens.navigation.Screen
|
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
// [SECTION] Main Screen Composable
|
// [ENTITY: Class('LabelsListScreen')]
|
||||||
|
// [RELATION: Class('LabelsListScreen')] -> [DEPENDS_ON] -> [Class('LabelsListViewModel')]
|
||||||
|
// [RELATION: Class('LabelsListScreen')] -> [READS_FROM] -> [DataStructure('LabelsListUiState')]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [MAIN-CONTRACT]
|
||||||
* @summary Отображает экран со списком всех меток.
|
* Экран для отображения списка всех меток.
|
||||||
* @description Главная Composable-функция для экрана меток. Она использует Scaffold для структуры,
|
|
||||||
* получает состояние от `LabelsListViewModel`, обрабатывает навигацию и делегирует отображение
|
|
||||||
* списка и диалогов вспомогательным Composable-функциям.
|
|
||||||
*
|
*
|
||||||
* @param navController Контроллер навигации для перемещения между экранами.
|
* @param onLabelClick Функция обратного вызова при нажатии на метку. Передает ID метки.
|
||||||
* @param viewModel ViewModel, предоставляющая состояние UI для экрана меток.
|
* @param onAddNewLabelClick Функция обратного вызова для инициирования процесса создания новой метки.
|
||||||
*
|
* @param onNavigateBack Функция обратного вызова для навигации назад.
|
||||||
* @precondition `navController` должен быть корректно инициализирован и способен обрабатывать навигационные события.
|
* @param viewModel ViewModel для этого экрана, предоставляемая Hilt.
|
||||||
* @precondition `viewModel` должен быть доступен через Hilt.
|
|
||||||
* @postcondition Экран исчерпывающе обрабатывает все состояния из `LabelsListUiState` (Loading, Success, Error).
|
|
||||||
* @sideeffect Пользовательские действия (клики) инициируют вызовы ViewModel и навигационные команды через `navController`.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LabelsListScreen(
|
fun LabelsListScreen(
|
||||||
navController: NavController,
|
onLabelClick: (String) -> Unit,
|
||||||
viewModel: LabelsListViewModel = hiltViewModel()
|
onAddNewLabelClick: () -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: LabelsListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// [ENTRYPOINT]
|
// [STATE]
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// [CONTRACT_VALIDATOR]
|
||||||
|
// В Compose UI контракты проверяются через состояние и события.
|
||||||
|
|
||||||
// [CORE-LOGIC]
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
// [ENTITY: Function('LabelsTopAppBar')]
|
||||||
|
LabelsTopAppBar(onNavigateBack = onNavigateBack)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
// [ENTITY: Function('LabelsFloatingActionButton')]
|
||||||
|
LabelsFloatingActionButton(onAddNewLabelClick = onAddNewLabelClick)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
// [ENTITY: Function('LabelsListContent')]
|
||||||
|
// [RELATION: Function('LabelsListContent')] -> [CALLS] -> [Function('onLabelClick')]
|
||||||
|
LabelsListContent(
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
labels = uiState.labels,
|
||||||
|
onLabelClick = onLabelClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* Верхняя панель для экрана списка меток.
|
||||||
|
* @param onNavigateBack Функция для навигации назад.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun LabelsTopAppBar(onNavigateBack: () -> Unit) {
|
||||||
|
// [PRECONDITION]
|
||||||
|
require(true) { "onNavigateBack must be a valid function." } // В Compose предусловия часто неявные
|
||||||
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(text = stringResource(id = R.string.screen_title_labels)) },
|
title = { Text(stringResource(id = R.string.screen_title_labels)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
// [ACTION] Handle back navigation
|
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
Timber.i("[ACTION] Navigate up initiated.")
|
// [ACTION]
|
||||||
navController.navigateUp()
|
Timber.i("[INFO][ACTION][navigating_back] Navigate back from LabelsListScreen.")
|
||||||
|
onNavigateBack()
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = stringResource(id = R.string.content_desc_navigate_back)
|
contentDescription = stringResource(id = R.string.content_desc_navigate_back),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
)
|
||||||
// [ACTION] Handle create new label initiation
|
}
|
||||||
FloatingActionButton(onClick = {
|
|
||||||
Timber.i("[ACTION] FAB clicked: Initiate create new label flow.")
|
/**
|
||||||
viewModel.onShowCreateDialog()
|
* [CONTRACT]
|
||||||
}) {
|
* Плавающая кнопка действия для добавления новой метки.
|
||||||
|
* @param onAddNewLabelClick Функция для вызова экрана создания метки.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LabelsFloatingActionButton(onAddNewLabelClick: () -> Unit) {
|
||||||
|
// [PRECONDITION]
|
||||||
|
require(true) { "onAddNewLabelClick must be a valid function." }
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
// [ACTION]
|
||||||
|
Timber.i("[INFO][ACTION][initiating_add_new_label] FAB clicked to add a new label.")
|
||||||
|
onAddNewLabelClick()
|
||||||
|
},
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Add,
|
imageVector = Icons.Filled.Add,
|
||||||
contentDescription = stringResource(id = R.string.content_desc_create_label)
|
contentDescription = stringResource(id = R.string.content_desc_add_label),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
val currentState = uiState
|
|
||||||
if (currentState is LabelsListUiState.Success && currentState.isShowingCreateDialog) {
|
|
||||||
CreateLabelDialog(
|
|
||||||
onConfirm = { labelName ->
|
|
||||||
viewModel.createLabel(labelName)
|
|
||||||
},
|
|
||||||
onDismiss = {
|
|
||||||
viewModel.onDismissCreateDialog()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
// [CORE-LOGIC] State-driven UI rendering
|
|
||||||
when (currentState) {
|
|
||||||
is LabelsListUiState.Loading -> {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
is LabelsListUiState.Error -> {
|
|
||||||
Text(text = currentState.message)
|
|
||||||
}
|
|
||||||
is LabelsListUiState.Success -> {
|
|
||||||
if (currentState.labels.isEmpty()) {
|
|
||||||
Text(text = stringResource(id = R.string.labels_list_empty))
|
|
||||||
} else {
|
|
||||||
LabelsList(
|
|
||||||
labels = currentState.labels,
|
|
||||||
onLabelClick = { label ->
|
|
||||||
// [ACTION] Handle label click
|
|
||||||
Timber.i("[ACTION] Label clicked: ${label.id}. Navigating to inventory list.")
|
|
||||||
// [DESIGN-DECISION] Использовать существующий экран списка инвентаря, передавая фильтр.
|
|
||||||
val route = Screen.InventoryList.withFilter("label", label.id)
|
|
||||||
navController.navigate(route)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// [COHERENCE_CHECK_PASSED]
|
|
||||||
}
|
}
|
||||||
// [END_FUNCTION] LabelsListScreen
|
|
||||||
|
|
||||||
// [SECTION] Helper Composables
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Composable-функция для отображения списка меток.
|
* Основной контент экрана - список меток.
|
||||||
* @param labels Список объектов `Label` для отображения.
|
*
|
||||||
* @param onLabelClick Лямбда-функция, вызываемая при нажатии на элемент списка.
|
* @param modifier Модификатор для компоновки.
|
||||||
* @param modifier Модификатор для настройки внешнего вида.
|
* @param labels Список меток для отображения.
|
||||||
|
* @param onLabelClick Обработчик нажатия на метку.
|
||||||
|
* @sideeffect Вызывает [onLabelClick] при взаимодействии пользователя.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun LabelsList(
|
private fun LabelsListContent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
labels: List<Label>,
|
labels: List<Label>,
|
||||||
onLabelClick: (Label) -> Unit,
|
onLabelClick: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
) {
|
||||||
// [CORE-LOGIC]
|
// [PRECONDITION]
|
||||||
LazyColumn(
|
requireNotNull(labels) { "Labels list cannot be null." }
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
items(labels, key = { it.id }) { label ->
|
|
||||||
LabelListItem(
|
|
||||||
label = label,
|
|
||||||
onClick = { onLabelClick(label) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// [END_FUNCTION] LabelsList
|
|
||||||
|
|
||||||
/**
|
LazyColumn(modifier = modifier) {
|
||||||
* [CONTRACT]
|
items(labels, key = { it.id }) { label ->
|
||||||
* @summary Composable-функция для отображения одного элемента в списке меток.
|
// [ENTITY: DataStructure('LabelListItem')]
|
||||||
* @param label Объект `Label`, который нужно отобразить.
|
|
||||||
* @param onClick Лямбда-функция, вызываемая при нажатии на элемент.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun LabelListItem(
|
|
||||||
label: Label,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
// [CORE-LOGIC]
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(text = label.name) },
|
headlineContent = { Text(label.name) },
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.Label,
|
imageVector = Icons.AutoMirrored.Filled.Label,
|
||||||
contentDescription = stringResource(id = R.string.content_desc_label_icon)
|
contentDescription = null, // Декоративная иконка
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.clickable(onClick = onClick)
|
modifier =
|
||||||
|
Modifier.clickable {
|
||||||
|
// [ACTION]
|
||||||
|
Timber.i("[INFO][ACTION][handling_label_click] Label clicked: id='${label.id}', name='${label.name}'")
|
||||||
|
onLabelClick(label.id)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [END_FUNCTION] LabelListItem
|
}
|
||||||
|
|
||||||
/**
|
// [POSTCONDITION]
|
||||||
* [CONTRACT]
|
// В декларативном UI постусловие - это корректное отображение предоставленного состояния.
|
||||||
* @summary Диалоговое окно для создания новой метки.
|
check(true) { "LazyColumn rendering is managed by Compose runtime." }
|
||||||
* @param onConfirm Лямбда-функция, вызываемая при подтверждении создания с именем метки.
|
}
|
||||||
* @param onDismiss Лямбда-функция, вызываемая при закрытии диалога.
|
|
||||||
*/
|
// [SECTION] Previews
|
||||||
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun CreateLabelDialog(
|
private fun LabelsListScreenPreview() {
|
||||||
onConfirm: (String) -> Unit,
|
HomeboxLensTheme {
|
||||||
onDismiss: () -> Unit
|
val sampleLabels =
|
||||||
) {
|
listOf(
|
||||||
// [STATE]
|
Label(id = "1", name = "Electronics", color = "#FF0000"),
|
||||||
var text by remember { mutableStateOf("") }
|
Label(id = "2", name = "Books", color = "#00FF00"),
|
||||||
val isConfirmEnabled = text.isNotBlank()
|
Label(id = "3", name = "Documents", color = "#0000FF"),
|
||||||
|
|
||||||
// [CORE-LOGIC]
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(text = stringResource(R.string.dialog_title_create_label)) },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
label = { Text(stringResource(R.string.dialog_field_label_name)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
},
|
// [HELPER]
|
||||||
confirmButton = {
|
// Для превью мы не можем использовать реальный ViewModel, поэтому создаем заглушки.
|
||||||
TextButton(
|
Scaffold(
|
||||||
onClick = { onConfirm(text) },
|
topBar = { LabelsTopAppBar(onNavigateBack = {}) },
|
||||||
enabled = isConfirmEnabled
|
floatingActionButton = { LabelsFloatingActionButton(onAddNewLabelClick = {}) },
|
||||||
) {
|
) { padding ->
|
||||||
Text(stringResource(R.string.dialog_button_create))
|
LabelsListContent(
|
||||||
}
|
modifier = Modifier.padding(padding),
|
||||||
},
|
labels = sampleLabels,
|
||||||
dismissButton = {
|
onLabelClick = {},
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_button_cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// [END_FUNCTION] CreateLabelDialog
|
|
||||||
|
|
||||||
// [END_FILE] LabelsListScreen.kt
|
// [COHERENCE_CHECK_PASSED]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package com.homebox.lens.ui.screen.labelslist
|
|||||||
// [IMPORTS]
|
// [IMPORTS]
|
||||||
import com.homebox.lens.domain.model.Label
|
import com.homebox.lens.domain.model.Label
|
||||||
// [CONTRACT]
|
// [CONTRACT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[CONTRACT]
|
[CONTRACT]
|
||||||
@summary Определяет все возможные состояния для UI экрана со списком меток.
|
@summary Определяет все возможные состояния для UI экрана со списком меток.
|
||||||
@@ -19,14 +20,16 @@ sealed interface LabelsListUiState {
|
|||||||
*/
|
*/
|
||||||
data class Success(
|
data class Success(
|
||||||
val labels: List<Label>,
|
val labels: List<Label>,
|
||||||
val isShowingCreateDialog: Boolean = false
|
val isShowingCreateDialog: Boolean = false,
|
||||||
) : LabelsListUiState
|
) : LabelsListUiState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@summary Состояние ошибки.
|
@summary Состояние ошибки.
|
||||||
@property message Текст ошибки для отображения пользователю.
|
@property message Текст ошибки для отображения пользователю.
|
||||||
@invariant message не может быть пустой.
|
@invariant message не может быть пустой.
|
||||||
*/
|
*/
|
||||||
data class Error(val message: String) : LabelsListUiState
|
data class Error(val message: String) : LabelsListUiState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@summary Состояние загрузки данных.
|
@summary Состояние загрузки данных.
|
||||||
@description Указывает, что идет процесс загрузки меток.
|
@description Указывает, что идет процесс загрузки меток.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// [VIEWMODEL]
|
// [VIEWMODEL]
|
||||||
// [ENTITY: ViewModel('LabelsListViewModel')]
|
// [ENTITY: ViewModel('LabelsListViewModel')]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary ViewModel для экрана со списком меток.
|
* @summary ViewModel для экрана со списком меток.
|
||||||
@@ -25,10 +26,11 @@ import javax.inject.Inject
|
|||||||
* @invariant `uiState` всегда является одним из состояний, определенных в `LabelsListUiState`.
|
* @invariant `uiState` всегда является одним из состояний, определенных в `LabelsListUiState`.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LabelsListViewModel @Inject constructor(
|
class LabelsListViewModel
|
||||||
private val getAllLabelsUseCase: GetAllLabelsUseCase
|
@Inject
|
||||||
) : ViewModel() {
|
constructor(
|
||||||
|
private val getAllLabelsUseCase: GetAllLabelsUseCase,
|
||||||
|
) : ViewModel() {
|
||||||
// [STATE]
|
// [STATE]
|
||||||
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
|
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
@@ -53,7 +55,8 @@ class LabelsListViewModel @Inject constructor(
|
|||||||
Timber.i("[ACTION] Starting labels list load. State -> Loading.")
|
Timber.i("[ACTION] Starting labels list load. State -> Loading.")
|
||||||
|
|
||||||
// [CORE-LOGIC]
|
// [CORE-LOGIC]
|
||||||
val result = runCatching {
|
val result =
|
||||||
|
runCatching {
|
||||||
getAllLabelsUseCase()
|
getAllLabelsUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,20 +66,22 @@ class LabelsListViewModel @Inject constructor(
|
|||||||
Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.")
|
Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.")
|
||||||
// [DATA-FLOW] Map List<LabelOut> to List<Label> for the UI state.
|
// [DATA-FLOW] Map List<LabelOut> to List<Label> for the UI state.
|
||||||
// The 'Label' model for the UI is simpler and only contains 'id' and 'name'.
|
// The 'Label' model for the UI is simpler and only contains 'id' and 'name'.
|
||||||
val labels = labelOuts.map { labelOut ->
|
val labels =
|
||||||
|
labelOuts.map { labelOut ->
|
||||||
Label(
|
Label(
|
||||||
id = labelOut.id,
|
id = labelOut.id,
|
||||||
name = labelOut.name
|
name = labelOut.name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_uiState.value = LabelsListUiState.Success(labels, isShowingCreateDialog = false)
|
_uiState.value = LabelsListUiState.Success(labels, isShowingCreateDialog = false)
|
||||||
},
|
},
|
||||||
onFailure = { exception ->
|
onFailure = { exception ->
|
||||||
Timber.e(exception, "[ERROR] Failed to load labels. State -> Error.")
|
Timber.e(exception, "[ERROR] Failed to load labels. State -> Error.")
|
||||||
_uiState.value = LabelsListUiState.Error(
|
_uiState.value =
|
||||||
message = exception.message ?: "Could not load labels."
|
LabelsListUiState.Error(
|
||||||
|
message = exception.message ?: "Could not load labels.",
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,5 +141,5 @@ class LabelsListViewModel @Inject constructor(
|
|||||||
onDismissCreateDialog()
|
onDismissCreateDialog()
|
||||||
// [REFACTORING_NOTE] На следующем этапе нужно добавить вызов UseCase и обновить список.
|
// [REFACTORING_NOTE] На следующем этапе нужно добавить вызов UseCase и обновить список.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [END_CLASS_LabelsListViewModel]
|
// [END_CLASS_LabelsListViewModel]
|
||||||
@@ -17,16 +17,16 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import com.homebox.lens.R
|
import com.homebox.lens.R
|
||||||
|
|
||||||
// [ENTRYPOINT]
|
// [ENTRYPOINT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Composable-функция для экрана "Редактирование местоположения".
|
* @summary Composable-функция для экрана "Редактирование местоположения".
|
||||||
* @param locationId ID местоположения для редактирования или "new" для создания.
|
* @param locationId ID местоположения для редактирования или "new" для создания.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LocationEditScreen(
|
fun LocationEditScreen(locationId: String?) {
|
||||||
locationId: String?
|
val title =
|
||||||
) {
|
if (locationId == "new") {
|
||||||
val title = if (locationId == "new") {
|
|
||||||
stringResource(id = R.string.location_edit_title_create)
|
stringResource(id = R.string.location_edit_title_create)
|
||||||
} else {
|
} else {
|
||||||
stringResource(id = R.string.location_edit_title_edit)
|
stringResource(id = R.string.location_edit_title_edit)
|
||||||
@@ -34,10 +34,11 @@ fun LocationEditScreen(
|
|||||||
|
|
||||||
Scaffold { paddingValues ->
|
Scaffold { paddingValues ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues),
|
.padding(paddingValues),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(text = "TODO: Location Edit Screen for ID: $locationId")
|
Text(text = "TODO: Location Edit Screen for ID: $locationId")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import com.homebox.lens.ui.common.MainScaffold
|
|||||||
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
||||||
|
|
||||||
// [ENTRYPOINT]
|
// [ENTRYPOINT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Composable-функция для экрана "Список местоположений".
|
* @summary Composable-функция для экрана "Список местоположений".
|
||||||
@@ -66,7 +67,7 @@ fun LocationsListScreen(
|
|||||||
navigationActions: NavigationActions,
|
navigationActions: NavigationActions,
|
||||||
onLocationClick: (String) -> Unit,
|
onLocationClick: (String) -> Unit,
|
||||||
onAddNewLocationClick: () -> Unit,
|
onAddNewLocationClick: () -> Unit,
|
||||||
viewModel: LocationsListViewModel = hiltViewModel()
|
viewModel: LocationsListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// [STATE]
|
// [STATE]
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
@@ -75,7 +76,7 @@ fun LocationsListScreen(
|
|||||||
MainScaffold(
|
MainScaffold(
|
||||||
topBarTitle = stringResource(id = R.string.locations_list_title),
|
topBarTitle = stringResource(id = R.string.locations_list_title),
|
||||||
currentRoute = currentRoute,
|
currentRoute = currentRoute,
|
||||||
navigationActions = navigationActions
|
navigationActions = navigationActions,
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
@@ -83,23 +84,24 @@ fun LocationsListScreen(
|
|||||||
FloatingActionButton(onClick = onAddNewLocationClick) {
|
FloatingActionButton(onClick = onAddNewLocationClick) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Add,
|
Icons.Default.Add,
|
||||||
contentDescription = stringResource(id = R.string.cd_add_new_location)
|
contentDescription = stringResource(id = R.string.cd_add_new_location),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
LocationsListContent(
|
LocationsListContent(
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.padding(innerPadding),
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onLocationClick = onLocationClick,
|
onLocationClick = onLocationClick,
|
||||||
onEditLocation = { /* TODO */ },
|
onEditLocation = { /* TODO */ },
|
||||||
onDeleteLocation = { /* TODO */ }
|
onDeleteLocation = { /* TODO */ },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [HELPER]
|
// [HELPER]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Отображает основной контент экрана в зависимости от `uiState`.
|
* @summary Отображает основной контент экрана в зависимости от `uiState`.
|
||||||
@@ -115,7 +117,7 @@ private fun LocationsListContent(
|
|||||||
uiState: LocationsListUiState,
|
uiState: LocationsListUiState,
|
||||||
onLocationClick: (String) -> Unit,
|
onLocationClick: (String) -> Unit,
|
||||||
onEditLocation: (String) -> Unit,
|
onEditLocation: (String) -> Unit,
|
||||||
onDeleteLocation: (String) -> Unit
|
onDeleteLocation: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
when (uiState) {
|
when (uiState) {
|
||||||
@@ -127,9 +129,10 @@ private fun LocationsListContent(
|
|||||||
text = uiState.message,
|
text = uiState.message,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is LocationsListUiState.Success -> {
|
is LocationsListUiState.Success -> {
|
||||||
@@ -137,21 +140,22 @@ private fun LocationsListContent(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.locations_not_found),
|
text = stringResource(id = R.string.locations_not_found),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
items(uiState.locations, key = { it.id }) { location ->
|
items(uiState.locations, key = { it.id }) { location ->
|
||||||
LocationCard(
|
LocationCard(
|
||||||
location = location,
|
location = location,
|
||||||
onClick = { onLocationClick(location.id) },
|
onClick = { onLocationClick(location.id) },
|
||||||
onEditClick = { onEditLocation(location.id) },
|
onEditClick = { onEditLocation(location.id) },
|
||||||
onDeleteClick = { onDeleteLocation(location.id) }
|
onDeleteClick = { onDeleteLocation(location.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +166,7 @@ private fun LocationsListContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Карточка для отображения одного местоположения.
|
* @summary Карточка для отображения одного местоположения.
|
||||||
@@ -175,25 +180,26 @@ private fun LocationCard(
|
|||||||
location: LocationOutCount,
|
location: LocationOutCount,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onEditClick: () -> Unit,
|
onEditClick: () -> Unit,
|
||||||
onDeleteClick: () -> Unit
|
onDeleteClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 16.dp, bottom = 16.dp),
|
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 16.dp, bottom = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(text = location.name, style = MaterialTheme.typography.titleMedium)
|
Text(text = location.name, style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.item_count, location.itemCount),
|
text = stringResource(id = R.string.item_count, location.itemCount),
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
@@ -203,21 +209,21 @@ private fun LocationCard(
|
|||||||
}
|
}
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = menuExpanded,
|
expanded = menuExpanded,
|
||||||
onDismissRequest = { menuExpanded = false }
|
onDismissRequest = { menuExpanded = false },
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(id = R.string.edit)) },
|
text = { Text(stringResource(id = R.string.edit)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
onEditClick()
|
onEditClick()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(id = R.string.delete)) },
|
text = { Text(stringResource(id = R.string.delete)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
onDeleteClick()
|
onDeleteClick()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,17 +235,18 @@ private fun LocationCard(
|
|||||||
@Preview(showBackground = true, name = "Locations List Success")
|
@Preview(showBackground = true, name = "Locations List Success")
|
||||||
@Composable
|
@Composable
|
||||||
fun LocationsListSuccessPreview() {
|
fun LocationsListSuccessPreview() {
|
||||||
val previewLocations = listOf(
|
val previewLocations =
|
||||||
|
listOf(
|
||||||
LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""),
|
LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""),
|
||||||
LocationOutCount("2", "Kitchen", "#00FF00", false, 5, "", ""),
|
LocationOutCount("2", "Kitchen", "#00FF00", false, 5, "", ""),
|
||||||
LocationOutCount("3", "Office", "#0000FF", false, 23, "", "")
|
LocationOutCount("3", "Office", "#0000FF", false, 23, "", ""),
|
||||||
)
|
)
|
||||||
HomeboxLensTheme {
|
HomeboxLensTheme {
|
||||||
LocationsListContent(
|
LocationsListContent(
|
||||||
uiState = LocationsListUiState.Success(previewLocations),
|
uiState = LocationsListUiState.Success(previewLocations),
|
||||||
onLocationClick = {},
|
onLocationClick = {},
|
||||||
onEditLocation = {},
|
onEditLocation = {},
|
||||||
onDeleteLocation = {}
|
onDeleteLocation = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +260,7 @@ fun LocationsListEmptyPreview() {
|
|||||||
uiState = LocationsListUiState.Success(emptyList()),
|
uiState = LocationsListUiState.Success(emptyList()),
|
||||||
onLocationClick = {},
|
onLocationClick = {},
|
||||||
onEditLocation = {},
|
onEditLocation = {},
|
||||||
onDeleteLocation = {}
|
onDeleteLocation = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,7 +274,7 @@ fun LocationsListLoadingPreview() {
|
|||||||
uiState = LocationsListUiState.Loading,
|
uiState = LocationsListUiState.Loading,
|
||||||
onLocationClick = {},
|
onLocationClick = {},
|
||||||
onEditLocation = {},
|
onEditLocation = {},
|
||||||
onDeleteLocation = {}
|
onDeleteLocation = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,7 +288,7 @@ fun LocationsListErrorPreview() {
|
|||||||
uiState = LocationsListUiState.Error("Failed to load locations. Please try again."),
|
uiState = LocationsListUiState.Error("Failed to load locations. Please try again."),
|
||||||
onLocationClick = {},
|
onLocationClick = {},
|
||||||
onEditLocation = {},
|
onEditLocation = {},
|
||||||
onDeleteLocation = {}
|
onDeleteLocation = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
// [CORE-LOGIC]
|
// [CORE-LOGIC]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary ViewModel для экрана списка местоположений.
|
* @summary ViewModel для экрана списка местоположений.
|
||||||
@@ -23,10 +24,11 @@ import javax.inject.Inject
|
|||||||
* @invariant `uiState` всегда отражает результат последней операции загрузки.
|
* @invariant `uiState` всегда отражает результат последней операции загрузки.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LocationsListViewModel @Inject constructor(
|
class LocationsListViewModel
|
||||||
private val getAllLocationsUseCase: GetAllLocationsUseCase
|
@Inject
|
||||||
) : ViewModel() {
|
constructor(
|
||||||
|
private val getAllLocationsUseCase: GetAllLocationsUseCase,
|
||||||
|
) : ViewModel() {
|
||||||
// [STATE]
|
// [STATE]
|
||||||
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading)
|
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading)
|
||||||
val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow()
|
||||||
@@ -37,6 +39,7 @@ class LocationsListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [ACTION]
|
// [ACTION]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Загружает список местоположений из репозитория.
|
* @summary Загружает список местоположений из репозитория.
|
||||||
@@ -54,5 +57,5 @@ class LocationsListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [END_CLASS_LocationsListViewModel]
|
// [END_CLASS_LocationsListViewModel]
|
||||||
}
|
}
|
||||||
// [END_FILE_LocationsListViewModel.kt]
|
// [END_FILE_LocationsListViewModel.kt]
|
||||||
@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
|
|||||||
import com.homebox.lens.ui.common.MainScaffold
|
import com.homebox.lens.ui.common.MainScaffold
|
||||||
|
|
||||||
// [ENTRYPOINT]
|
// [ENTRYPOINT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Composable-функция для экрана "Поиск".
|
* @summary Composable-функция для экрана "Поиск".
|
||||||
@@ -22,13 +23,13 @@ import com.homebox.lens.ui.common.MainScaffold
|
|||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
currentRoute: String?,
|
currentRoute: String?,
|
||||||
navigationActions: NavigationActions
|
navigationActions: NavigationActions,
|
||||||
) {
|
) {
|
||||||
// [UI_COMPONENT]
|
// [UI_COMPONENT]
|
||||||
MainScaffold(
|
MainScaffold(
|
||||||
topBarTitle = stringResource(id = R.string.search_title),
|
topBarTitle = stringResource(id = R.string.search_title),
|
||||||
currentRoute = currentRoute,
|
currentRoute = currentRoute,
|
||||||
navigationActions = navigationActions
|
navigationActions = navigationActions,
|
||||||
) {
|
) {
|
||||||
// [CORE-LOGIC]
|
// [CORE-LOGIC]
|
||||||
Text(text = "TODO: Search Screen")
|
Text(text = "TODO: Search Screen")
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// [VIEWMODEL]
|
// [VIEWMODEL]
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SearchViewModel @Inject constructor() : ViewModel() {
|
class SearchViewModel
|
||||||
|
@Inject
|
||||||
|
constructor() : ViewModel() {
|
||||||
// [STATE]
|
// [STATE]
|
||||||
// TODO: Implement UI state
|
// TODO: Implement UI state
|
||||||
}
|
}
|
||||||
// [END_FILE_SearchViewModel.kt]
|
// [END_FILE_SearchViewModel.kt]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import com.homebox.lens.R
|
import com.homebox.lens.R
|
||||||
|
|
||||||
// [ENTRYPOINT]
|
// [ENTRYPOINT]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Главная Composable-функция для экрана настройки соединения с сервером.
|
* @summary Главная Composable-функция для экрана настройки соединения с сервером.
|
||||||
@@ -32,7 +33,7 @@ import com.homebox.lens.R
|
|||||||
@Composable
|
@Composable
|
||||||
fun SetupScreen(
|
fun SetupScreen(
|
||||||
viewModel: SetupViewModel = hiltViewModel(),
|
viewModel: SetupViewModel = hiltViewModel(),
|
||||||
onSetupComplete: () -> Unit
|
onSetupComplete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// [STATE]
|
// [STATE]
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
@@ -48,12 +49,13 @@ fun SetupScreen(
|
|||||||
onServerUrlChange = viewModel::onServerUrlChange,
|
onServerUrlChange = viewModel::onServerUrlChange,
|
||||||
onUsernameChange = viewModel::onUsernameChange,
|
onUsernameChange = viewModel::onUsernameChange,
|
||||||
onPasswordChange = viewModel::onPasswordChange,
|
onPasswordChange = viewModel::onPasswordChange,
|
||||||
onConnectClick = viewModel::connect
|
onConnectClick = viewModel::connect,
|
||||||
)
|
)
|
||||||
// [END_FUNCTION_SetupScreen]
|
// [END_FUNCTION_SetupScreen]
|
||||||
}
|
}
|
||||||
|
|
||||||
// [HELPER]
|
// [HELPER]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* @summary Отображает контент экрана настройки: поля ввода и кнопку.
|
* @summary Отображает контент экрана настройки: поля ввода и кнопку.
|
||||||
@@ -69,33 +71,34 @@ private fun SetupScreenContent(
|
|||||||
onServerUrlChange: (String) -> Unit,
|
onServerUrlChange: (String) -> Unit,
|
||||||
onUsernameChange: (String) -> Unit,
|
onUsernameChange: (String) -> Unit,
|
||||||
onPasswordChange: (String) -> Unit,
|
onPasswordChange: (String) -> Unit,
|
||||||
onConnectClick: () -> Unit
|
onConnectClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(title = { Text(stringResource(id = R.string.setup_title)) })
|
TopAppBar(title = { Text(stringResource(id = R.string.setup_title)) })
|
||||||
}
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.serverUrl,
|
value = uiState.serverUrl,
|
||||||
onValueChange = onServerUrlChange,
|
onValueChange = onServerUrlChange,
|
||||||
label = { Text(stringResource(id = R.string.setup_server_url_label)) },
|
label = { Text(stringResource(id = R.string.setup_server_url_label)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.username,
|
value = uiState.username,
|
||||||
onValueChange = onUsernameChange,
|
onValueChange = onUsernameChange,
|
||||||
label = { Text(stringResource(id = R.string.setup_username_label)) },
|
label = { Text(stringResource(id = R.string.setup_username_label)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -103,13 +106,13 @@ private fun SetupScreenContent(
|
|||||||
onValueChange = onPasswordChange,
|
onValueChange = onPasswordChange,
|
||||||
label = { Text(stringResource(id = R.string.setup_password_label)) },
|
label = { Text(stringResource(id = R.string.setup_password_label)) },
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onConnectClick,
|
onClick = onConnectClick,
|
||||||
enabled = !uiState.isLoading,
|
enabled = !uiState.isLoading,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
@@ -135,7 +138,7 @@ fun SetupScreenPreview() {
|
|||||||
onServerUrlChange = {},
|
onServerUrlChange = {},
|
||||||
onUsernameChange = {},
|
onUsernameChange = {},
|
||||||
onPasswordChange = {},
|
onPasswordChange = {},
|
||||||
onConnectClick = {}
|
onConnectClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [END_FILE_SetupScreen.kt]
|
// [END_FILE_SetupScreen.kt]
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -8,7 +8,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.homebox.lens.domain.model.Credentials
|
import com.homebox.lens.domain.model.Credentials
|
||||||
import com.homebox.lens.domain.repository.CredentialsRepository
|
import com.homebox.lens.domain.repository.CredentialsRepository
|
||||||
import com.homebox.lens.domain.usecase.LoginUseCase
|
import com.homebox.lens.domain.usecase.LoginUseCase
|
||||||
import com.homebox.lens.ui.screen.setup.SetupUiState
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -18,6 +17,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// [VIEWMODEL]
|
// [VIEWMODEL]
|
||||||
// [ENTITY: ViewModel('SetupViewModel')]
|
// [ENTITY: ViewModel('SetupViewModel')]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [CONTRACT]
|
* [CONTRACT]
|
||||||
* ViewModel для экрана первоначальной настройки (Setup).
|
* ViewModel для экрана первоначальной настройки (Setup).
|
||||||
@@ -30,11 +30,12 @@ import javax.inject.Inject
|
|||||||
* @invariant Состояние `uiState` всегда является единственным источником истины для UI.
|
* @invariant Состояние `uiState` всегда является единственным источником истины для UI.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SetupViewModel @Inject constructor(
|
class SetupViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val credentialsRepository: CredentialsRepository,
|
private val credentialsRepository: CredentialsRepository,
|
||||||
private val loginUseCase: LoginUseCase
|
private val loginUseCase: LoginUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
// [STATE]
|
// [STATE]
|
||||||
private val _uiState = MutableStateFlow(SetupUiState())
|
private val _uiState = MutableStateFlow(SetupUiState())
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
@@ -61,7 +62,7 @@ class SetupViewModel @Inject constructor(
|
|||||||
it.copy(
|
it.copy(
|
||||||
serverUrl = credentials.serverUrl,
|
serverUrl = credentials.serverUrl,
|
||||||
username = credentials.username,
|
username = credentials.username,
|
||||||
password = credentials.password
|
password = credentials.password,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,10 +117,11 @@ class SetupViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
||||||
// [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов.
|
// [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов.
|
||||||
val credentials = Credentials(
|
val credentials =
|
||||||
|
Credentials(
|
||||||
serverUrl = _uiState.value.serverUrl.trim(),
|
serverUrl = _uiState.value.serverUrl.trim(),
|
||||||
username = _uiState.value.username.trim(),
|
username = _uiState.value.username.trim(),
|
||||||
password = _uiState.value.password
|
password = _uiState.value.password,
|
||||||
)
|
)
|
||||||
|
|
||||||
// [ACTION] Сохраняем учетные данные для будущего использования.
|
// [ACTION] Сохраняем учетные данные для будущего использования.
|
||||||
@@ -134,10 +136,10 @@ class SetupViewModel @Inject constructor(
|
|||||||
onFailure = { exception ->
|
onFailure = { exception ->
|
||||||
// [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке.
|
// [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке.
|
||||||
_uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") }
|
_uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") }
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [END_CLASS_SetupViewModel]
|
// [END_CLASS_SetupViewModel]
|
||||||
}
|
}
|
||||||
// [END_FILE_SetupViewModel.kt]
|
// [END_FILE_SetupViewModel.kt]
|
||||||
@@ -18,26 +18,29 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme =
|
||||||
|
darkColorScheme(
|
||||||
primary = Purple80,
|
primary = Purple80,
|
||||||
secondary = PurpleGrey80,
|
secondary = PurpleGrey80,
|
||||||
tertiary = Pink80
|
tertiary = Pink80,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme =
|
||||||
|
lightColorScheme(
|
||||||
primary = Purple40,
|
primary = Purple40,
|
||||||
secondary = PurpleGrey40,
|
secondary = PurpleGrey40,
|
||||||
tertiary = Pink40
|
tertiary = Pink40,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeboxLensTheme(
|
fun HomeboxLensTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
// Dynamic color is available on Android 12+
|
// Dynamic color is available on Android 12+
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme =
|
||||||
|
when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
@@ -58,7 +61,7 @@ fun HomeboxLensTheme(
|
|||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// [END_FILE_Theme.kt]
|
// [END_FILE_Theme.kt]
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
// Set of Material typography styles to start with
|
// Set of Material typography styles to start with
|
||||||
val Typography = Typography(
|
val Typography =
|
||||||
bodyLarge = TextStyle(
|
Typography(
|
||||||
|
bodyLarge =
|
||||||
|
TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
lineHeight = 24.sp,
|
lineHeight = 24.sp,
|
||||||
letterSpacing = 0.5.sp
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
// [END_FILE_Typography.kt]
|
// [END_FILE_Typography.kt]
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
@@ -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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
com.busya.ktlint.rules.CustomRuleSetProvider
|
||||||
@@ -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] ...'.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 ??.
|
||||||
|
|||||||
11
logs/communication_log.xml
Normal file
11
logs/communication_log.xml
Normal 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>
|
||||||
@@ -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
1532
tasks/01.xml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
Reference in New Issue
Block a user