diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6965136..026f990 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,16 +1,17 @@ { "name": "layrz-theme", "owner": { - "name": "goldenm-software" + "name": "Golden M, Inc.", + "email": "kenny@goldenm.com" }, "metadata": { "description": "Claude Code skills for layrz_theme Flutter widget library" }, "plugins": [ { - "name": "layrz_theme", - "source": "./", - "description": "Skills for using layrz_theme components in Flutter projects" + "name": "layrz-theme", + "source": "./.claude", + "description": "Skills for using layrz-theme components in Flutter projects" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index f8a18f1..0000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "layrz_theme", - "description": "Claude Code skills for layrz_theme Flutter widget library", - "version": "7.5.26" -} diff --git a/.claude-plugin/skills/add-dual-list/SKILL.md b/.claude-plugin/skills/add-dual-list/SKILL.md deleted file mode 100644 index ee83822..0000000 --- a/.claude-plugin/skills/add-dual-list/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: add-dual-list -description: Add a ThemedDualListInput to a layrz Flutter form or tab. Use when wiring a new list field from any layrz Input class (e.g. LocatorInput, AssetInput) into any widget — a tab, a form view, or any stateful/stateless widget. ---- - -## Task - -Wire a new list field into any widget in a layrz Flutter package using `ThemedDualListInput`. - -The user must tell you: -- The **Input class** and the **field name** (e.g. `LocatorInput.poisIds`) -- The **item model** whose list will be displayed (e.g. `Poi`) -- The **target widget** where the dual list should appear (a tab file, a form view, or any other widget) -- The **parent** that owns the Input object and must pass the item list down (if different from the target widget) - -If any of these are unclear, ask before proceeding. - -## Steps - -### 1. Resolve the correct package version - -1. Read `pubspec.lock` in the project root and find the resolved version of the package that contains the Input class (e.g. `layrz_models: 3.6.24`). -2. Locate the pub cache: - - Default on Linux/macOS: `~/.pub-cache/hosted/pub.dev/-/` - - Default on Windows: `%LOCALAPPDATA%\Pub\Cache\hosted\pub.dev/-/` - - If the versioned directory is not found at either default path, ask the user for the pub cache location before continuing. -3. Confirm the directory name exactly matches the resolved version from `pubspec.lock` — do not read a different version. - -### 2. Read the Input class field - -Inside the resolved package directory, locate the Input class source file and confirm: -- The exact field name (e.g. `poisIds`) -- Its type (`List`) - -Check the item model (e.g. `Poi`) for the properties used in the selector — typically `id` and `name`. - -### 2. Add the list parameter to the target widget - -In the target widget class, add: - -```dart -final List items; // e.g. final List pois -``` - -Add it to the constructor as `required`. - -### 3. Add ThemedDualListInput inside the build method - -Place it at the appropriate position, preceded by `const SizedBox(height: 10)` if other widgets are already present: - -```dart -const SizedBox(height: 10), -ThemedDualListInput( - labelText: context.i18n.t(''), - value: object., - items: items.map((item) => ThemedSelectItem(value: item.id, label: item.name)).toList(), - errors: context.getErrors(key: ''), - onChanged: (values) { - object. = values.map((e) => e.value).nonNulls.toList(); - if (context.mounted) onChanged.call(); - }, -), -``` - -### 4. Wire the list through the parent(s) - -For each widget in the chain between the data source and the target widget: - -1. **Args class** (e.g. `LocatorsFormArgs`) — add `final List items` and the constructor parameter if it isn't already there. -2. **Stateful parent** — add `late final List items`, assign it from args in `initState`, and pass it down to the child widget. -3. **If the target is a tab** — pass it in the `ThemedTab` child constructor call. -4. **If the target is the form view itself** — use it directly from the state. - -### 5. Verify - -```bash -flutter analyze -``` - -No issues = done. - -## Key conventions - -- Use `nonNulls` to filter nulls: `values.map((e) => e.value).nonNulls.toList()` -- Always guard `onChanged` with `if (context.mounted)` -- i18n key typically follows `.` (e.g. `locators.poisIds`) -- Line width limit is **120 characters** -- Never use raw Material widgets — always use `layrz_theme` components -- Always derive the package version from `pubspec.lock`, never guess or use the latest available directory in the cache diff --git a/.claude-plugin/skills/boolean-radio-inputs/SKILL.md b/.claude-plugin/skills/boolean-radio-inputs/SKILL.md deleted file mode 100644 index 91fbddc..0000000 --- a/.claude-plugin/skills/boolean-radio-inputs/SKILL.md +++ /dev/null @@ -1,289 +0,0 @@ ---- -name: boolean-radio-inputs -description: Use ThemedCheckboxInput or ThemedRadioInput in a layrz Flutter widget. Apply when adding a boolean toggle, switch, checkbox, or radio group to any form or view. ---- - -## Overview - -Two components cover boolean and discrete-choice inputs: - -| Component | State type | `onChanged` signature | When to use | -|---|---|---|---| -| `ThemedCheckboxInput` | `bool` | `void Function(bool)?` | Single true/false flag — active state, agreement, toggle | -| `ThemedRadioInput` | `T?` | `void Function(T?)?` | Pick exactly one value from a fixed, visible list of options | - -Use `ThemedCheckboxInput` when the field is a plain boolean. Use `ThemedRadioInput` when the user must choose one item from a short enumerable list that should all be visible at once (not hidden behind a dialog). - -Never use raw Flutter `Checkbox`, `Switch`, or `Radio` — always use these components. - ---- - -## ThemedCheckboxInput — boolean toggle - -### Minimal usage - -```dart -// State -bool isActive = false; - -// Widget -ThemedCheckboxInput( - labelText: context.i18n.t('entity.isActive'), - value: isActive, - onChanged: (value) { - setState(() => isActive = value); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Mutually exclusive with `label`. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Mutually exclusive with `labelText`. | -| `value` | `bool` | `false` | Current boolean state | -| `onChanged` | `void Function(bool)?` | `null` | Callback on toggle. Receives the new `bool` directly — no item unwrapping needed. | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation error messages shown below | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row | -| `padding` | `EdgeInsets` | `EdgeInsets.all(10)` | Outer padding around the widget | -| `dense` | `bool` | `false` | Reduces vertical padding | -| `style` | `ThemedCheckboxInputStyle` | `.asFlutterCheckbox` | Visual style variant — see style table below | - -### Style variants - -| Style | Appearance | Notes | -|---|---|---| -| `.asFlutterCheckbox` | Native Flutter `Checkbox` + label | Default. Inline checkbox, label is tappable to toggle. | -| `.asCheckbox2` | Animated custom checkbox + label | Layrz animated design. Preferred for new UIs. | -| `.asSwitch` | Material `Switch` + label | Use when the semantic is "enable/disable" rather than "agree/check". | -| `.asField` | Renders as `ThemedSelectInput` with Yes/No items | Use when the field must match the visual style of a full select input row. Opens a dialog on tap. | - -### Behavior notes - -- `value` is always plain `bool` — there is no nullable `T?` here. The internal state syncs from `widget.value` on `didUpdateWidget`, so the parent is the source of truth. -- `onChanged` receives the new `bool` directly (not wrapped in a `ThemedSelectItem`). No `.value` unwrapping needed. -- When `style` is `.asField`, the widget delegates entirely to `ThemedSelectInput` with two items (`true` → "Yes", `false` → "No"). Labels use `LayrzAppLocalizations` (`helpers.true` / `helpers.false`) with "Yes"/"No" fallbacks. All `ThemedSelectInput` caveats apply (dialog-based, `autoclose`, etc.). -- Tapping the label text also toggles the value for `.asFlutterCheckbox`, `.asCheckbox2`, and `.asSwitch` — the `GestureDetector` wraps the label `Expanded`. -- `label` and `labelText` are mutually exclusive — the constructor asserts this. Never pass both. - -### Common patterns - -```dart -// Default — animated checkbox (preferred for new code) -ThemedCheckboxInput( - labelText: context.i18n.t('entity.isActive'), - value: isActive, - style: .asCheckbox2, - errors: context.getErrors(key: 'isActive'), - onChanged: (value) { - setState(() => isActive = value); - }, -) - -// Switch style — semantic "enable/disable" -ThemedCheckboxInput( - labelText: context.i18n.t('entity.notificationsEnabled'), - value: notificationsEnabled, - style: .asSwitch, - onChanged: (value) { - setState(() => notificationsEnabled = value); - }, -) - -// Field style — matches a form that is all select inputs -ThemedCheckboxInput( - labelText: context.i18n.t('entity.isPublic'), - value: isPublic, - style: .asField, - errors: context.getErrors(key: 'isPublic'), - onChanged: (value) { - setState(() => isPublic = value); - }, -) - -// Disabled / read-only -ThemedCheckboxInput( - labelText: context.i18n.t('entity.isVerified'), - value: isVerified, - disabled: true, -) -``` - ---- - -## ThemedRadioInput — single choice from a visible list - -### Minimal usage - -```dart -// State -String? selectedStatus; - -// Items -final items = [ - ThemedSelectItem(value: 'active', label: 'Active'), - ThemedSelectItem(value: 'inactive', label: 'Inactive'), - ThemedSelectItem(value: 'pending', label: 'Pending'), -]; - -// Widget -ThemedRadioInput( - labelText: context.i18n.t('entity.status'), - items: items, - value: selectedStatus, - onChanged: (value) { - setState(() => selectedStatus = value); - }, -) -``` - -`onChanged` receives `T?` directly — no `ThemedSelectItem` unwrapping needed. - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Mutually exclusive with `label`. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Mutually exclusive with `labelText`. | -| `items` | `List>` | required | The selectable options | -| `value` | `T?` | `null` | Currently selected value | -| `onChanged` | `void Function(T?)?` | `null` | Callback — receives the raw `T?`, not a `ThemedSelectItem` | -| `disabled` | `bool` | `false` | Disables all radio buttons (selection still shows) | -| `errors` | `List` | `[]` | Validation error messages shown below | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row | -| `padding` | `EdgeInsets` | `EdgeInsets.all(10)` | Outer padding around the widget | -| `xsSize` | `Sizes` | `.col12` | Column width on extra-small screens (< 600 px) | -| `smSize` | `Sizes?` | `.col6` | Column width on small screens (600–960 px) | -| `mdSize` | `Sizes?` | `.col4` | Column width on medium screens (960–1264 px) | -| `lgSize` | `Sizes?` | `.col3` | Column width on large screens (1264–1904 px) | -| `xlSize` | `Sizes?` | `.col2` | Column width on extra-large screens (> 1904 px) | - -### Responsive grid - -Items are laid out in a `ResponsiveRow` / `ResponsiveCol` grid. The default layout gives: - -- xs → 1 column (`col12`) -- sm → 2 columns (`col6`) -- md → 3 columns (`col4`) -- lg → 4 columns (`col3`) -- xl → 6 columns (`col2`) - -Override any breakpoint to control density. Example — force two columns at all sizes: - -```dart -ThemedRadioInput( - labelText: context.i18n.t('entity.gender'), - items: genderItems, - value: selectedGender, - xsSize: .col6, - smSize: .col6, - mdSize: .col6, - lgSize: .col6, - xlSize: .col6, - onChanged: (value) => setState(() => selectedGender = value), -) -``` - -### Behavior notes - -- `onChanged` is called with the raw `T?` — no `.value` unwrapping. This differs from `ThemedSelectInput` which wraps the result in `ThemedSelectItem?`. -- `disabled: true` renders the widget but ignores all taps and label GestureDetector callbacks. The currently selected item remains visible. -- There is no "deselect" capability — once an item is selected the user cannot clear back to `null` through the UI. If unselected state is required, handle it in the parent (e.g., expose a clear button). -- Tapping the label `Text` next to a radio button also triggers selection via `GestureDetector`, not just tapping the radio circle itself. -- `label` and `labelText` are mutually exclusive — the constructor asserts this. Never pass both. -- Items are built from `ThemedSelectItem` — reuse the same items list you would use for `ThemedSelectInput`. - -### Common patterns - -```dart -// Enum-backed radio group -ThemedRadioInput( - labelText: context.i18n.t('entity.role'), - items: UserRole.values.map((r) => ThemedSelectItem(value: r, label: r.label)).toList(), - value: selectedRole, - errors: context.getErrors(key: 'role'), - onChanged: (value) => setState(() => selectedRole = value), -) - -// Full-width single column on all screens -ThemedRadioInput( - labelText: context.i18n.t('entity.priority'), - items: priorityItems, - value: selectedPriority, - xsSize: .col12, - smSize: .col12, - mdSize: .col12, - lgSize: .col12, - xlSize: .col12, - onChanged: (value) => setState(() => selectedPriority = value), -) -``` - ---- - -## Integrating with layrz forms - -```dart -// ThemedCheckboxInput in a form -ThemedCheckboxInput( - labelText: context.i18n.t('entity.isActive'), - value: object.isActive, - style: .asCheckbox2, - errors: context.getErrors(key: 'isActive'), - onChanged: (value) { - object.isActive = value; - if (context.mounted) onChanged.call(); - }, -) - -const SizedBox(height: 10), - -// ThemedRadioInput in a form -ThemedRadioInput( - labelText: context.i18n.t('entity.status'), - items: statusItems, - value: object.status, - errors: context.getErrors(key: 'status'), - onChanged: (value) { - object.status = value; - if (context.mounted) onChanged.call(); - }, -) -``` - -Key conventions: -- Always guard `onChanged` body with `if (context.mounted)` before calling parent callbacks that might rebuild the tree. -- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode display strings. -- Use `context.getErrors(key: 'fieldName')` for `errors` — never build the error list manually. -- Separate stacked inputs with `const SizedBox(height: 10)`. -- `label` and `labelText` are mutually exclusive — pick one per widget instance. - ---- - -## Choosing between the two - -### ThemedCheckboxInput vs ThemedRadioInput - -Use `ThemedCheckboxInput` when: -- The field is a plain `bool` (active/inactive, agree/disagree, enabled/disabled). -- There are exactly two states and no in-between. - -Use `ThemedRadioInput` when: -- The field stores a discrete typed value (`String`, `int`, enum) from a fixed list. -- All options should be visible simultaneously without a dialog. -- The number of options is small enough to fit in the layout (typically 2–6 items). - -For longer option lists (7+ items, or items loaded from an API), prefer `ThemedSelectInput` instead — it hides options behind a searchable dialog. - -### Choosing a ThemedCheckboxInput style - -| Scenario | Recommended style | -|---|---| -| New UI, checkbox semantics | `.asCheckbox2` (animated, Layrz design) | -| Enable/disable toggle, prominent | `.asSwitch` | -| Form with all select-input rows, need visual consistency | `.asField` | -| Legacy form or matching older screens | `.asFlutterCheckbox` | - -Avoid `.asField` unless visual consistency with `ThemedSelectInput` rows is explicitly required — it introduces a dialog tap for a simple boolean, which adds unnecessary friction. diff --git a/.claude-plugin/skills/conventional-commits/SKILL.md b/.claude-plugin/skills/conventional-commits/SKILL.md deleted file mode 100644 index 489e986..0000000 --- a/.claude-plugin/skills/conventional-commits/SKILL.md +++ /dev/null @@ -1,393 +0,0 @@ ---- -name: conventional-commits -description: Create semantic commit messages following Conventional Commits specification with proper formatting and changelog impact ---- - -## Overview - -Conventional Commits provides a specification for adding human and machine readable meaning to commit messages. This skill helps create commits with proper formatting, types, scopes, and breaking change declarations. - -| Element | Purpose | Impact | -|---|---|---| -| Type | Classifies the change (feat, fix, docs, etc.) | Determines changelog category | -| Scope | Optional context (component, module name) | Narrows change scope in changelog | -| Description | Concise change summary in imperative mood | Primary changelog entry | -| Body | Detailed explanation (optional) | PR review and code history | -| Breaking Change | Signals incompatible API/behavior changes | Major version bump required | -| Co-authors | Attribution for human/AI collaborators | Credit in commit history | - -**When to use:** Every commit. The specification ensures semantic versioning, automatic changelog generation, and clear git history. - ---- - -## Format Specification - -### Basic Format - -``` -: - -[optional body] -[extra : ] - -[BREAKING CHANGE: ] - -[optional co-author(s)] -``` - -### Subject Line Rules - -- **Type MUST be lowercase**: `feat`, `fix`, `docs`, `perf`, `test`, `refactor`, `sec`, `lab`, `exp`, `deps`, `revert`, `chore`, `style` -- **Colon and space MUST follow type**: `: ` (required separator) -- **Description MUST be present**: Concise, actionable, and in imperative mood -- **Description MUST NOT end with period**: "Add OAuth support" NOT "Add OAuth support." -- **Scope is optional**: `feat(auth): Add OAuth2 support` — narrows context in changelog -- **Keep under 72 characters**: Optimal for git log display - ---- - -## Required Types - -| Type | Changelog | Purpose | Example | -|---|---|---|---| -| `feat` | ✅ Features | New feature addition | `feat: Add passkey authentication` | -| `fix` | ✅ Bug Fixes | Bug fix | `fix: Resolve MFA validation timing issue` | -| `docs` | ✅ Documentation | Documentation changes | `docs: Update OAuth2 setup instructions` | -| `perf` | ✅ Performance | Performance improvement | `perf: Optimize GraphQL query caching` | -| `test` | ❌ Hidden | Adding/updating tests | `test: Add login flow integration tests` | -| `refactor` | ❌ Hidden | Code refactoring (no functional change) | `refactor: Simplify auth middleware` | -| `sec` | ✅ Security | Security patch/CVE fix | `sec: Patch CVE-2026-12345 in jwt` | -| `lab` | ✅ Labs | Labs/exploratory work | `lab: Prototype Redis caching layer` | -| `exp` | ✅ Experimental | Experimental features | `exp: Test WebAuthn alternative flow` | -| `deps` | ✅ Dependencies | Dependency updates | `deps: Bump axios from 1.6.0 to 1.7.2` | -| `revert` | ✅ Reverts | Revert previous commit | `revert: Revert OAuth2 flow changes` | -| `chore` | ❌ Hidden | Maintenance tasks | `chore: Update build config` | -| `style` | ❌ Hidden | Code style changes | `style: Format TypeScript files` | - ---- - -## Single-Type Commits - -### Simple Feature - -``` -feat: Add OAuth2 authentication support -``` - -### Bug Fix with Scope - -``` -fix(tabs): Resolve arrow button state not updating during navigation -``` - -### Security Fix - -``` -sec: Upgrade jwt library to patch CVE-2026-12345 -``` - -### Documentation - -``` -docs: Add OAuth2 setup guide for web platform -``` - -### Performance Improvement - -``` -perf: Optimize GraphQL query caching layer -``` - ---- - -## Multi-Type Commits - -A single commit can declare **multiple changes** in the body. The subject line is the primary type, and additional `: ` lines in the body are each categorized independently in the changelog. - -### Example: Feature with Security, Docs, and Fixes - -``` -feat: Add OAuth2 authentication - -sec: Upgrade jwt library to fix CVE-2026-12345 -docs: Add OAuth2 setup instructions -fix: Resolve token refresh race condition - -Co-authored-by: Kenny Mochizuki -Co-authored-by: AI Assistant -``` - -**Result:** 4 separate changelog entries: -1. ✅ Features: "Add OAuth2 authentication" -2. ✅ Security: "Upgrade jwt library to fix CVE-2026-12345" -3. ✅ Documentation: "Add OAuth2 setup instructions" -4. ✅ Bug Fixes: "Resolve token refresh race condition" - -### Example: Widget Enhancement with Tests and Docs - -``` -feat: Enhance ThemedTabView with wrap navigation - -test: Add 4 comprehensive widget tests for arrow navigation -docs: Update ThemedTabView skill documentation - -BREAKING CHANGE: initialPosition now clamped instead of rejected for invalid indices -``` - -**Result:** 3 visible entries + 1 breaking change notice: -1. ✅ Features: "Enhance ThemedTabView with wrap navigation" -2. ✅ Tests (hidden): "Add 4 comprehensive widget tests for arrow navigation" -3. ✅ Documentation: "Update ThemedTabView skill documentation" -4. ⚠️ BREAKING CHANGE announced - ---- - -## Breaking Changes - -### Declaration Methods - -#### Method 1: As Subject Line - -``` -BREAKING CHANGE: Replace session-based auth with JWT -``` - -#### Method 2: As Footer - -``` -feat: Replace session-based auth with JWT - -All endpoints now require Bearer token authentication instead of session cookies. - -BREAKING CHANGE: Session-based authentication removed -``` - -#### Method 3: With Scope (Recommended) - -``` -feat(auth)!: Replace session-based auth with JWT - -Migration path: -- Clients must obtain Bearer token via /auth/token endpoint -- Old session cookies no longer accepted -- Legacy endpoints deprecated (see docs) - -BREAKING CHANGE: All endpoints now require Bearer token authentication - -Co-authored-by: Security Team -``` - -The `!` before the colon signals a breaking change at a glance. - ---- - -## Scope Guidelines - -Optional scope clarifies **which component/module/system** the change affects. - -``` -feat(auth): Add passkey support ← Scope: auth module -feat(tabs): Add wrap navigation ← Scope: tabs widget -fix(api): Resolve timeout on bulk upload ← Scope: api layer -perf(cache): Optimize query results ← Scope: cache system -docs(setup): Update installation steps ← Scope: setup instructions -``` - -**Common scopes:** -- Component names: `(tabs)`, `(table)`, `(input)`, `(avatar)` -- Feature areas: `(auth)`, `(api)`, `(ui)`, `(db)` -- Layers: `(frontend)`, `(backend)`, `(schema)` -- Systems: `(cache)`, `(logging)`, `(monitoring)` - ---- - -## Co-Authors - -Attribute multiple contributors (humans or AIs) to a single commit. - -``` -feat: Add OAuth2 authentication - -sec: Upgrade jwt library to fix CVE-2026-12345 -docs: Add OAuth2 setup instructions -fix: Resolve token refresh race condition - -Co-authored-by: Kenny Mochizuki -Co-authored-by: AI Assistant -``` - -**Format rules:** -- One `Co-authored-by:` per line -- Include name and email in angle brackets -- Placed at end of commit message -- Email must be valid format (can be noreply) - -**AI Attribution Examples:** -``` -Co-authored-by: Claude AI -Co-authored-by: GitHub Copilot -Co-authored-by: Claude Haiku -``` - ---- - -## Real-World Examples - -### Example 1: ThemedTabView Enhancement (Multi-type) - -``` -feat(tabs): Add wrapArrowNavigation for circular tab navigation - -test: Add 4 comprehensive widget tests for arrow navigation - - Arrow button state updates reactively during navigation (regression) - - wrapArrowNavigation wraps from last tab to first tab - - wrapArrowNavigation wraps from first tab to last tab - - wrapArrowNavigation keeps arrows enabled at boundaries - -fix(tabs): Resolve arrow button state not updating on tab change - -docs: Update ThemedTabView skill documentation - -All 358 tests passing, flutter analyze clean. - -Co-authored-by: Claude AI -``` - -**Changelog result:** -- ✅ Features: "Add wrapArrowNavigation for circular tab navigation" -- ✅ Bug Fixes: "Resolve arrow button state not updating on tab change" -- ✅ Documentation: "Update ThemedTabView skill documentation" -- ❌ Hidden: Test coverage details - -### Example 2: Security Patch - -``` -sec: Patch CVE-2026-12345 in jwt dependency - -Upgraded jwt library from 8.2.0 to 8.2.1. - -Vulnerability: Timing attack on signature verification -Impact: Low - affects only applications validating tokens with high frequency -Mitigation: Update immediately for defense-in-depth - -Co-authored-by: Security Team -``` - -### Example 3: Simple Bug Fix - -``` -fix(auth): Resolve session timeout not refreshing token -``` - -### Example 4: Breaking Change - -``` -feat(auth)!: Replace session-based auth with JWT - -Session cookies are no longer accepted. All clients must: -1. Call POST /auth/token to obtain Bearer token -2. Include token in Authorization header: "Bearer " -3. Handle 401 responses with token refresh logic - -Deprecation timeline: -- v8.0: Bearer token required (breaking) -- v7.5: Both session and Bearer supported (this release) -- v7.4 and earlier: Session-only (legacy) - -BREAKING CHANGE: Session-based authentication removed in favor of JWT - -Co-authored-by: API Team -``` - ---- - -## Commit Message Structure - -### Minimal Commit - -``` -type: description -``` - -### Full Commit with Details - -``` -type(scope): description - -Detailed explanation of what was changed and why. This is where you explain -the motivation for the change and any relevant context. - -BREAKING CHANGE: description of the breaking change -Co-authored-by: Name -``` - -### Multi-Type Commit - -``` -type(scope): primary description - -secondary_type: secondary description -tertiary_type: tertiary description - -Additional context or motivation here. - -BREAKING CHANGE: optional breaking change notice -Co-authored-by: Name -``` - ---- - -## Validation Checklist - -Before creating a commit, verify: - -- ✅ Type is lowercase (`feat`, `fix`, `docs`, etc.) -- ✅ Type followed by colon and space (`: `) -- ✅ Description is in imperative mood ("Add", "Fix", "Update" NOT "Added", "Fixed") -- ✅ Description does NOT end with period -- ✅ Subject line under 72 characters -- ✅ Scope in parentheses if used: `feat(component): description` -- ✅ Body explains "why" not just "what" -- ✅ Breaking changes declared with `BREAKING CHANGE:` footer -- ✅ Co-authors formatted as `Co-authored-by: Name ` -- ✅ No extra blank lines between header and body -- ✅ One blank line before BREAKING CHANGE or Co-authored-by - ---- - -## Common Mistakes to Avoid - -❌ **Wrong:** `feat: Adds OAuth support` (past tense, ends with period) -✅ **Correct:** `feat: Add OAuth support` - -❌ **Wrong:** `FIX: resolve token issue` (type not lowercase) -✅ **Correct:** `fix: Resolve token issue` - -❌ **Wrong:** `feat(tabs) Add navigation` (missing colon) -✅ **Correct:** `feat(tabs): Add navigation` - -❌ **Wrong:** Mixing descriptions without types in body -✅ **Correct:** Each line in body starts with `type: description` - -❌ **Wrong:** `Co-authored by: Name` (incorrect keyword) -✅ **Correct:** `Co-authored-by: Name ` - ---- - -## Benefits - -1. **Semantic Versioning:** Commit types drive major/minor/patch version bumps -2. **Automated Changelog:** Tools parse commits and generate organized changelogs by type -3. **Searchable History:** `git log --grep="^feat"` finds all features -4. **Code Review:** Clear intent in subject line speeds review -5. **Release Notes:** Types automatically categorize changes for users -6. **Team Alignment:** Consistent format across all contributors - ---- - -## Reference - -- Full Specification: https://www.conventionalcommits.org -- Changelog Generation: commitizen/cz-cli automates commits and changelog -- Type Definitions: See "Required Types" table above -- Scope Best Practices: Keep scopes consistent with your codebase structure diff --git a/.claude-plugin/skills/date-pickers/SKILL.md b/.claude-plugin/skills/date-pickers/SKILL.md deleted file mode 100644 index 88d5fcf..0000000 --- a/.claude-plugin/skills/date-pickers/SKILL.md +++ /dev/null @@ -1,445 +0,0 @@ ---- -name: date-pickers -description: Use ThemedDatePicker, ThemedDateRangePicker, ThemedMonthPicker, or ThemedMonthRangePicker in a layrz - Flutter widget. Apply when adding a date or month selection field. ---- - -## Overview - -Four components cover all date and month selection needs: - -| Component | State type | `onChanged` signature | Granularity | -|---|---|---|---| -| `ThemedDatePicker` | `DateTime?` | `void Function(DateTime)` | Single day | -| `ThemedDateRangePicker` | `List` (0 or 2) | `void Function(List)` | Day range | -| `ThemedMonthPicker` | `ThemedMonth?` | `void Function(ThemedMonth)` | Single month + year | -| `ThemedMonthRangePicker` | `List` | `void Function(List)` | Month range | - -All four open a dialog — never use raw Flutter `showDatePicker` or `showDateRangePicker`. - ---- - -## ThemedDatePicker — single day - -### Minimal usage - -```dart -// State -DateTime? selectedDate; - -// Widget -ThemedDatePicker( - labelText: context.i18n.t('entity.date'), - value: selectedDate, - onChanged: (date) { - if (context.mounted) setState(() => selectedDate = date); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Exactly one must be set. | -| `value` | `DateTime?` | `null` | Currently selected date | -| `onChanged` | `void Function(DateTime)?` | `null` | Called with the picked day; never null inside callback | -| `pattern` | `String` | `'%Y-%m-%d'` | Display format string passed to `DateTime.format()` | -| `disabledDays` | `List` | `[]` | Days greyed out and non-tappable in the calendar | -| `disabled` | `bool` | `false` | Disables the entire input | -| `errors` | `List` | `[]` | Validation error messages shown below the field | -| `hideDetails` | `bool` | `false` | Hides the errors / hints row | -| `padding` | `EdgeInsets?` | `null` | Outer padding of the input | -| `placeholder` | `String?` | `null` | Hint text when no date is selected | -| `prefixText` | `String?` | `null` | Static text before the input | -| `prefixIcon` | `IconData?` | `null` | Icon before the field. Mutually exclusive with `prefixWidget`. | -| `prefixWidget` | `Widget?` | `null` | Widget before the field. Mutually exclusive with `prefixIcon`. | -| `onPrefixTap` | `VoidCallback?` | `null` | Called when prefix is tapped | -| `customChild` | `Widget?` | `null` | Replaces the text field; the entire widget becomes tappable | -| `translations` | `Map` | see below | Override i18n keys when `LayrzAppLocalizations` is absent | -| `overridesLayrzTranslations` | `bool` | `false` | Force `translations` map even when localizations are present | - -### Behavior notes - -- The dialog renders a `ThemedCalendar` inside a `Dialog` constrained to `maxWidth: 400, maxHeight: 400`. -- If `value` is a `TZDateTime`, the returned `DateTime` is also wrapped in the same `TZDateTime` location — - the timezone is preserved automatically. -- The suffix icon is always `LayrzIcons.solarOutlineCalendar`; it cannot be overridden. -- The field is always `readonly: true` — users cannot type dates directly. - -### Common patterns - -```dart -// With timezone-aware value (TZDateTime) -ThemedDatePicker( - labelText: context.i18n.t('trip.departureDate'), - value: trip.departureDate, // TZDateTime - onChanged: (date) { - // date is already TZDateTime with the same location - if (context.mounted) setState(() => trip.departureDate = date as TZDateTime); - }, -) - -// With disabled days -ThemedDatePicker( - labelText: context.i18n.t('schedule.date'), - value: schedule.date, - disabledDays: holidays, - errors: context.getErrors(key: 'date'), - onChanged: (date) { - if (context.mounted) setState(() => schedule.date = date); - }, -) - -// Custom display format -ThemedDatePicker( - labelText: context.i18n.t('report.period'), - value: report.date, - pattern: '%d/%m/%Y', - onChanged: (date) { - if (context.mounted) setState(() => report.date = date); - }, -) -``` - ---- - -## ThemedDateRangePicker — day range - -### Minimal usage - -```dart -// State — always empty or exactly 2 elements: [start, end] -List dateRange = []; - -// Widget -ThemedDateRangePicker( - labelText: context.i18n.t('entity.dateRange'), - value: dateRange, - onChanged: (range) { - if (context.mounted) setState(() => dateRange = range); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided | -| `value` | `List` | `[]` | Must be empty or exactly 2 items: `[start, end]` | -| `onChanged` | `void Function(List)?` | `null` | Returns `[start, end]`, always sorted | -| `pattern` | `String` | `'%Y-%m-%d'` | Display format for both dates; they are joined with ` - ` | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation error messages | -| `hideDetails` | `bool` | `false` | Hides the errors / hints row | -| `padding` | `EdgeInsets?` | `null` | Outer padding of the input | -| `placeholder` | `String?` | `null` | Hint text when no range is selected | -| `prefixText` | `String?` | `null` | Static text before the input | -| `prefixIcon` | `IconData?` | `null` | Icon before the field. Mutually exclusive with `prefixWidget`. | -| `prefixWidget` | `Widget?` | `null` | Widget before the field. Mutually exclusive with `prefixIcon`. | -| `onPrefixTap` | `VoidCallback?` | `null` | Called when prefix is tapped | -| `customChild` | `Widget?` | `null` | Replaces the text field | -| `translations` | `Map` | see below | Override i18n keys | -| `overridesLayrzTranslations` | `bool` | `false` | Force `translations` map | - -### Behavior notes - -- The dialog uses the same `ThemedCalendar` constrained to `maxWidth: 400, maxHeight: 400`. -- Selection is a two-tap flow: first tap sets the start date (highlighted); second tap sets the end date - and closes the dialog immediately. The result is always sorted — no need to sort on the receiving side. -- If the existing `value` is non-empty, the calendar pre-highlights the full current range until the user - taps to start a new selection. -- If `value.first` is a `TZDateTime`, the returned list is also converted to the same timezone. -- The assert `value.length == 0 || value.length == 2` is enforced at runtime — never pass a single-element list. - -### Common patterns - -```dart -// Form integration -ThemedDateRangePicker( - labelText: context.i18n.t('report.dateRange'), - value: report.dateRange, - errors: context.getErrors(key: 'dateRange'), - onChanged: (range) { - if (context.mounted) setState(() => report.dateRange = range); - }, -) - -// Clearing the range -ThemedDateRangePicker( - labelText: context.i18n.t('filter.period'), - value: filter.dateRange, - onChanged: (range) { - // range is always [start, end] — to clear, set state to [] - if (context.mounted) setState(() => filter.dateRange = range); - }, -) -``` - ---- - -## ThemedMonthPicker — single month - -### Minimal usage - -```dart -// State -ThemedMonth? selectedMonth; - -// Widget -ThemedMonthPicker( - labelText: context.i18n.t('entity.month'), - value: selectedMonth, - onChanged: (month) { - if (context.mounted) setState(() => selectedMonth = month); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided | -| `value` | `ThemedMonth?` | `null` | Currently selected month | -| `onChanged` | `void Function(ThemedMonth)?` | `null` | Called with the picked month | -| `minimum` | `ThemedMonth?` | `null` | Months before this are greyed out and non-tappable | -| `maximum` | `ThemedMonth?` | `null` | Months after this are greyed out and non-tappable | -| `disabledMonths` | `List` | `[]` | Specific months to disable individually | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation error messages | -| `hideDetails` | `bool` | `false` | Hides the errors / hints row | -| `padding` | `EdgeInsets?` | `null` | Outer padding | -| `placeholder` | `String?` | `null` | Hint text when no month is selected | -| `prefixText` | `String?` | `null` | Static text before the input | -| `prefixIcon` | `IconData?` | `null` | Icon before the field. Mutually exclusive with `prefixWidget`. | -| `prefixWidget` | `Widget?` | `null` | Widget before the field. Mutually exclusive with `prefixIcon`. | -| `onPrefixTap` | `VoidCallback?` | `null` | Called when prefix is tapped | -| `customChild` | `Widget?` | `null` | Replaces the text field | -| `translations` | `Map` | see below | Override i18n keys | -| `overridesLayrzTranslations` | `bool` | `false` | Force `translations` map | - -### ThemedMonth struct - -```dart -ThemedMonth( - month: Month.january, // Month enum: january … december (index 0–11) - year: 2024, -) -``` - -`Month` is an enum with values `january` through `december`. Use `Month.values[index]` to convert from a -0-based integer. - -### Behavior notes - -- The dialog renders a responsive 4-column month grid (2 columns on small screens, 3 on medium). -- The header shows the focused year with prev/next arrow buttons — the user navigates years without closing. -- Tapping a month closes the dialog immediately and calls `onChanged`. -- Cancel and Save buttons are shown at the bottom; Save without selecting does nothing (returns `null`). -- The dialog is constrained to `maxWidth: 500, maxHeight: 600`. -- `minimum` and `maximum` enforce bounds by year+month comparison; `disabledMonths` disables exact entries. - -### Common patterns - -```dart -// With min/max bounds -ThemedMonthPicker( - labelText: context.i18n.t('invoice.billingMonth'), - value: invoice.billingMonth, - minimum: ThemedMonth(month: Month.january, year: 2020), - maximum: ThemedMonth(month: Month.december, year: DateTime.now().year), - errors: context.getErrors(key: 'billingMonth'), - onChanged: (month) { - if (context.mounted) setState(() => invoice.billingMonth = month); - }, -) - -// Disable specific months -ThemedMonthPicker( - labelText: context.i18n.t('schedule.month'), - value: schedule.month, - disabledMonths: closedMonths, // List - onChanged: (month) { - if (context.mounted) setState(() => schedule.month = month); - }, -) -``` - ---- - -## ThemedMonthRangePicker — month range - -### Minimal usage - -```dart -// State -List monthRange = []; - -// Widget -ThemedMonthRangePicker( - labelText: context.i18n.t('entity.monthRange'), - value: monthRange, - onChanged: (range) { - if (context.mounted) setState(() => monthRange = range); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided | -| `value` | `List` | `[]` | Currently selected months | -| `onChanged` | `void Function(List)?` | `null` | Returns sorted list of selected months | -| `consecutive` | `bool` | `false` | Selection mode — see behavior notes | -| `minimum` | `ThemedMonth?` | `null` | Months before this are disabled | -| `maximum` | `ThemedMonth?` | `null` | Months after this are disabled | -| `disabledMonths` | `List` | `[]` | Specific months to disable (only in non-consecutive mode) | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation error messages | -| `hideDetails` | `bool` | `false` | Hides the errors / hints row | -| `padding` | `EdgeInsets?` | `null` | Outer padding | -| `placeholder` | `String?` | `null` | Hint text when empty | -| `prefixText` | `String?` | `null` | Static text before the input | -| `prefixIcon` | `IconData?` | `null` | Icon before the field. Mutually exclusive with `prefixWidget`. | -| `prefixWidget` | `Widget?` | `null` | Widget before the field. Mutually exclusive with `prefixIcon`. | -| `onPrefixTap` | `VoidCallback?` | `null` | Called when prefix is tapped | -| `customChild` | `Widget?` | `null` | Replaces the text field | -| `translations` | `Map` | see below | Override i18n keys (includes `actions.reset`) | -| `overridesLayrzTranslations` | `bool` | `false` | Force `translations` map | - -### Behavior notes - -- **`consecutive: false` (default):** each tap toggles the individual month on/off. Any combination is valid. - `disabledMonths` works in this mode. -- **`consecutive: true`:** first tap sets the anchor; second tap fills in all months between anchor and the - tapped month, inclusive. Only months at the start or end of the current selection can be re-tapped to change - bounds. `disabledMonths` has no effect in this mode. -- The dialog has three footer buttons: **Cancel** (discards, returns `null`), **Reset** (clears selection in - place without closing), and **Save** (confirms and calls `onChanged`). -- The result passed to `onChanged` is always sorted ascending by year then month. -- If Save is pressed while a consecutive first-pick is pending (anchor set, no end yet), the range resolves to - a single-month list containing only the anchor. -- Dialog constrained to `maxWidth: 500, maxHeight: 600`. Responsive grid: 2/3/4 columns by breakpoint. - -### Common patterns - -```dart -// Arbitrary month toggle (non-consecutive) -ThemedMonthRangePicker( - labelText: context.i18n.t('report.months'), - value: report.selectedMonths, - errors: context.getErrors(key: 'selectedMonths'), - onChanged: (months) { - if (context.mounted) setState(() => report.selectedMonths = months); - }, -) - -// Consecutive range with bounds -ThemedMonthRangePicker( - labelText: context.i18n.t('contract.period'), - value: contract.months, - consecutive: true, - minimum: ThemedMonth(month: Month.january, year: 2022), - maximum: ThemedMonth(month: Month.december, year: DateTime.now().year), - errors: context.getErrors(key: 'months'), - onChanged: (months) { - if (context.mounted) setState(() => contract.months = months); - }, -) -``` - ---- - -## Translation keys - -All four widgets use `LayrzAppLocalizations` by default. Pass a `translations` map only when localizations are -absent or you need to override specific strings. Keys and their English fallbacks: - -| Key | English fallback | Used by | -|---|---|---| -| `actions.cancel` | `'Cancel'` | All four | -| `actions.save` | `'Save'` | All four | -| `actions.reset` | `'Reset'` | `ThemedMonthRangePicker` only | -| `layrz.monthPicker.year` | `'Year {year}'` | All four (supports `{year}` interpolation) | -| `layrz.monthPicker.back` | `'Previous year'` | All four | -| `layrz.monthPicker.next` | `'Next year'` | All four | - -Set `overridesLayrzTranslations: true` to force the `translations` map even when `LayrzAppLocalizations` is -available in context. - ---- - -## Integrating with layrz forms - -```dart -// Single date -ThemedDatePicker( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, - errors: context.getErrors(key: 'fieldName'), - onChanged: (date) { - object.fieldName = date; - if (context.mounted) onChanged.call(); - }, -) - -// Date range -ThemedDateRangePicker( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, // List — empty or [start, end] - errors: context.getErrors(key: 'fieldName'), - onChanged: (range) { - object.fieldName = range; - if (context.mounted) onChanged.call(); - }, -) - -// Single month -ThemedMonthPicker( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, - errors: context.getErrors(key: 'fieldName'), - onChanged: (month) { - object.fieldName = month; - if (context.mounted) onChanged.call(); - }, -) - -// Month range -ThemedMonthRangePicker( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, // List - errors: context.getErrors(key: 'fieldName'), - onChanged: (months) { - object.fieldName = months; - if (context.mounted) onChanged.call(); - }, -) -``` - -Always guard `onChanged` with `if (context.mounted)` before calling the parent callback — the dialog is async -and the widget may have been unmounted by the time it resolves. - ---- - -## Choosing between the four - -| Need | Use | -|---|---| -| Pick a single calendar day | `ThemedDatePicker` | -| Pick a start date and an end date (two days) | `ThemedDateRangePicker` | -| Pick a month and year (no specific day) | `ThemedMonthPicker` | -| Pick one or many months, or a month span | `ThemedMonthRangePicker` | -| Pick an unordered set of months | `ThemedMonthRangePicker` with `consecutive: false` | -| Pick a contiguous block of months | `ThemedMonthRangePicker` with `consecutive: true` | - -Never use raw Flutter `showDatePicker`, `showDateRangePicker`, or any Material date dialog directly. Always use -the Layrz-themed components above. diff --git a/.claude-plugin/skills/datetime-pickers/SKILL.md b/.claude-plugin/skills/datetime-pickers/SKILL.md deleted file mode 100644 index a134ca2..0000000 --- a/.claude-plugin/skills/datetime-pickers/SKILL.md +++ /dev/null @@ -1,389 +0,0 @@ ---- -name: datetime-pickers -description: Use ThemedDateTimePicker, ThemedDateTimeRangePicker, or ThemedDateTimeSteppedPicker in a layrz - Flutter widget. Apply when adding a combined date-and-time selection field. ---- - -## Overview - -Three components cover all combined date+time selection needs: - -| Component | State type | UX style | When to prefer it | -|---|---|---|---| -| `ThemedDateTimePicker` | `DateTime?` | Single tabbed dialog (Date tab / Time tab) | General purpose; user can switch freely between date and time | -| `ThemedDateTimeSteppedPicker` | `DateTime?` | Two sequential dialogs (calendar, then time) | When date and time feel like separate decisions; cleaner on mobile | -| `ThemedDateTimeRangePicker` | `List` | Single tabbed dialog with start/end time pickers | When the field represents a time interval (start → end) | - -All three render as a read-only `ThemedTextInput` with a calendar icon suffix. Tapping opens the picker dialog. -`TZDateTime` (from `timezone` package) is preserved: when the incoming `value` is a `TZDateTime`, the result -returned by `onChanged` is also a `TZDateTime` using the same `Location`. - ---- - -## Pattern composition — datePattern + patternSeparator + timePattern - -The displayed text in the field is built as: - -``` -$datePattern$patternSeparator$timePattern -``` - -| Parameter | Default | Notes | -|---|---|---| -| `datePattern` | `'%Y-%m-%d'` | strftime-style pattern for the date portion | -| `patternSeparator` | `' '` (space) | Placed between date and time tokens | -| `timePattern` | auto | When `null`: `'%I:%M %p'` (12h) or `'%H:%M'` (24h) based on `use24HourFormat` | -| `use24HourFormat` | `false` | Ignored when an explicit `timePattern` is provided | - -Examples: - -```dart -// Result: "2024-06-15 02:30 PM" (defaults) -ThemedDateTimePicker(value: dt, ...) - -// Result: "15/06/2024 — 14:30" -ThemedDateTimePicker( - datePattern: '%d/%m/%Y', - patternSeparator: ' — ', - timePattern: '%H:%M', - ... -) - -// Result: "2024-06-15 14:30" (24h auto) -ThemedDateTimePicker( - use24HourFormat: true, - ... -) -``` - ---- - -## ThemedDateTimePicker — tabbed dialog - -### Minimal usage - -```dart -// State -DateTime? scheduledAt; - -// Widget -ThemedDateTimePicker( - labelText: context.i18n.t('entity.scheduledAt'), - value: scheduledAt, - onChanged: (dt) { - scheduledAt = dt; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Mutually exclusive with `label`. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Mutually exclusive with `labelText`. | -| `value` | `DateTime?` | `null` | Currently selected date+time. Accepts `TZDateTime`. | -| `onChanged` | `void Function(DateTime)?` | `null` | Returns a plain `DateTime` or `TZDateTime` (timezone preserved). | -| `disabled` | `bool` | `false` | Disables the field; tap does nothing. | -| `errors` | `List` | `[]` | Validation error messages shown below the field. | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row. | -| `datePattern` | `String` | `'%Y-%m-%d'` | Display format for the date portion. | -| `timePattern` | `String?` | `null` | Display format for the time portion. Overrides `use24HourFormat`. | -| `use24HourFormat` | `bool` | `false` | Toggles 12h/24h when `timePattern` is null. | -| `patternSeparator` | `String` | `' '` | Separator between date and time in the displayed text. | -| `disabledDays` | `List` | `[]` | Days blocked from selection in the calendar. | -| `placeholder` | `String?` | `null` | Hint shown when `value` is null. | -| `prefixIcon` | `IconData?` | `null` | Icon at the start of the field. Mutually exclusive with `prefixWidget`. | -| `prefixWidget` | `Widget?` | `null` | Widget at the start of the field. Mutually exclusive with `prefixIcon`. | -| `prefixText` | `String?` | `null` | Text prefix inside the field. | -| `onPrefixTap` | `VoidCallback?` | `null` | Callback when the prefix is tapped. | -| `customChild` | `Widget?` | `null` | Replaces the text field with a custom widget that acts as the tap target. | -| `hoverColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set. | -| `focusColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set. | -| `splashColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set. | -| `highlightColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set. | -| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Only applies when `customChild` is set. | -| `padding` | `EdgeInsets?` | `null` | Outer padding around the input. | -| `translations` | `Map` | see Translation keys section | Fallback strings when `LayrzAppLocalizations` is absent. | -| `overridesLayrzTranslations` | `bool` | `false` | Forces use of `translations` map even when `LayrzAppLocalizations` is present. | - -### Behavior notes - -- The dialog shows two tabs: **Date** (calendar) and **Time** (drum/spinner pickers for hours and minutes). -- The user can switch between tabs freely before confirming. Both values are saved together on Save. -- If `value` is null, the dialog opens with today's date and current time pre-selected. -- Saving calls `onChanged` only when both a date and a time are set (both are always set when opening the dialog, - so save always fires). -- The tab controller resets to the Date tab after saving. - -### Common patterns - -```dart -// 24-hour format, European date -ThemedDateTimePicker( - labelText: context.i18n.t('event.startsAt'), - value: event.startsAt, - use24HourFormat: true, - datePattern: '%d/%m/%Y', - errors: context.getErrors(key: 'startsAt'), - onChanged: (dt) { - event.startsAt = dt; - if (context.mounted) onChanged.call(); - }, -) - -// Block past days -ThemedDateTimePicker( - labelText: context.i18n.t('task.dueAt'), - value: task.dueAt, - disabledDays: [ - for (int i = 1; i <= DateTime.now().day - 1; i++) - DateTime(DateTime.now().year, DateTime.now().month, i), - ], - onChanged: (dt) { - task.dueAt = dt; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## ThemedDateTimeSteppedPicker — sequential dialogs - -### Minimal usage - -```dart -// State -DateTime? appointmentAt; - -// Widget -ThemedDateTimeSteppedPicker( - labelText: context.i18n.t('entity.appointmentAt'), - value: appointmentAt, - onChanged: (dt) { - appointmentAt = dt; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -All parameters from `ThemedDateTimePicker` apply, plus: - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `disableTimePickerBlink` | `bool` | `false` | Suppresses the blinking cursor animation in the time picker spinner. | - -Parameters identical to `ThemedDateTimePicker` (same types, same defaults): `value`, `onChanged`, `labelText`, -`label`, `disabled`, `errors`, `hideDetails`, `datePattern`, `timePattern`, `use24HourFormat`, -`patternSeparator`, `disabledDays`, `placeholder`, `prefixIcon`, `prefixWidget`, `prefixText`, `onPrefixTap`, -`customChild`, `hoverColor`, `focusColor`, `splashColor`, `highlightColor`, `borderRadius`, `padding`, -`translations`, `overridesLayrzTranslations`. - -### Behavior notes - -- Dialog 1 is a calendar. Tapping any day immediately closes it and opens Dialog 2. -- Dialog 2 is the time picker. Confirming calls `onChanged`. Cancelling the time picker discards the whole - selection (no partial save). -- Unlike `ThemedDateTimePicker`, the date is committed when the day is tapped — not on a Save button. -- `TZDateTime` is NOT preserved in `ThemedDateTimeSteppedPicker` — the result is always a plain `DateTime`. - Use `ThemedDateTimePicker` when timezone preservation is required. -- Set `disableTimePickerBlink: true` in automated tests or when the blinking cursor causes visual noise in - screenshot comparisons. - -### Common patterns - -```dart -// Stepped picker with 24-hour time, blink disabled for tests -ThemedDateTimeSteppedPicker( - labelText: context.i18n.t('report.generatedAt'), - value: report.generatedAt, - use24HourFormat: true, - disableTimePickerBlink: true, - errors: context.getErrors(key: 'generatedAt'), - onChanged: (dt) { - report.generatedAt = dt; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## ThemedDateTimeRangePicker — start/end interval - -### Minimal usage - -```dart -// State — must be empty list OR exactly 2 elements (enforced by assert) -List period = []; - -// Widget -ThemedDateTimeRangePicker( - labelText: context.i18n.t('entity.period'), - value: period, - onChanged: (range) { - period = range; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Mutually exclusive with `label`. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Mutually exclusive with `labelText`. | -| `value` | `List` | `[]` | Must be empty or exactly length 2. Enforced by assert at construction time. | -| `onChanged` | `void Function(List)?` | `null` | Returns a sorted list of exactly 2 `DateTime` values: `[start, end]`. | -| `disabled` | `bool` | `false` | Disables the field. | -| `errors` | `List` | `[]` | Validation error messages shown below the field. | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row. | -| `datePattern` | `String` | `'%Y-%m-%d'` | Display format for the date portion of each bound. | -| `timePattern` | `String?` | `null` | Display format for the time portion. Overrides `use24HourFormat`. | -| `use24HourFormat` | `bool` | `false` | Toggles 12h/24h when `timePattern` is null. | -| `patternSeparator` | `String` | `' '` | Separator between date and time tokens in the displayed text. | -| `disabledDays` | `List` | `[]` | Days blocked from selection in the calendar. | -| `placeholder` | `String?` | `null` | Hint shown when `value` is empty. | -| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget`. | -| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon`. | -| `prefixText` | `String?` | `null` | Text prefix inside the field. | -| `onPrefixTap` | `VoidCallback?` | `null` | Callback when the prefix is tapped. | -| `customChild` | `Widget?` | `null` | Replaces the text field with a custom widget. | -| `hoverColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set. | -| `focusColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set. | -| `splashColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set. | -| `highlightColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set. | -| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Only applies when `customChild` is set. | -| `padding` | `EdgeInsets?` | `null` | Outer padding around the input. | -| `translations` | `Map` | see Translation keys section | Fallback strings. | -| `overridesLayrzTranslations` | `bool` | `false` | Forces `translations` map over `LayrzAppLocalizations`. | - -### Behavior notes - -- The displayed text joins both datetimes with ` - `: e.g. `"2024-06-01 08:00 AM - 2024-06-15 06:00 PM"`. -- The dialog has the same two-tab layout (Date / Time) as `ThemedDateTimePicker`, but the Time tab shows - **two** time pickers stacked: one for the start time, one for the end time. -- On the Date tab, tapping the first day sets `startDate` and highlights it; tapping a second day sets - `endDate` and fills the range highlight between the two. The user can re-tap to pick a new start. -- The result list is always sorted ascending before being returned: `[earlier, later]`. -- `TZDateTime` is preserved: when `value.first` is a `TZDateTime`, both `start` and `end` in the result use - the same `Location`. -- `value.length` must be 0 or 2 — passing a list of length 1 throws an assertion error. - -### Common patterns - -```dart -// Range picker, 24h, with validation errors -ThemedDateTimeRangePicker( - labelText: context.i18n.t('report.period'), - value: report.period, - use24HourFormat: true, - errors: context.getErrors(key: 'period'), - onChanged: (range) { - report.period = range; - if (context.mounted) onChanged.call(); - }, -) - -// Initialise from nullable start/end fields on a model -ThemedDateTimeRangePicker( - labelText: context.i18n.t('shift.interval'), - value: (shift.startAt != null && shift.endAt != null) ? [shift.startAt!, shift.endAt!] : [], - onChanged: (range) { - shift.startAt = range.first; - shift.endAt = range.last; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## Translation keys - -All three components share the same set of required translation keys. `LayrzAppLocalizations` provides them -automatically. Only supply `translations` when `LayrzAppLocalizations` is not in the widget tree, or set -`overridesLayrzTranslations: true` to force custom strings. - -| Key | Default (English) | Used in | -|---|---|---| -| `actions.cancel` | `'Cancel'` | Cancel button | -| `actions.save` | `'Save'` | Save button | -| `layrz.monthPicker.year` | `'Year {year}'` | Year label in month picker (use `{year}` placeholder) | -| `layrz.monthPicker.back` | `'Previous year'` | Year navigation | -| `layrz.monthPicker.next` | `'Next year'` | Year navigation | -| `layrz.datetimePicker.date` | `'Date'` | Date tab label | -| `layrz.datetimePicker.time` | `'Time'` | Time tab label | -| `layrz.timePicker.hours` | `'Hours'` | Hours label in time utility | -| `layrz.timePicker.minutes` | `'Minutes'` | Minutes label in time utility | -| `layrz.calendar.month.back` | `'Previous month'` | Calendar month navigation | -| `layrz.calendar.month.next` | `'Next month'` | Calendar month navigation | -| `layrz.calendar.today` | `'Today'` | Today button in calendar | -| `layrz.calendar.month` | `'View as month'` | Calendar view toggle | -| `layrz.calendar.pickMonth` | `'Pick a month'` | Month picker trigger | - ---- - -## Integrating with layrz forms - -```dart -// Single datetime -ThemedDateTimePicker( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, - errors: context.getErrors(key: 'fieldName'), - onChanged: (dt) { - object.fieldName = dt; - if (context.mounted) onChanged.call(); - }, -) - -// Stepped single datetime -ThemedDateTimeSteppedPicker( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, - errors: context.getErrors(key: 'fieldName'), - onChanged: (dt) { - object.fieldName = dt; - if (context.mounted) onChanged.call(); - }, -) - -// Range (store as List on the model) -ThemedDateTimeRangePicker( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, // List, empty or length 2 - errors: context.getErrors(key: 'fieldName'), - onChanged: (range) { - object.fieldName = range; - if (context.mounted) onChanged.call(); - }, -) -``` - -Rules: -- Always guard `onChanged` body with `if (context.mounted)` before calling external callbacks. -- Use `context.i18n.t('entity.fieldName')` for `labelText`. -- Use `context.getErrors(key: 'fieldName')` for `errors`. -- `label` and `labelText` are mutually exclusive — the constructor asserts exactly one is set. -- Never use raw Material pickers (`showDatePicker`, `showTimePicker`) — always use these components. - ---- - -## Choosing between the three - -| Scenario | Use | -|---|---| -| User picks a single datetime, may want to tweak date and time independently | `ThemedDateTimePicker` | -| User picks a single datetime, date and time feel like two separate decisions | `ThemedDateTimeSteppedPicker` | -| User picks a start datetime AND an end datetime | `ThemedDateTimeRangePicker` | -| `TZDateTime` timezone must be round-tripped through the picker | `ThemedDateTimePicker` or `ThemedDateTimeRangePicker` | -| You need to disable the time picker animation (e.g., for tests) | `ThemedDateTimeSteppedPicker` (has `disableTimePickerBlink`) | -| The field represents a reporting window, shift, or booking interval | `ThemedDateTimeRangePicker` | - -**Never** mix these widgets for the same field. Pick one and keep it consistent throughout the form. diff --git a/.claude-plugin/skills/file-media-pickers/SKILL.md b/.claude-plugin/skills/file-media-pickers/SKILL.md deleted file mode 100644 index e700e00..0000000 --- a/.claude-plugin/skills/file-media-pickers/SKILL.md +++ /dev/null @@ -1,377 +0,0 @@ ---- -name: file-media-pickers -description: Use ThemedAvatarPicker, ThemedFilePicker, or ThemedColorPicker in a layrz Flutter widget. Apply when adding an image upload, file upload, or color selection field. ---- - -## Overview - -| Component | State type | `onChanged` signature | When to use | -|---|---|---|---| -| `ThemedAvatarPicker` | `String?` (base64 data URI) | `void Function(String?)?` | Image-only upload stored as base64; displayed as a 100×100 avatar | -| `ThemedFilePicker` | `String?` (file name display) | `void Function(String, List)?` | Any file upload; caller receives both a base64 data URI and a raw byte array | -| `ThemedColorPicker` | `Color?` | `void Function(Color)?` | Color selection via a dialog powered by `flutter_colorpicker` | - -All three follow the same `label`/`labelText` exclusivity rule, `disabled`, `errors`, `hideDetails`, and `customChild` pattern. - ---- - -## ThemedAvatarPicker - -### Minimal usage - -```dart -// State -String? avatarValue; - -// Widget -ThemedAvatarPicker( - labelText: 'Avatar', - value: avatarValue, - onChanged: (v) { - avatarValue = v; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Exactly one must be set. | -| `value` | `String?` | `null` | Base64 data URI (`data:mime;base64,...`) or an HTTP URL | -| `onChanged` | `void Function(String?)?` | `null` | Receives the data URI string, or `null` when deleted | -| `disabled` | `bool` | `false` | Shows a lock icon; disables tapping | -| `errors` | `List` | `[]` | Validation messages rendered below the avatar | -| `hideDetails` | `bool` | `false` | Hides the errors row | -| `customChild` | `Widget?` | `null` | Replaces the 100×100 card with a custom widget (see customChild pattern) | -| `hoverColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `focusColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `splashColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `highlightColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Ink splash radius; only active when `customChild` is set | - -### Behavior notes - -- Renders a **100×100 container** with elevation. Empty state shows an upload icon (`solarOutlineUpload`); disabled state shows a lock icon (`solarOutlineLockKeyhole`). -- On tap, opens the native file picker filtered to `FileType.image`. The selected file is parsed via `compute(parseFileToBase64, file)` off the main thread and stored as `"data:;base64,"`. -- A **delete button** (top-right, red circle) appears with a fade-in animation (`AnimationController`, 300 ms) once a value is present. Tapping it calls `onChanged(null)` and fades the button out. -- `onChanged` receives `null` on delete; always null-guard when updating model fields. - -### Common patterns - -```dart -// Form field with validation -ThemedAvatarPicker( - labelText: context.i18n.t('user.avatar'), - value: user.avatarUrl, - errors: context.getErrors(key: 'avatarUrl'), - onChanged: (v) { - user.avatarUrl = v; - if (context.mounted) onChanged.call(); - }, -) - -// Disabled (view-only mode) -ThemedAvatarPicker( - labelText: context.i18n.t('user.avatar'), - value: user.avatarUrl, - disabled: true, -) -``` - ---- - -## ThemedFilePicker - -### Minimal usage - -```dart -// State -String? fileName; // display only -String? fileDataUri; // stored value -List fileBytes = []; - -// Widget -ThemedFilePicker( - labelText: 'Attachment', - value: fileName, - onChanged: (dataUri, bytes) { - fileDataUri = dataUri; - fileBytes = bytes; - fileName = dataUri.isEmpty ? null : 'file'; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided | -| `value` | `String?` | `null` | The file **name** shown in the text field (not the data URI) | -| `onChanged` | `void Function(String, List)?` | `null` | First arg: base64 data URI. Second arg: raw bytes. Both are `""` / `[]` when cleared. | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation messages | -| `hideDetails` | `bool` | `false` | Hides the errors row | -| `isRequired` | `bool` | `false` | Shows a required indicator | -| `acceptedTypes` | `FileType` | `FileType.any` | Filter applied to the native file picker | -| `allowedExtensions` | `List?` | `null` | Only used when `acceptedTypes == FileType.custom` | -| `padding` | `EdgeInsets?` | `null` | Outer padding passed to the inner `ThemedTextInput` | -| `customChild` | `Widget?` | `null` | Replaces the text input with a custom widget | -| `hoverColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `focusColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `splashColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `highlightColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Ink splash radius; only active when `customChild` is set | - -### Behavior notes - -- Renders as a `ThemedTextInput` (readonly). Prefix icon is always `solarOutlineFile`. Suffix icon is a **paperclip** (`solarOutlinePaperclip2`) when empty, and an **eraser** (`solarOutlineEraserSquare`) when a file is selected. -- **Tapping the eraser clears the field** — `onChanged("", [])` is called without reopening the picker. -- On pick, both `parseFileToBase64` and `parseFileToByteArray` run via `compute()` off the main thread. Both results are passed together in one `onChanged` call. -- `value` is the **file name** for display only. Store the data URI and/or bytes yourself in state. -- To restrict extensions: set `acceptedTypes: FileType.custom` and pass `allowedExtensions: ['pdf', 'docx']`. - -### Common patterns - -```dart -// PDF/Word only -ThemedFilePicker( - labelText: context.i18n.t('document.file'), - value: doc.fileName, - acceptedTypes: FileType.custom, - allowedExtensions: ['pdf', 'doc', 'docx'], - errors: context.getErrors(key: 'file'), - onChanged: (dataUri, bytes) { - doc.fileDataUri = dataUri; - doc.fileBytes = bytes; - if (context.mounted) onChanged.call(); - }, -) - -// Any file, required -ThemedFilePicker( - labelText: context.i18n.t('report.attachment'), - value: report.fileName, - isRequired: true, - onChanged: (dataUri, bytes) { - report.attachment = dataUri; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## ThemedColorPicker - -### Minimal usage - -```dart -// State -Color? selectedColor; - -// Widget -ThemedColorPicker( - labelText: 'Color', - value: selectedColor, - onChanged: (color) { - selectedColor = color; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided | -| `value` | `Color?` | `null` | Currently selected color. Falls back to `kPrimaryColor` internally if null. | -| `onChanged` | `void Function(Color)?` | `null` | Called with the confirmed color when the dialog is saved | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation messages | -| `hideDetails` | `bool` | `false` | Hides the errors row | -| `padding` | `EdgeInsets?` | `null` | Outer padding passed to the inner `ThemedTextInput` | -| `dense` | `bool` | `false` | Reduces vertical padding | -| `prefixIcon` | `IconData?` | `null` | Additional icon before the color box prefix widget | -| `onPrefixTap` | `VoidCallback?` | `null` | Callback when the prefix area is tapped | -| `placeholder` | `String?` | `null` | Placeholder text for the text field | -| `saveText` | `String` | `"OK"` | Label for the confirm button in the picker dialog | -| `cancelText` | `String` | `"Cancel"` | Label for the cancel button in the picker dialog | -| `enabledTypes` | `List` | `[ColorPickerType.both, ColorPickerType.wheel]` | Which picker tabs are shown in the dialog | -| `maxWidth` | `double` | `400` | Max width of the picker dialog | -| `customChild` | `Widget?` | `null` | Replaces the text input with a custom widget | -| `hoverColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `focusColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `splashColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `highlightColor` | `Color` | `Colors.transparent` | Only active when `customChild` is set | -| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Ink splash radius; only active when `customChild` is set | - -### Behavior notes - -- Renders as a readonly `ThemedTextInput` showing the hex value (e.g. `#FF8200`) and a small color box as the prefix widget. Suffix icon is always `solarOutlinePalette2`. -- Opens a `showDialog` containing a `flutter_colorpicker` `ColorPicker`. Shades selection, tonal palette, and opacity are **disabled**. Copy format is `numHexRRGGBB`. -- The dialog has its own Cancel / Save buttons (`ThemedButton.cancel` and `ThemedButton.save`). `onChanged` is only called when Save is tapped — cancelling leaves state unchanged. -- Available `ColorPickerType` values: `both`, `primary`, `accent`, `bw`, `custom`, `wheel`. Only types listed in `enabledTypes` are active. -- If `value` is `null`, the internal state initializes to `kPrimaryColor` (`#001e60`). - -### Common patterns - -```dart -// Wheel only (no material swatches) -ThemedColorPicker( - labelText: context.i18n.t('brand.primaryColor'), - value: brand.primaryColor, - enabledTypes: [ColorPickerType.wheel], - errors: context.getErrors(key: 'primaryColor'), - onChanged: (color) { - brand.primaryColor = color; - if (context.mounted) onChanged.call(); - }, -) - -// Localized dialog buttons -ThemedColorPicker( - labelText: context.i18n.t('asset.color'), - value: asset.color, - saveText: context.i18n.t('general.confirm'), - cancelText: context.i18n.t('general.cancel'), - onChanged: (color) { - asset.color = color; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## customChild pattern - -All three components support replacing their default UI with an arbitrary widget via `customChild`. When provided, the component wraps `customChild` in an `InkWell` and delegates taps to the picker logic. The default UI (avatar card, text input) is not rendered. - -Use `customChild` when the standard trigger widget doesn't fit the design — e.g., an icon button in a toolbar, a card tile, or a table cell. - -```dart -// Avatar picker triggered by a small icon button -ThemedAvatarPicker( - labelText: 'Photo', - value: user.photo, - customChild: IconButton( - icon: const Icon(Icons.camera_alt), - onPressed: null, // taps are handled by the wrapping InkWell - ), - splashColor: Theme.of(context).primaryColor.withOpacity(0.1), - hoverColor: Theme.of(context).primaryColor.withOpacity(0.05), - onChanged: (v) { - user.photo = v; - if (context.mounted) onChanged.call(); - }, -) - -// Color picker triggered by a colored container in a list tile -ThemedColorPicker( - labelText: 'Color', - value: item.color, - customChild: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: item.color ?? Colors.grey, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.black12), - ), - ), - borderRadius: BorderRadius.circular(8), - onChanged: (color) { - item.color = color; - if (context.mounted) onChanged.call(); - }, -) -``` - -**Rules for customChild:** -- `hoverColor`, `focusColor`, `splashColor`, `highlightColor`, and `borderRadius` only take effect when `customChild` is provided. -- Do not put `GestureDetector` or `InkWell` inside `customChild` — the wrapping `InkWell` handles all taps. -- `disabled: true` suppresses the tap even with `customChild`. - ---- - -## Integrating with layrz forms - -```dart -// Avatar picker -ThemedAvatarPicker( - labelText: context.i18n.t('entity.avatarUrl'), - value: object.avatarUrl, - errors: context.getErrors(key: 'avatarUrl'), - onChanged: (v) { - object.avatarUrl = v; - if (context.mounted) onChanged.call(); - }, -) - -// File picker — store data URI in the model, display name separately -ThemedFilePicker( - labelText: context.i18n.t('entity.attachment'), - value: object.attachmentName, - errors: context.getErrors(key: 'attachment'), - onChanged: (dataUri, bytes) { - object.attachment = dataUri; - object.attachmentName = dataUri.isEmpty ? null : object.attachmentName; - if (context.mounted) onChanged.call(); - }, -) - -// Color picker -ThemedColorPicker( - labelText: context.i18n.t('entity.color'), - value: object.color, - errors: context.getErrors(key: 'color'), - onChanged: (color) { - object.color = color; - if (context.mounted) onChanged.call(); - }, -) -``` - -Always guard `onChanged` with `if (context.mounted)`. Always use `context.i18n.t('entity.fieldName')` for `labelText`. Always pass `errors: context.getErrors(key: 'fieldName')` for validation. - ---- - -## Choosing between the three - -- Use `ThemedAvatarPicker` when the field stores a **profile image or logo**, displayed as a small square avatar. Output is a base64 data URI or URL. -- Use `ThemedFilePicker` when the field stores **any file** (document, spreadsheet, binary). Output is a base64 data URI plus raw bytes — store whichever the backend expects. -- Use `ThemedColorPicker` when the field stores a **`Color`** value — theme colors, brand colors, category colors. -- Never use raw `FilePicker`, `file_picker`, `showColorPickerDialog`, or `flutter_colorpicker` directly — always use these components. -- `ThemedAvatarPicker` is image-only (`FileType.image`). For non-image files that need a preview, use `ThemedFilePicker` with `acceptedTypes: FileType.image`. - ---- - -## Platform setup (suggest to dev — never apply automatically) - -When adding `ThemedFilePicker` or `ThemedAvatarPicker`, always warn the developer about the required platform permissions. Do NOT edit entitlement or manifest files yourself — show the exact snippet and let the dev decide. - -### macOS — file-access entitlement - -Without this, the picker throws `PlatformException(ENTITLEMENT_NOT_FOUND, ...)` at runtime. The macOS sandbox blocks all file-system access by default. - -Tell the developer: - -> Add the following entitlement to both `macos/Runner/DebugProfile.entitlements` and `macos/Runner/Release.entitlements` (inside ``): -> -> ```xml -> com.apple.security.files.user-selected.read-write -> -> ``` -> -> Use `read-only` if the app only reads files; `read-write` is the safe default for upload pickers. -> A full hot restart is required after this change — hot reload is not enough. - -Check both files first. If the key is already present, no action is needed — just confirm it to the dev. diff --git a/.claude-plugin/skills/icon-emoji-avatar-inputs/SKILL.md b/.claude-plugin/skills/icon-emoji-avatar-inputs/SKILL.md deleted file mode 100644 index 9287e9d..0000000 --- a/.claude-plugin/skills/icon-emoji-avatar-inputs/SKILL.md +++ /dev/null @@ -1,463 +0,0 @@ ---- -name: icon-emoji-avatar-inputs -description: > - Use ThemedIconPicker, ThemedEmojiPicker, ThemedDynamicAvatarInput, or ThemedDynamicCredentialsInput in a layrz - Flutter widget. Apply when adding an icon picker, emoji picker, dynamic avatar composer, or dynamic credentials form. ---- - -## Overview - -| Component | State type | `onChanged` signature | When to use | -|---|---|---|---| -| `ThemedIconPicker` | `LayrzIcon?` | `void Function(LayrzIcon)` | Let user pick a Layrz icon from the full icon set or a filtered subset | -| `ThemedEmojiPicker` | `String?` (single emoji char) | `void Function(String)` | Let user pick a single emoji from all groups or a filtered subset | -| `ThemedDynamicAvatarInput` | `AvatarInput?` | `void Function(AvatarInput?)` | Let user compose an avatar from url / base64 / icon / emoji — any combination of the four types | -| `ThemedDynamicCredentialsInput` | `Map` | `void Function(Map)` | Render a dynamic credentials form driven by a `List` schema | - -All four types come from `package:layrz_models/layrz_models.dart` (imported transitively through `layrz_theme`). - ---- - -## ThemedIconPicker - -### Minimal usage - -```dart -// State -LayrzIcon? _icon; - -// Widget -ThemedIconPicker( - labelText: context.i18n.t('entity.icon'), - value: _icon, - errors: context.getErrors(key: 'icon'), - onChanged: (icon) { - setState(() => _icon = icon); - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Mutually exclusive with `label`. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Mutually exclusive with `labelText`. | -| `value` | `LayrzIcon?` | `null` | Currently selected icon | -| `onChanged` | `void Function(LayrzIcon)?` | `null` | Called when user picks an icon and confirms | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation error messages shown below the field | -| `isRequired` | `bool` | `false` | Shows required indicator | -| `dense` | `bool` | `false` | Reduces vertical padding | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row | -| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | -| `focusNode` | `FocusNode?` | `null` | External focus node | -| `allowedIcons` | `List` | `[]` | When non-empty, restricts the picker to this subset only | -| `customChild` | `Widget?` | `null` | Replaces the text field trigger with a custom widget wrapped in `InkWell` | -| `hoverColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is provided | -| `focusColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is provided | -| `splashColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is provided | -| `highlightColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is provided | -| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Only applies when `customChild` is provided | -| `translations` | `Map` | cancel/save/search defaults | Fallback strings when `LayrzAppLocalizations` is absent | -| `overridesLayrzTranslations` | `bool` | `false` | Forces `translations` map over the app i18n even when i18n is present | - -### Behavior notes - -- The dialog opens at up to 500 × 700 logical pixels. -- The icon list is the full `iconMapping` from `layrz_icons`, sorted alphabetically by name. When `allowedIcons` is non-empty it is used as a whitelist filter. -- Search filters by `LayrzIcon.name.toLowerCase().contains(query.toLowerCase())` — not by icon code. -- On open the list auto-scrolls to the currently selected icon (`itemExtent` = 50 px per row). -- `onChanged` is only called when the user taps an icon (immediate close) — no explicit Save needed from the user's perspective; the confirm flow is internal. - -### Common patterns - -```dart -// Restrict to a project-defined subset -ThemedIconPicker( - labelText: context.i18n.t('asset.markerIcon'), - value: _icon, - allowedIcons: kAllowedMarkerIcons, // List - errors: context.getErrors(key: 'markerIcon'), - onChanged: (icon) { - setState(() => _icon = icon); - if (context.mounted) onChanged.call(); - }, -) - -// Custom trigger (e.g. an avatar card) -ThemedIconPicker( - labelText: context.i18n.t('entity.icon'), - value: _icon, - onChanged: (icon) { - setState(() => _icon = icon); - if (context.mounted) onChanged.call(); - }, - customChild: ThemedAvatar(icon: _icon?.iconData, size: 48), -) -``` - ---- - -## ThemedEmojiPicker - -### Minimal usage - -```dart -// State -String? _emoji; - -// Widget -ThemedEmojiPicker( - labelText: context.i18n.t('entity.emoji'), - value: _emoji, - errors: context.getErrors(key: 'emoji'), - onChanged: (emoji) { - setState(() => _emoji = emoji); - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Mutually exclusive with `label`. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Mutually exclusive with `labelText`. | -| `value` | `String?` | `null` | The currently selected emoji character (e.g. `"😀"`) | -| `onChanged` | `void Function(String)?` | `null` | Receives the emoji char when user picks and confirms | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation error messages | -| `isRequired` | `bool` | `false` | Shows required indicator | -| `dense` | `bool` | `false` | Reduces vertical padding | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row | -| `readonly` | `bool` | `false` | Prevents opening the picker dialog | -| `padding` | `EdgeInsets?` | `null` | Outer padding | -| `focusNode` | `FocusNode?` | `null` | External focus node | -| `onSubmitted` | `VoidCallback?` | `null` | Called on keyboard submit action | -| `maxLines` | `int` | `1` | Not typically changed; governs underlying text field lines | -| `buttomSize` | `double?` | `null` | Controls the grid button size in the picker; `null` = auto | -| `enabledGroups` | `List` | `[]` | When non-empty, only these emoji groups appear in the picker; empty = all groups | -| `customChild` | `Widget?` | `null` | Replaces the text field trigger with a custom widget | -| `hoverColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is provided | -| `focusColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is provided | -| `splashColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is provided | -| `highlightColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is provided | -| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Only applies when `customChild` is provided | -| `translations` | `Map` | cancel/save/search defaults | Fallback strings when `LayrzAppLocalizations` is absent | -| `overridesLayrzTranslations` | `bool` | `false` | Forces `translations` map over the app i18n | - -### Behavior notes - -- Dialog opens at up to 500 × 700 logical pixels. -- Groups are shown as a horizontal scrollable chip row above the grid. Selecting a group filters the grid. -- Search filters by `emoji.shortName.contains(query)` — case-sensitive; queries are not lowercased. -- Picking an emoji closes the dialog immediately (no explicit Save step from the user's POV). -- `value` is a raw emoji character string, not a code point or short name. Use `Emoji.byChar(value)` from the `emojis` package if you need metadata. -- `buttomSize` (note: this is the actual parameter name in the API — not a typo you should correct) controls both button width and height in the grid. - -### Common patterns - -```dart -// Restrict to nature + food emoji groups -ThemedEmojiPicker( - labelText: context.i18n.t('entity.emoji'), - value: _emoji, - enabledGroups: [EmojiGroup.animalsAndNature, EmojiGroup.foodAndDrink], - onChanged: (emoji) { - setState(() => _emoji = emoji); - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## ThemedDynamicAvatarInput - -### Minimal usage - -```dart -// State -AvatarInput? _avatar; - -// Widget -ThemedDynamicAvatarInput( - labelText: context.i18n.t('entity.avatar'), - value: _avatar, - errors: context.getErrors(key: 'avatar'), - onChanged: (avatar) { - setState(() => _avatar = avatar); - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Mutually exclusive with `label`. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Mutually exclusive with `labelText`. | -| `value` | `AvatarInput?` | `null` | The current avatar. `null` is treated as `AvatarInput()` (no avatar / type none). | -| `onChanged` | `void Function(AvatarInput?)?` | `null` | Receives the updated avatar, or `null` when none is selected | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation error messages | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row | -| `padding` | `EdgeInsets?` | `null` | Outer padding | -| `enabledTypes` | `List` | `[url, base64, icon, emoji]` | Which tabs appear in the dialog. `AvatarType.none` is always prepended automatically. | -| `heightFactor` | `double` | `0.7` | Dialog height as a fraction of screen height (not currently applied to the fixed constraints; reserved for future use) | -| `maxHeight` | `double` | `350` | Maximum dialog height in logical pixels (not currently applied to fixed constraints; reserved) | - -### AvatarInput — structure (from `layrz_models`) - -```dart -// Simplified view of the relevant fields: -class AvatarInput { - AvatarType type; // none | url | base64 | icon | emoji - String? url; // for type == url - String? base64; // for type == base64 (data URI or raw base64) - LayrzIcon? icon; // for type == icon - String? emoji; // for type == emoji (single char) -} -``` - -Only one of `url`, `base64`, `icon`, `emoji` is non-null at a time — the dialog clears the others on each tab selection. - -### AvatarType values - -| Value | Tab label (i18n key suffix) | Content | -|---|---|---| -| `AvatarType.none` | `helpers.dynamicAvatar.types.none` | Always present; shows an explanatory hint, no input | -| `AvatarType.url` | `helpers.dynamicAvatar.types.URL` | Text input for an image URL | -| `AvatarType.base64` | `helpers.dynamicAvatar.types.BASE64` | `ThemedAvatarPicker` (file pick → base64) | -| `AvatarType.icon` | `helpers.dynamicAvatar.types.icon` | Searchable icon grid (same as `ThemedIconPicker`) | -| `AvatarType.emoji` | `helpers.dynamicAvatar.types.emoji` | Group-filtered emoji grid (same as `ThemedEmojiPicker`) | - -### Behavior notes - -- The dialog uses `ThemedTabView` with one tab per `enabledTypes` entry (plus the auto-prepended `none` tab). -- The initial tab is determined by matching `value.type` inside `enabledTypes`. -- `onChanged` is fired inline (not on dialog close) — each sub-picker calls it immediately when the user picks a value. There is no Cancel/confirm flow at the dialog level. -- The preview `ThemedAvatar` shown in the text field reflects the current `AvatarInput` state in real time. - -### Common patterns - -```dart -// Icon or emoji only — no image upload -ThemedDynamicAvatarInput( - labelText: context.i18n.t('layer.avatar'), - value: _avatar, - enabledTypes: [AvatarType.icon, AvatarType.emoji], - onChanged: (avatar) { - setState(() => _avatar = avatar); - if (context.mounted) onChanged.call(); - }, -) - -// Full avatar with all types -ThemedDynamicAvatarInput( - labelText: context.i18n.t('asset.avatar'), - value: _avatar, - errors: context.getErrors(key: 'avatar'), - onChanged: (avatar) { - setState(() => _avatar = avatar); - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## ThemedDynamicCredentialsInput - -This widget is fundamentally different from the other three. It is NOT a single-field picker — it renders an entire `ResponsiveRow` of inputs driven by a `List` schema. It does NOT use `labelText` or `label`. - -### Minimal usage - -```dart -// State — the full credentials map (key → value) -Map _credentials = {}; - -// Widget -ThemedDynamicCredentialsInput( - value: _credentials, - fields: protocol.credentialFields, // List - translatePrefix: 'inboundProtocols.myProtocol', - isEditing: isEditing, - errors: context.getErrors(key: 'credentials') as Map? ?? {}, - onChanged: (creds) { - setState(() => _credentials = creds); - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `value` | `Map` | required | The current credentials map. Keys are field names from `CredentialField.field`. | -| `fields` | `List` | required | The schema that drives which inputs are rendered | -| `onChanged` | `void Function(Map)?` | `null` | Receives the full updated credentials map on any field change | -| `errors` | `Map` | `{}` | Error map — NOT `List`. Keys are credential field paths. | -| `translatePrefix` | `String` | `''` | i18n key prefix. Each field label is looked up as `'$translatePrefix.${field.field}.title'` | -| `isEditing` | `bool` | `true` | When `false`, all inputs are rendered in `disabled` mode | -| `layrzGeneratedToken` | `String?` | `null` | When set, displayed in a read-only field for `CredentialFieldType.layrzApiToken` fields, with a copy-to-clipboard suffix button | -| `nested` | `String?` | `null` | Parent field name when this widget is rendered recursively for `CredentialFieldType.nestedField`. Sets the error key path prefix. | -| `actionCallback` | `void Function(CredentialFieldAction)?` | `null` | Called for fields with special actions (e.g. `CredentialFieldAction.wialonOAuth` for `CredentialFieldType.wialonToken`) | -| `isLoading` | `bool` | `false` | When `true`, shows a lock icon on action fields instead of the refresh icon | - -### CredentialField — structure (from `layrz_models`) - -```dart -class CredentialField { - final String field; // map key in credentials - final CredentialFieldType type; // drives which input widget is rendered - final List? choices; // required when type == choices - final List? requiredFields; // required when type == nestedField - final String? onlyField; // conditional display: show only when credentials[onlyField] is in onlyChoices - final List? onlyChoices; // values of onlyField that make this field visible -} -``` - -### CredentialFieldType — all supported values - -| Value | Rendered as | Notes | -|---|---|---| -| `string` | `ThemedTextInput` | Plain text | -| `soapUrl` | `ThemedTextInput` | Plain text (URL for SOAP endpoints) | -| `restUrl` | `ThemedTextInput` | Plain text (URL for REST endpoints) | -| `ftp` | `ThemedTextInput` | Plain text (FTP address) | -| `dir` | `ThemedTextInput` | Plain text (directory path) | -| `integer` | `ThemedNumberInput` | Value stored as `int` | -| `float` | `ThemedNumberInput` | Value stored as `double` | -| `choices` | `ThemedSelectInput` | Requires `field.choices` to be non-null. Item labels are `'$translatePrefix.${field.field}.$choice'` | -| `layrzApiToken` | Read-only `ThemedTextInput` | Shows `layrzGeneratedToken` or a placeholder. Has copy-to-clipboard suffix when token is present. | -| `nestedField` | Recursive `ThemedDynamicCredentialsInput` | Requires `field.requiredFields`. Passes `nested: field.field` and adjusted `translatePrefix`. | -| `wialonToken` | Read-only `ThemedTextInput` with action suffix | Calls `actionCallback(CredentialFieldAction.wialonOAuth)` on suffix tap. Shows lock icon when `isLoading`. | - -### Error map wiring - -`errors` is `Map`, not `List`. It mirrors the Layrz API error response shape. -Internally, each field calls `context.getErrors(key: path)` with the appropriate key: - -- Flat field: `'credentials.${field.field}'` -- Nested field: `'credentials.${widget.nested}.${field.field}'` - -Pass the full API error response's `credentials` subtree (or the whole error map) so `context.getErrors` can resolve nested paths correctly. - -```dart -// Example: API returns { "credentials": { "host": ["is required"] } } -// Pass the whole error map — getErrors navigates the path internally. -errors: apiErrors, // Map -``` - -### Conditional field visibility - -Fields with `onlyField` and `onlyChoices` set are shown only when `credentials[field.onlyField]` is contained in `field.onlyChoices`. The widget evaluates this on every build — no explicit state needed. - -### Common patterns - -```dart -// Standard credentials form for an inbound protocol -ThemedDynamicCredentialsInput( - value: entity.credentials, - fields: selectedProtocol.credentialFields, - translatePrefix: 'inboundProtocols.${selectedProtocol.identifier}', - isEditing: isEditing, - layrzGeneratedToken: entity.layrzToken, - errors: store.errors, - actionCallback: (action) async { - if (action == CredentialFieldAction.wialonOAuth) { - await _launchWialonOAuth(); - } - }, - onChanged: (creds) { - entity.credentials = creds; - if (context.mounted) onChanged.call(); - }, -) - -// Read-only view (detail screen) -ThemedDynamicCredentialsInput( - value: entity.credentials, - fields: protocol.credentialFields, - translatePrefix: 'inboundProtocols.${protocol.identifier}', - isEditing: false, - errors: const {}, -) -``` - ---- - -## Integrating with layrz forms - -```dart -// ThemedIconPicker — store the LayrzIcon directly -ThemedIconPicker( - labelText: context.i18n.t('entity.icon'), - value: entity.icon, - errors: context.getErrors(key: 'icon'), - onChanged: (icon) { - entity.icon = icon; - if (context.mounted) onChanged.call(); - }, -) - -// ThemedEmojiPicker — store the emoji char string -ThemedEmojiPicker( - labelText: context.i18n.t('entity.emoji'), - value: entity.emoji, - errors: context.getErrors(key: 'emoji'), - onChanged: (emoji) { - entity.emoji = emoji; - if (context.mounted) onChanged.call(); - }, -) - -// ThemedDynamicAvatarInput — store the AvatarInput object -ThemedDynamicAvatarInput( - labelText: context.i18n.t('entity.avatar'), - value: entity.avatar, - errors: context.getErrors(key: 'avatar'), - onChanged: (avatar) { - entity.avatar = avatar; - if (context.mounted) onChanged.call(); - }, -) - -// ThemedDynamicCredentialsInput — NO label; wire directly to the credentials map -ThemedDynamicCredentialsInput( - value: entity.credentials, - fields: selectedProtocol.credentialFields, - translatePrefix: 'protocols.${selectedProtocol.identifier}', - isEditing: isEditing, - errors: store.errors, - onChanged: (creds) { - entity.credentials = creds; - if (context.mounted) onChanged.call(); - }, -) -``` - -Conventions: -- Always guard `onChanged` body with `if (context.mounted)` before calling the parent callback. -- Use `context.i18n.t('entity.fieldName')` for `labelText`. -- Use `context.getErrors(key: 'fieldName')` for `errors` on the three standard pickers. -- For `ThemedDynamicCredentialsInput`, pass the raw API error map to `errors` — `context.getErrors` is called internally per field. -- `label` and `labelText` are mutually exclusive — the constructor asserts this. -- Never use raw Material widgets (`DropdownButton`, `TextField`, etc.) in layrz forms. - ---- - -## Choosing between the four - -- **`ThemedIconPicker`** — user picks one icon from the Layrz icon set (Solar icons). Value type: `LayrzIcon?`. The canonical choice when you need a single icon selection field. -- **`ThemedEmojiPicker`** — user picks one standard Unicode emoji. Value type: `String?` (the emoji character). Use when entities allow emoji labeling. -- **`ThemedDynamicAvatarInput`** — user composes an avatar that can be one of four types (URL, file upload, icon, or emoji). Value type: `AvatarInput?`. Use when entities need a flexible visual identity (asset, layer, user profile, etc.) and you want to support all or a subset of avatar types in one field. -- **`ThemedDynamicCredentialsInput`** — renders a schema-driven credentials form for protocol integrations. Value type: `Map`. Has no label param. Is NOT a single field — it outputs a full row of inputs. Use exclusively for Layrz API entities that carry a `credentials` map (e.g. `InboundProtocol`, `OutboundProtocol`). - -Do NOT use `ThemedIconPicker` or `ThemedEmojiPicker` inside forms where `ThemedDynamicAvatarInput` is already present — the avatar input has built-in icon and emoji tabs. Compose them separately only when the entity tracks icon/emoji independently of an `AvatarInput` field. diff --git a/.claude-plugin/skills/number-duration-inputs/SKILL.md b/.claude-plugin/skills/number-duration-inputs/SKILL.md deleted file mode 100644 index 47b4628..0000000 --- a/.claude-plugin/skills/number-duration-inputs/SKILL.md +++ /dev/null @@ -1,424 +0,0 @@ ---- -name: number-duration-inputs -description: Use ThemedNumberInput or ThemedDurationInput in a layrz Flutter widget. Apply when adding a numeric field with step/min/max or a duration picker to any form or view. ---- - -## Overview - -| Component | State type | `onChanged` signature | When to use | -|---|---|---|---| -| `ThemedNumberInput` | `num?` | `void Function(num?)?` | Numeric fields: integers, decimals, quantities, coordinates | -| `ThemedDurationInput` | `Duration?` | `Function(Duration?)?` | Time span fields: timeouts, intervals, elapsed time | - -`ThemedNumberInput` renders a text field with minus (prefix) and plus (suffix) icon buttons for increment/decrement. The buttons show a **visual disabled state (0.4 opacity)** when the value reaches `minimum` or `maximum` — implemented via `prefixIconDisabled`/`suffixIconDisabled` on the internal `ThemedTextInput`. `ThemedDurationInput` renders a readonly text field that opens a dialog with one `ThemedNumberInput` per visible time unit. - ---- - -## ThemedNumberInput - -### Minimal usage - -```dart -// State -num? speed; - -// Widget -ThemedNumberInput( - labelText: context.i18n.t('entity.speed'), - value: speed, - onChanged: (value) { - speed = value; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Exactly one must be set. | -| `value` | `num?` | `null` | Current numeric value | -| `onChanged` | `void Function(num?)?` | `null` | Called with `null` when field is cleared | -| `minimum` | `num?` | `null` | Disables decrement button visually at boundary; does **not** block typed input | -| `maximum` | `num?` | `null` | Disables increment button visually at boundary; does **not** block typed input | -| `step` | `num?` | `1` | Amount added/subtracted by the ± buttons | -| `maximumDecimalDigits` | `int` | `4` | Max fraction digits shown (capped at 15 internally) | -| `decimalSeparator` | `ThemedDecimalSeparator` | `.dot` | `.dot` → en locale (`1,234.56`), `.comma` → pt locale (`1.234,56`) | -| `format` | `NumberFormat?` | `null` | Custom `intl` `NumberFormat`. **Requires** `inputRegExp` when set. | -| `inputRegExp` | `RegExp?` | `null` | Overrides the default `[-0-9\,.]` regex when provided. **Required** when `format` is set. | -| `disabled` | `bool` | `false` | Makes field read-only and hides ± buttons | -| `hidePrefixSuffixActions` | `bool` | `false` | Hides ± buttons without disabling the field | -| `errors` | `List` | `[]` | Validation messages shown below the field | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | -| `isRequired` | `bool` | `false` | Shows `*` required indicator | -| `dense` | `bool` | `false` | Reduces vertical padding | -| `padding` | `EdgeInsets?` | `null` | Outer padding of the field | -| `prefixText` | `String?` | `null` | Static text before the value (e.g. `'$'`) | -| `suffixText` | `String?` | `null` | Static text after the value (e.g. `'kg'`) | -| `keyboardType` | `TextInputType` | `TextInputType.number` | Keyboard hint on mobile | -| `focusNode` | `FocusNode?` | `null` | External focus node; **not** disposed by this widget (caller owns it) | -| `onSubmitted` | `VoidCallback?` | `null` | Called when the user submits the field | -| `inputFormatters` | `List` | `[]` | Extra formatters appended after the built-in regex filter | -| `borderRadius` | `double?` | `null` | Corner radius override | - -### Behavior notes - -- `label` and `labelText` are **mutually exclusive**. Providing both (or neither) throws an assertion at construction. -- When `format` is set you **must** also set `inputRegExp`. The widget asserts this at construction. The default regex `[-0-9\,.]` only applies when no custom format is used — if you provide `inputRegExp`, that takes priority. -- `minimum`/`maximum` only gate the **buttons** — they do not restrict keyboard input. Add a validator in your form layer if you need hard limits on typed values. -- The ± buttons show **0.4 opacity** (visually disabled) when the next step would exceed the boundary. This is reactive — no manual state management needed. -- **Cursor after step:** When using ± buttons, the cursor always moves to the end of the formatted number. When typing manually, the cursor stays at the current position. This distinction is tracked internally via a `_stepTriggered` flag. -- `onChanged` is called with `null` when the field is cleared, and is **not** called when the user types only a `-` sign (intermediate state waiting for digits) or when `format.tryParse()` returns null (unparseable input is silently ignored — last valid value is preserved). -- `decimalSeparator: .comma` uses the `pt` locale `NumberFormat` internally, producing `1.234,56` style output. -- `hidePrefixSuffixActions` also activates automatically when `disabled: true`. - ---- - -### Common patterns - -```dart -// Integer-only field with bounds -ThemedNumberInput( - labelText: context.i18n.t('entity.quantity'), - value: quantity, - minimum: 0, - maximum: 999, - step: 1, - maximumDecimalDigits: 0, - errors: context.getErrors(key: 'quantity'), - onChanged: (value) { - quantity = value?.toInt(); - if (context.mounted) onChanged.call(); - }, -) - -// Decimal field with comma separator and unit suffix -ThemedNumberInput( - labelText: context.i18n.t('entity.temperature'), - value: temperature, - decimalSeparator: ThemedDecimalSeparator.comma, - maximumDecimalDigits: 2, - suffixText: '°C', - errors: context.getErrors(key: 'temperature'), - onChanged: (value) { - temperature = value; - if (context.mounted) onChanged.call(); - }, -) - -// Price field with currency prefix -ThemedNumberInput( - labelText: context.i18n.t('entity.price'), - value: price, - prefixText: '\$', - maximumDecimalDigits: 2, - minimum: 0, - errors: context.getErrors(key: 'price'), - onChanged: (value) { - price = value; - if (context.mounted) onChanged.call(); - }, -) - -// Custom NumberFormat (inputRegExp is required when format is set) -ThemedNumberInput( - labelText: context.i18n.t('entity.price'), - value: price, - format: NumberFormat.currency(symbol: '\$'), - inputRegExp: RegExp(r'[\d.]'), - errors: context.getErrors(key: 'price'), - onChanged: (value) { - price = value; - if (context.mounted) onChanged.call(); - }, -) - -// No step buttons (free-form entry only) -ThemedNumberInput( - labelText: context.i18n.t('entity.offset'), - value: offset, - hidePrefixSuffixActions: true, - onChanged: (value) { - offset = value; - if (context.mounted) onChanged.call(); - }, -) - -// Fine decimal steps (0.1 increments, 0–1 range) -ThemedNumberInput( - labelText: context.i18n.t('entity.opacity'), - value: opacity, - minimum: 0, - maximum: 1, - step: 0.1, - maximumDecimalDigits: 1, - onChanged: (value) { - opacity = value; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## ThemedDurationInput - -### Minimal usage - -```dart -// State -Duration? timeout; - -// Widget -ThemedDurationInput( - labelText: context.i18n.t('entity.timeout'), - value: timeout, - onChanged: (value) { - timeout = value; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Exactly one must be set. | -| `value` | `Duration?` | `null` | Current duration value | -| `onChanged` | `Function(Duration?)?` | `null` | Called only when the user taps Save in the dialog | -| `visibleValues` | `List` | `kThemedDurationSupported` | Units shown in the dialog. Subset of `[day, hour, minute, second]` | -| `disabled` | `bool` | `false` | Disables tapping; field is always readonly | -| `errors` | `List` | `[]` | Validation messages shown below the field | -| `padding` | `EdgeInsets?` | `null` | Outer padding of the field | -| `prefixIcon` | `IconData?` | `null` | Icon before the text field | -| `suffixIcon` | `IconData?` | `null` | Icon after the text field | - -`kThemedDurationSupported` is `[ThemedUnits.day, ThemedUnits.hour, ThemedUnits.minute, ThemedUnits.second]`. - -### Behavior notes - -- The field is **always readonly** — tapping it opens a dialog. Keyboard entry is not supported. -- `onChanged` fires **only when the user taps Save**. Cancel or dismiss discards changes. -- The dialog has three actions: **Cancel**, **Reset** (zeroes all units, doesn't close), and **Save** (commits and closes). -- Display text is humanized using `Duration.humanize(...)` with the active `LayrzAppLocalizations` locale. -- `visibleValues` must be a subset of `kThemedDurationSupported`. Passing `year`, `month`, `week`, or `millisecond` throws an assertion. -- When only one unit is visible it occupies full width; two or more use a two-column `ResponsiveRow`. - -### Common patterns - -```dart -// Hours and minutes only — for scheduling -ThemedDurationInput( - labelText: context.i18n.t('entity.shiftLength'), - value: shiftLength, - visibleValues: const [ThemedUnits.hour, ThemedUnits.minute], - errors: context.getErrors(key: 'shiftLength'), - onChanged: (value) { - shiftLength = value; - if (context.mounted) onChanged.call(); - }, -) - -// Days only — for expiration windows -ThemedDurationInput( - labelText: context.i18n.t('entity.retentionPeriod'), - value: retentionPeriod, - visibleValues: const [ThemedUnits.day], - errors: context.getErrors(key: 'retentionPeriod'), - onChanged: (value) { - retentionPeriod = value; - if (context.mounted) onChanged.call(); - }, -) - -// Full granularity with icon -ThemedDurationInput( - labelText: context.i18n.t('entity.connectionTimeout'), - value: connectionTimeout, - prefixIcon: LayrzIcons.solarOutlineClockCircle, - errors: context.getErrors(key: 'connectionTimeout'), - onChanged: (value) { - connectionTimeout = value; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## Integrating with layrz forms (onChanged + errors pattern) - -```dart -ThemedNumberInput( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, - errors: context.getErrors(key: 'fieldName'), - onChanged: (value) { - object.fieldName = value; - if (context.mounted) onChanged.call(); - }, -) -``` - -```dart -ThemedDurationInput( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, - errors: context.getErrors(key: 'fieldName'), - onChanged: (value) { - object.fieldName = value; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## Gotchas & Edge Cases - -### 1. `minimum`/`maximum` gate buttons, not keyboard - -The `minimum`/`maximum` parameters only disable the ± buttons visually. A user can still type a value outside the range. Add explicit validation in your form layer if hard limits are required. - -```dart -// Button disabled at 0, but user can still type "-5" -ThemedNumberInput( - value: qty, - minimum: 0, - step: 1, - errors: qty != null && qty! < 0 ? ['Must be >= 0'] : [], - onChanged: (v) => setState(() => qty = v), -) -``` - -### 2. `onChanged` is NOT called for unparseable input - -When `format.tryParse()` returns null (e.g. a lone `.` or incomplete number), `onChanged` is silently skipped. The last valid value is preserved in the parent state. This is intentional — don't rely on `onChanged` being called on every keystroke. - -```dart -// User types "1." — onChanged not called until they type "1.5" -// Parent keeps the previous value during partial input -``` - -### 3. Cursor moves to end after ± buttons - -After tapping the increment or decrement button, the cursor always jumps to the end of the formatted number. This is correct and intentional — don't fight it. When typing manually, cursor position is preserved. - -```dart -// 9 → tap + → "10" with cursor at end (position 2) ✅ -// Was broken before: cursor would land at position 1 (between "1" and "0") -``` - -### 4. `format` requires `inputRegExp` — assertion at construction - -This is enforced via a Dart `assert`. If you provide a custom `format` without `inputRegExp`, the app crashes in debug mode immediately. Always pair them. - -```dart -// ❌ Crashes in debug -ThemedNumberInput( - format: NumberFormat.currency(symbol: '\$'), - // inputRegExp missing! -) - -// ✅ Correct -ThemedNumberInput( - format: NumberFormat.currency(symbol: '\$'), - inputRegExp: RegExp(r'[\d.]'), -) -``` - -### 5. `focusNode` lifecycle is the caller's responsibility - -If you pass a `focusNode`, **you** must dispose it. The widget only disposes `FocusNode` instances it creates internally (when `focusNode` is null). - -```dart -// Caller owns lifecycle -final _focus = FocusNode(); - -@override -void dispose() { - _focus.dispose(); // your responsibility - super.dispose(); -} - -ThemedNumberInput(focusNode: _focus, ...) -``` - -### 6. `decimalSeparator: .comma` affects the full format - -`.comma` uses the `pt` locale pattern internally, which means **thousands separator is `.`** and **decimal separator is `,`**. The input regex accepts both `.` and `,` regardless of locale, so typos can still occur. Validate the parsed `num` value rather than the raw string. - ---- - -## Choosing between the two - -- Use `ThemedNumberInput` when the field represents a **scalar number**: count, weight, speed, price, percentage, coordinates. -- Use `ThemedDurationInput` when the field represents a **time span** stored as `Duration`. -- Never use raw `TextField`, `TextFormField`, `Slider`, or any Material numeric widget — always use these components. -- Prefer `ThemedDurationInput` over storing duration as a plain integer (seconds, milliseconds) — it gives the user explicit unit controls and produces a human-readable display automatically. - ---- - -## Testing ThemedNumberInput - -```dart -import 'package:layrz_icons/layrz_icons.dart'; -import 'package:layrz_theme/layrz_theme.dart'; - -// Find the ± buttons by icon -final addButton = find.byIcon(LayrzIcons.solarOutlineAddSquare); -final subButton = find.byIcon(LayrzIcons.solarOutlineMinusSquare); - -// Tap increment -await tester.tap(addButton); -await tester.pumpAndSettle(); -expect(currentValue, equals(expectedValue)); - -// Verify button is disabled at max (icon is wrapped in Opacity 0.4 InkWell with null onTap) -// To test the boundary, verify onChanged is NOT called at maximum: -int calls = 0; -// pump widget with value == maximum, tap add, assert calls == 0 - -// Enter text via keyboard -await tester.enterText(find.byType(TextField), '42'); -await tester.pump(); -expect(received, equals(42)); - -// Clear field → onChanged(null) -await tester.enterText(find.byType(TextField), ''); -await tester.pump(); -expect(received, isNull); - -// Verify cursor is at end after step (regression guard) -final tf = tester.widget(find.byType(TextField)); -expect( - tf.controller!.selection.extentOffset, - equals(tf.controller!.text.length), - reason: 'Cursor must be at end after step button tap', -); -``` - -### StatefulBuilder pattern for reactive tests - -```dart -Widget buildReactive(num? Function() get, void Function(num?) set) { - num? value = get(); - return MaterialApp( - home: Scaffold( - body: StatefulBuilder( - builder: (context, setState) { - return ThemedNumberInput( - labelText: 'Number', - value: value, - onChanged: (v) => setState(() { value = v; set(v); }), - ); - }, - ), - ), - ); -} -``` diff --git a/.claude-plugin/skills/responsive-row/SKILL.md b/.claude-plugin/skills/responsive-row/SKILL.md deleted file mode 100644 index e6b25e6..0000000 --- a/.claude-plugin/skills/responsive-row/SKILL.md +++ /dev/null @@ -1,332 +0,0 @@ ---- -name: responsive-row -description: ResponsiveRow and ResponsiveCol — Material 3 responsive grid layouts with breakpoint-based column sizing ---- - -## Overview - -`ResponsiveRow` and `ResponsiveCol` provide a responsive 12-column grid system for layrz_theme. ResponsiveRow acts as a flex container using Wrap layout, while ResponsiveCol defines column width at different breakpoints (xs, sm, md, lg, xl). Together they create layouts that adapt to screen size without manual media queries. - -| Feature | Details | -|---|---| -| Responsive Breakpoints | xs (0-600), sm (600-960), md (960-1264), lg (1264-1904), xl (1904+) | -| 12-Column Grid | Sizes enum with col1-col12 for flexible width specification | -| Wrap Layout | ResponsiveRow uses Wrap for automatic line breaking when space insufficient | -| Builder Pattern | ResponsiveRow.builder for dynamic child generation | -| Spacing Control | Configurable gap between columns | -| Alignment Options | Main axis and cross axis alignment control | -| Full-Width Enforcement | ResponsiveRow always spans full parent width | -| Fallback Chain | Missing breakpoints cascade to larger breakpoints automatically | - -**When to use:** Responsive dashboards, card grids, sidebar + content layouts, responsive forms. Prefer when you need width-based column sizing without MediaQuery or custom LayoutDelegate. - -## ResponsiveRow - -Horizontal container that arranges ResponsiveCol children using a Wrap widget. All children are laid out horizontally with configurable spacing and alignment. Wraps to next line when space is insufficient. - -### Key Parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `children` | `List` | required (OR `builder`) | List of ResponsiveCol widgets. Type-safe at compile time | -| `builder` | `IndexedWidgetBuilder?` | required (OR `children`) | Build function for dynamic children. Use with `itemCount` | -| `itemCount` | `int?` | `null` | Number of items to build when using `builder` | -| `spacing` | `double` | `0` | Horizontal gap between columns (in pixels) | -| `mainAxisAlignment` | `WrapAlignment` | `.start` | Horizontal alignment of columns | -| `crossAxisAlignment` | `WrapCrossAlignment` | `.start` | Vertical alignment of columns | - -### Minimal responsive row - -```dart -ResponsiveRow( - children: [ - ResponsiveCol(xs: Sizes.col12, sm: Sizes.col6, md: Sizes.col4, child: CardWidget()), - ResponsiveCol(xs: Sizes.col12, sm: Sizes.col6, md: Sizes.col4, child: CardWidget()), - ResponsiveCol(xs: Sizes.col12, sm: Sizes.col6, md: Sizes.col4, child: CardWidget()), - ], -) -``` - -### Row with spacing and alignment - -```dart -ResponsiveRow( - spacing: 16, - mainAxisAlignment: WrapAlignment.spaceEvenly, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - ResponsiveCol(xs: Sizes.col12, md: Sizes.col6, child: SidebarWidget()), - ResponsiveCol(xs: Sizes.col12, md: Sizes.col6, child: ContentWidget()), - ], -) -``` - -### Row with builder for dynamic children - -```dart -ResponsiveRow.builder( - spacing: 12, - itemCount: 12, - itemBuilder: (context, index) { - return ResponsiveCol( - xs: Sizes.col12, - sm: Sizes.col6, - md: Sizes.col4, - lg: Sizes.col3, - child: ProductCard(product: products[index]), - ); - }, -) -``` - -## ResponsiveCol - -Defines a column's width at different breakpoints. Must be a child of ResponsiveRow. Uses LayoutBuilder internally to detect current breakpoint and apply responsive sizing. - -### Key Parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `child` | `Widget` | required | Content widget displayed in this column | -| `xs` | `Sizes` | `col12` | Width at extra small breakpoint (0-600px) | -| `sm` | `Sizes?` | `null` | Width at small breakpoint (600-960px). Falls back to xs if null | -| `md` | `Sizes?` | `null` | Width at medium breakpoint (960-1264px). Falls back to sm→xs if null | -| `lg` | `Sizes?` | `null` | Width at large breakpoint (1264-1904px). Falls back to md→sm→xs if null | -| `xl` | `Sizes?` | `null` | Width at extra large breakpoint (1904px+). Falls back to lg→md→sm→xs if null | - -### Sizes Enum - -Column widths specified using Sizes enum with 12-column system: -- `Sizes.col1` through `Sizes.col12` — represents 1 to 12 columns -- Width = `(containerWidth / 12) * columnCount` - -### Minimal column - -```dart -ResponsiveCol( - xs: Sizes.col12, - md: Sizes.col6, - child: CardWidget(), -) -``` - -### Column with all breakpoints - -```dart -ResponsiveCol( - xs: Sizes.col12, // Mobile: full width - sm: Sizes.col6, // Tablet: half width - md: Sizes.col4, // Desktop: one-third - lg: Sizes.col3, // Large: one-quarter - xl: Sizes.col2, // Extra large: one-sixth - child: ProductCard(), -) -``` - -### Column with fallback chain (lazy specification) - -```dart -ResponsiveCol( - xs: Sizes.col12, // Mobile: full width (explicit) - md: Sizes.col8, // Desktop: two-thirds (sm and lg fall back to this) - // sm → falls back to md (col8) - // lg → falls back to xl (col12) → defaults to col12 - child: ArticleView(), -) -``` - -## Sizes Enum & Extension - -### Sizes Enum -```dart -enum Sizes { col1, col2, col3, col4, col5, col6, col7, col8, col9, col10, col11, col12 } -``` - -### SizesExt Extension -```dart -extension SizesExt on Sizes { - double boxWidth(double width) // Calculate actual width in pixels - int get gridSize // Get column count (1-12) -} -``` - -**Usage:** -```dart -Sizes.col6.gridSize // Returns: 6 -Sizes.col6.boxWidth(1200) // Returns: 600 (6 out of 12 = 50%) -Sizes.col3.boxWidth(600) // Returns: 150 (3 out of 12 = 25%) -``` - -## Responsive Behavior - -### Breakpoint Logic - -ResponsiveCol evaluates width and selects the best Sizes: - -``` -width < 600px - → use xs - -600px ≤ width < 960px - → use sm (or xs if sm is null) - -960px ≤ width < 1264px - → use md (fallback: sm → xs) - -1264px ≤ width < 1904px - → use lg (fallback: md → sm → xs) - -width ≥ 1904px - → use xl (fallback: lg → md → sm → xs) -``` - -### Example: 3-Column Grid - -```dart -ResponsiveRow( - spacing: 16, - children: [ - ResponsiveCol( - xs: .col12, // 1 column on mobile - sm: .col6, // 2 columns on tablet portrait - md: .col4, // 3 columns on tablet landscape - child: Card(), - ), - // ... 2 more cols with identical responsive behavior - ], -) - -// Renders as: -// Mobile (< 600px): [full-width] [full-width] [full-width] -// Tablet Portrait (600-960): [50%] [50%] [50%] (wraps to 2 rows + 1) -// Tablet Land (960-1264): [33%] [33%] [33%] -// Desktop (1264px+): [33%] [33%] [33%] -``` - -## Testing - -Widget tests are located in `test/widgets/responsive_row_test.dart`. - -**Test coverage:** -- Basic rendering with children (empty, single, multiple) -- Spacing parameter (0, custom values) -- Alignment parameters (mainAxis, crossAxis) -- Full width enforcement -- builder() with various itemCounts -- ResponsiveCol rendering and child preservation -- Sizes enum calculations (boxWidth, gridSize) -- Integration: ResponsiveRow + ResponsiveCol together - -**Running tests:** -```bash -flutter test test/widgets/responsive_row_test.dart -``` - -## Best Practices - -1. **Specify xs explicitly** — Always define `xs` since it's the fallback for all breakpoints - ```dart - ResponsiveCol(xs: .col12, md: .col6) // ✓ Good - ResponsiveCol(md: .col6) // ✗ Compile error (xs required) - ``` - -2. **Use lazy specification for efficiency** — Only specify breakpoints that differ from cascading - ```dart - ResponsiveCol( - xs: .col12, // Mobile - lg: .col6, // Desktop (tablet will inherit xs) - child: Widget(), - ) - ``` - -3. **Combine with LayoutBuilder for complex layouts** — ResponsiveCol handles sizing, but layout decisions may need LayoutBuilder: - ```dart - LayoutBuilder( - builder: (ctx, constraints) { - bool isMobile = constraints.maxWidth < 600; - return ResponsiveRow( - children: [ - ResponsiveCol(xs: isMobile ? .col12 : .col6, child: Widget()), - ], - ); - }, - ) - ``` - -4. **Remember wrap behavior** — ResponsiveRow wraps children to next line when space is insufficient. Plan layouts accordingly. - -5. **Spacing and alignment matter** — Use `spacing` and alignment parameters for proper visual hierarchy - ```dart - ResponsiveRow( - spacing: 16, - mainAxisAlignment: WrapAlignment.spaceEvenly, - crossAxisAlignment: WrapCrossAlignment.center, - children: [...], - ) - ``` - -## Common Patterns - -### Full-Width Sidebar + Content -```dart -ResponsiveRow( - children: [ - ResponsiveCol( - xs: .col12, - lg: .col3, - child: Sidebar(), - ), - ResponsiveCol( - xs: .col12, - lg: .col9, - child: MainContent(), - ), - ], -) -``` - -### Card Grid with Even Spacing -```dart -ResponsiveRow.builder( - itemCount: cards.length, - spacing: 16, - mainAxisAlignment: WrapAlignment.center, - itemBuilder: (index) => ResponsiveCol( - xs: .col12, - sm: .col6, - lg: .col4, - child: CardWidget(data: cards[index]), - ), -) -``` - -### Responsive Form Layout -```dart -ResponsiveRow( - spacing: 12, - children: [ - ResponsiveCol( - xs: .col12, - md: .col6, - child: TextInput(label: 'First Name'), - ), - ResponsiveCol( - xs: .col12, - md: .col6, - child: TextInput(label: 'Last Name'), - ), - ResponsiveCol( - xs: .col12, - child: TextInput(label: 'Email'), - ), - ], -) -``` - -## Implementation Notes - -- **Wrap direction is always Axis.horizontal** — Children are laid out left-to-right and wrap to next line -- **ResponsiveCol uses LayoutBuilder** — Breakpoint evaluation is dynamic and responds to constraint changes -- **No state management** — Both components are StatelessWidget (stateless, efficient) -- **No custom LayoutDelegate** — Uses Wrap's built-in logic for simplicity and predictability -- **Type-safe children** — Dart's type system ensures only ResponsiveCol can be children of ResponsiveRow diff --git a/.claude-plugin/skills/select-inputs/SKILL.md b/.claude-plugin/skills/select-inputs/SKILL.md deleted file mode 100644 index 6203517..0000000 --- a/.claude-plugin/skills/select-inputs/SKILL.md +++ /dev/null @@ -1,213 +0,0 @@ ---- -name: select-inputs -description: Use ThemedSelectInput (single select) or ThemedMultiSelectInput (multi select) in a layrz Flutter widget. Apply when wiring a single-value or multi-value picker field into any stateful widget — forms, tabs, or standalone views. ---- - -## Overview - -Two components cover all selection needs: - -| Component | State type | `onChanged` returns | Default `autoclose` | -|---|---|---|---| -| `ThemedSelectInput` | `T?` | `ThemedSelectItem?` | `true` | -| `ThemedMultiSelectInput` | `List` | `List>` | `false` | - -Both open a dialog with a searchable list. The user selects an item (or items) and confirms. Always use `ThemedSelectItem` to build the `items` list. - ---- - -## ThemedSelectItem - -Every item in both components is a `ThemedSelectItem`: - -```dart -ThemedSelectItem( - value: 1, // T — the actual stored value - label: 'Option 1', // displayed text -) -``` - -Optional: `onTap` (`VoidCallback?`) — called when the item is tapped inside the dialog. - ---- - -## ThemedSelectInput — single value - -### Minimal usage - -```dart -// State -int? selectedId; - -// Widget -ThemedSelectInput( - labelText: 'Country', - items: countries.map((c) => ThemedSelectItem(value: c.id, label: c.name)).toList(), - value: selectedId, - onChanged: (item) => setState(() => selectedId = item?.value), -) -``` - -`onChanged` receives `ThemedSelectItem?`. Always use `.value` to extract the raw `T`. - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Exactly one of the two must be set. | -| `items` | `List>` | required | The selectable options | -| `value` | `T?` | `null` | Currently selected value | -| `onChanged` | `void Function(ThemedSelectItem?)?` | `null` | Callback on selection | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation error messages shown below | -| `isRequired` | `bool` | `false` | Shows required indicator | -| `dense` | `bool` | `false` | Reduces vertical padding | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row | -| `autoclose` | `bool` | `true` | Closes dialog immediately on selection | -| `canUnselect` | `bool` | `false` | Allows the user to deselect the current item; `onChanged` receives `null` | -| `returnNullOnClose` | `bool` | `false` | Calls `onChanged(null)` when dialog is dismissed without picking | -| `autoSelectFirst` | `bool` | `false` | Auto-selects `items[0]` on `initState` when `value` is null | -| `enableSearch` | `bool` | `true` | Shows a search field inside the dialog | -| `hideTitle` | `bool` | `false` | Hides the dialog title; also disables search | -| `hideButtons` | `bool` | `false` | Hides Cancel / Save buttons | -| `dialogContraints` | `BoxConstraints` | `maxWidth:500, maxHeight:500` | Dialog size constraints (note: typo in API — `Contraints`) | -| `overrideHeightDialog` | `double?` | `null` | Forces dialog height | -| `itemExtent` | `double` | `50` | Fixed row height inside the list | -| `prefixIcon` | `IconData?` | `null` | Icon before the text field | -| `customChild` | `Widget?` | `null` | Replaces the text field with a custom widget | - -### Behavior notes - -- When `autoclose: true` (default), the dialog closes as soon as an item is tapped — no Save button needed. -- When `autoclose: false`, the user must tap Save to confirm. Use this when `canUnselect: true` so the user can explicitly deselect and save. -- When `returnNullOnClose: true` and the user taps outside the dialog, `onChanged(null)` is called — useful to clear the field. -- `autoSelectFirst` only fires once during `initState`; it does not re-fire if `value` later becomes null. - -### Common patterns - -```dart -// Allow deselection -ThemedSelectInput( - labelText: 'Status', - items: statuses, - value: selectedStatus, - canUnselect: true, - autoclose: false, // show Save button so user can confirm the unselect - onChanged: (item) => setState(() => selectedStatus = item?.value), -) - -// Clear on dismiss -ThemedSelectInput( - labelText: 'Category', - items: categories, - value: selectedCategory, - autoclose: false, - returnNullOnClose: true, - onChanged: (item) => setState(() => selectedCategory = item?.value), -) -``` - ---- - -## ThemedMultiSelectInput — multiple values - -### Minimal usage - -```dart -// State -List selectedIds = []; - -// Widget -ThemedMultiSelectInput( - labelText: 'Tags', - items: tags.map((t) => ThemedSelectItem(value: t.id, label: t.name)).toList(), - value: selectedIds, - onChanged: (items) => setState( - () => selectedIds = items.map((e) => e.value!).toList(), - ), -) -``` - -`onChanged` receives `List>`. Use `.map((e) => e.value!).toList()` to extract the raw list of `T`. - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided | -| `label` | `Widget?` | — | Required unless `labelText` is provided | -| `items` | `List>` | required | The selectable options | -| `value` | `List?` | `null` | Currently selected values | -| `onChanged` | `void Function(List>)?` | `null` | Callback on selection change | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation error messages | -| `isRequired` | `bool` | `false` | Shows required indicator | -| `dense` | `bool` | `false` | Reduces vertical padding | -| `hideDetails` | `bool` | `false` | Hides errors/hints row | -| `autoclose` | `bool` | `false` | Closes dialog immediately on each tap (not recommended for multi-select) | -| `autoselectFirst` | `bool` | `false` | Auto-selects `items[0]` on `initState` when `value` is empty | -| `enableSearch` | `bool` | `true` | Shows a search field inside the dialog | -| `hideTitle` | `bool` | `false` | Hides the dialog title; also disables search | -| `waitUntilClosedToSubmit` | `bool` | `false` | Delays `onChanged` call until the dialog is closed (Save tapped) | -| `dialogConstraints` | `BoxConstraints` | `maxWidth:500, maxHeight:500` | Dialog size constraints | -| `itemExtent` | `double` | `50` | Fixed row height inside the list | -| `prefixIcon` | `IconData?` | `null` | Icon before the text field | -| `customChild` | `Widget?` | `null` | Replaces the text field with a custom widget | - -### Behavior notes - -- The dialog always shows **Cancel**, **Select All / Unselect All**, and **Save** buttons. This cannot be hidden. -- Cancel discards all changes and calls `onChanged` with the previous selection (no change). -- By default (`waitUntilClosedToSubmit: false`), `onChanged` is fired on every tap — the parent state updates in real time while the dialog is open. -- Set `waitUntilClosedToSubmit: true` when real-time updates cause expensive side effects. -- Select All / Unselect All toggles between all items selected and none selected; it does not call `onChanged` — only Save does. - -### Common patterns - -```dart -// Batch submit — only fire onChanged when user confirms -ThemedMultiSelectInput( - labelText: 'Permissions', - items: permissions, - value: selectedPermissions, - waitUntilClosedToSubmit: true, - onChanged: (items) => setState( - () => selectedPermissions = items.map((e) => e.value!).toList(), - ), -) -``` - ---- - -## Integrating with layrz forms (onChanged + errors pattern) - -```dart -ThemedSelectInput( - labelText: context.i18n.t('entity.fieldName'), - items: sourceList.map((e) => ThemedSelectItem(value: e.id, label: e.name)).toList(), - value: object.fieldName, - errors: context.getErrors(key: 'fieldName'), - onChanged: (item) { - object.fieldName = item?.value; - if (context.mounted) onChanged.call(); - }, -) -``` - -For multi-select, replace `value` with `List` and `onChanged` body with: - -```dart -onChanged: (items) { - object.fieldName = items.map((e) => e.value!).nonNulls.toList(); - if (context.mounted) onChanged.call(); -}, -``` - ---- - -## Choosing between the two - -- Use `ThemedSelectInput` when the field stores a **single value** (`T?`). -- Use `ThemedMultiSelectInput` when the field stores a **list of values** (`List`). -- Never use raw Flutter `DropdownButton` or `CheckboxListTile` — always use these components. diff --git a/.claude-plugin/skills/text-inputs/SKILL.md b/.claude-plugin/skills/text-inputs/SKILL.md deleted file mode 100644 index 62225f4..0000000 --- a/.claude-plugin/skills/text-inputs/SKILL.md +++ /dev/null @@ -1,395 +0,0 @@ ---- -name: text-inputs -description: Use ThemedTextInput, ThemedPasswordInput, or ThemedSearchInput in a layrz Flutter widget. Apply when adding a text field, password field, or search bar to any form or view. ---- - -## Overview - -| Component | State type | `onChanged` signature | When to use | -|---|---|---|---| -| `ThemedTextInput` | `String` | `void Function(String)` | Any free-text form field; also foundation for combobox autocomplete | -| `ThemedPasswordInput` | `String` | `ValueChanged` (`void Function(String)`) | Password creation or login fields; adds strength indicator and show/hide toggle | -| `ThemedSearchInput` | `String` | `OnSearch` (`void Function(String)`) | Search bars; compact icon button that expands into an overlay, or a full-width field | - -Never use raw Flutter `TextField`, `TextFormField`, or `SearchBar` — always use these components. - ---- - -## ThemedTextInput - -### Minimal usage - -```dart -// State -String name = ''; - -// Widget -ThemedTextInput( - labelText: context.i18n.t('entity.name'), - value: name, - errors: context.getErrors(key: 'name'), - onChanged: (value) { - name = value; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Exactly one must be set — assert enforced. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Exactly one must be set — assert enforced. | -| `value` | `String?` | `null` | Controlled value; widget syncs `TextEditingController` to this on `didUpdateWidget` | -| `onChanged` | `void Function(String)?` | `null` | Fires only when `validator` passes (defaults to always pass) | -| `controller` | `TextEditingController?` | `null` | External controller; if provided, the widget does NOT dispose it | -| `focusNode` | `FocusNode?` | `null` | External focus node; if provided, the widget does NOT dispose it | -| `disabled` | `bool` | `false` | Sets readOnly + disabled; automatically appends a lock icon as suffix | -| `readonly` | `bool` | `false` | Readonly without the disabled styling | -| `errors` | `List` | `[]` | Validation messages shown below the field | -| `hideDetails` | `bool` | `false` | Hides the errors/hints area entirely | -| `isRequired` | `bool` | `false` | Prepends `*` to the label | -| `placeholder` | `String?` | `null` | Hint text shown when empty | -| `keyboardType` | `TextInputType` | `TextInputType.text` | Controls software keyboard layout | -| `obscureText` | `bool` | `false` | Hides input characters (use `ThemedPasswordInput` instead of setting this directly) | -| `maxLines` | `int` | `1` | Values > 1 switch to multiline mode and always show floating label | -| `dense` | `bool` | `false` | Reduces vertical padding | -| `padding` | `EdgeInsets?` | `EdgeInsets.all(10)` | Outer padding; use `ThemedTextInput.outerPadding` to read the static default | -| `prefixText` | `String?` | `null` | Inline text prefix inside the field (e.g. currency symbol) | -| `prefixIcon` | `IconData?` | `null` | Prefix icon. Mutually exclusive with `prefixWidget` — set only one per side. | -| `prefixWidget` | `Widget?` | `null` | Prefix widget. Mutually exclusive with `prefixIcon`. | -| `onPrefixTap` | `VoidCallback?` | `null` | Tap callback for the prefix area | -| `suffixIcon` | `IconData?` | `null` | Suffix icon. Mutually exclusive with `suffixWidget` — set only one per side. | -| `suffixText` | `String?` | `null` | Inline text suffix inside the field | -| `suffixWidget` | `Widget?` | `null` | Suffix widget. Mutually exclusive with `suffixIcon`. | -| `onSuffixTap` | `VoidCallback?` | `null` | Tap callback for the suffix area | -| `validator` | `bool Function(String)?` | `null` | If provided, `onChanged` only fires when this returns `true` | -| `onSubmitted` | `VoidCallback?` | `null` | Called when the user submits (keyboard action) | -| `onTap` | `VoidCallback?` | `null` | Called when the field is tapped | -| `inputFormatters` | `List` | `[]` | Applied to the underlying `TextField` | -| `autofillHints` | `List` | `[]` | Browser/OS autofill hints | -| `autofocus` | `bool` | `false` | Requests focus on first build | -| `autocorrect` | `bool` | `true` | Enables autocorrect | -| `enableSuggestions` | `bool` | `true` | Enables keyboard suggestions | -| `borderRadius` | `double?` | `null` | Switches to `OutlineInputBorder` with this radius | -| `textStyle` | `TextStyle?` | `null` | Overrides the text style inside the field | -| `enableCombobox` | `bool` | `false` | Activates dropdown overlay with `choices` | -| `choices` | `List` | `[]` | Options shown in the combobox overlay; reactive — updates via stream | -| `maxChoicesToDisplay` | `int` | `5` | Maximum rows visible in the combobox before scrolling | -| `emptyChoicesText` | `String` | `'No choices'` | Message when `choices` is empty | -| `position` | `ThemedComboboxPosition` | `.below` | Whether the combobox opens above or below the field | - -### Behavior notes - -- `label` and `labelText` are **mutually exclusive**. The constructor asserts this. Setting both causes an assertion error at runtime. -- `prefixIcon` and `prefixWidget` are **mutually exclusive per side**. Likewise, `suffixIcon` and `suffixWidget` are mutually exclusive. The widget renders both if you pass both — avoid it. -- When `disabled: true`, the widget automatically appends a lock icon (`LayrzIcons.solarOutlineLockKeyhole`) as a suffix. Do not add your own lock icon on top of this. -- `value` is synced to the internal `TextEditingController` in `didUpdateWidget`. Cursor position is preserved up to the current text length. This sync only fires when no external `controller` is provided. -- When `enableCombobox: true`, tapping the field opens an `OverlayEntry` instead of calling `onTap`. The overlay reacts to live `choices` changes via a `StreamController.broadcast`. -- `ThemedTextInput.outerPadding` is a static getter returning `EdgeInsets.all(10)`. Use it when you need to offset other widgets to match input alignment. -- `maxLines > 1` forces `floatingLabelBehavior: .always` — the label is always shown above the field. - -### Common patterns - -```dart -// Multiline text area -ThemedTextInput( - labelText: context.i18n.t('entity.description'), - value: description, - maxLines: 5, - errors: context.getErrors(key: 'description'), - onChanged: (value) { - description = value; - if (context.mounted) onChanged.call(); - }, -) - -// Combobox autocomplete -ThemedTextInput( - labelText: context.i18n.t('entity.city'), - value: city, - enableCombobox: true, - choices: citySuggestions, - errors: context.getErrors(key: 'city'), - onChanged: (value) { - city = value; - if (context.mounted) onChanged.call(); - // trigger suggestion fetch externally - }, -) - -// With prefix and suffix icons -ThemedTextInput( - labelText: context.i18n.t('entity.url'), - value: url, - prefixIcon: LayrzIcons.solarOutlineLink, - suffixIcon: LayrzIcons.solarOutlineCopy, - onSuffixTap: () => Clipboard.setData(ClipboardData(text: url)), - errors: context.getErrors(key: 'url'), - onChanged: (value) { - url = value; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## ThemedPasswordInput - -### Minimal usage - -```dart -// State -String password = ''; - -// Widget -ThemedPasswordInput( - labelText: context.i18n.t('entity.password'), - value: password, - errors: context.getErrors(key: 'password'), - onChanged: (value) { - password = value; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Exactly one must be set — assert enforced. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Exactly one must be set — assert enforced. | -| `value` | `String?` | `null` | Controlled value | -| `onChanged` | `ValueChanged?` | `null` | Fires on every keystroke | -| `controller` | `TextEditingController?` | `null` | External controller; widget does NOT dispose it | -| `focusNode` | `FocusNode?` | `null` | External focus node; widget does NOT dispose it | -| `disabled` | `bool` | `false` | Disables the input | -| `errors` | `List` | `[]` | Validation messages shown below the field | -| `hideDetails` | `bool` | `false` | Hides the errors/hints area | -| `isRequired` | `bool` | `false` | Prepends `*` to the label | -| `placeholder` | `String?` | `null` | Hint text | -| `showLevels` | `bool` | `true` | Shows strength icon and requirement checklist tooltip next to the toggle | -| `autofillHints` | `List` | `[AutofillHints.newPassword, AutofillHints.password]` | Adjust per context — use `newPassword` for creation, `password` for login | -| `padding` | `EdgeInsets?` | `null` | Falls through to `ThemedTextInput.outerPadding` | -| `borderRadius` | `double?` | `null` | Passed through to `ThemedTextInput` | -| `onSubmitted` | `VoidCallback?` | `null` | Called on keyboard submit | - -### Strength calculation - -Strength is derived exclusively from `value`. The widget computes: - -1. **Requirements met** (0–4): lowercase letter, uppercase letter, digit, special character -2. **All requirements met** AND **value matches the allowed character set** → `_isValid = true` -3. **Level** (0–4, only when `_isValid`): `< 8` chars → 0, `< 12` → 1, `< 12` → 1, `< 16` → 2, `< 20` → 3, `≥ 20` → 4 - -Color output: level 0 → red, 1–2 → orange, 3–4 → green. - -When `showLevels: true`, hovering the strength icon shows a tooltip checklist with pass/fail status for each requirement plus the current password length. - -### Behavior notes - -- `ThemedPasswordInput` is a thin wrapper around `ThemedTextInput`. It does not expose `prefixIcon`, `prefixWidget`, `suffixIcon`, or `suffixWidget` — the suffix area is reserved for the strength indicator and the show/hide toggle. -- `label` and `labelText` are **mutually exclusive**. Assert enforced with a descriptive message. -- The show/hide toggle always renders. Set `showLevels: false` to remove the strength indicator but keep the toggle. -- For login forms, set `autofillHints: const [AutofillHints.password]` (remove `newPassword`) so password managers match correctly. -- The allowed character regex is strict: only `A-Za-z0-9` and `!@#$%^&*()_-+=[]{};\:'",.<>/?` `` ` `` `~|\\`. Characters outside this set invalidate the password regardless of length. - -### Common patterns - -```dart -// Login form — disable strength indicator and use login autofill hint -ThemedPasswordInput( - labelText: context.i18n.t('auth.password'), - value: password, - showLevels: false, - autofillHints: const [AutofillHints.password], - errors: context.getErrors(key: 'password'), - onChanged: (value) { - password = value; - if (context.mounted) onChanged.call(); - }, -) - -// Password confirm field — no strength needed, no new-password hint -ThemedPasswordInput( - labelText: context.i18n.t('auth.confirmPassword'), - value: passwordConfirm, - showLevels: false, - autofillHints: const [], - errors: context.getErrors(key: 'passwordConfirm'), - onChanged: (value) { - passwordConfirm = value; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## ThemedSearchInput - -### Minimal usage — button mode (default) - -```dart -// State -String searchQuery = ''; - -// Widget — renders a 40×40 icon button; tap expands overlay -ThemedSearchInput( - value: searchQuery, - labelText: context.i18n.t('general.search'), - onSearch: (value) { - searchQuery = value; - if (context.mounted) setState(() {}); - }, -) -``` - -### Minimal usage — field mode - -```dart -// Widget — renders a full-width text field inline (no overlay) -ThemedSearchInput( - value: searchQuery, - labelText: context.i18n.t('general.search'), - asField: true, - maxWidth: 300, - onSearch: (value) { - searchQuery = value; - if (context.mounted) setState(() {}); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `value` | `String` | **required** | Current search query; **non-nullable** | -| `onSearch` | `OnSearch` (`void Function(String)`) | **required** | Fired on change (debounced) and on keyboard submit | -| `labelText` | `String` | `'Search'` | Hint text inside the field. Not a form label — no i18n forced, but use `context.i18n.t(...)` in practice. | -| `maxWidth` | `double` | `300` | Max width of the expanded overlay or the field when `asField: true` | -| `asField` | `bool` | `false` | Renders a full-width inline field instead of the compact button | -| `inputPadding` | `EdgeInsets` | `EdgeInsets.zero` | Inner padding, only applied when `asField: true` | -| `disabled` | `bool` | `false` | Disables tapping the button; no effect in field mode | -| `position` | `ThemedSearchPosition` | `.left` | Overlay expansion direction: `.left` (expands leftward) or `.right` (expands rightward) | -| `debounce` | `Duration?` | `Duration(milliseconds: 300)` | Debounce delay; set to `null` to fire `onSearch` synchronously on every keystroke | -| `customChild` | `Widget?` | `null` | Replaces the default icon button with a custom widget; tap still opens the overlay | - -### Behavior notes - -- `ThemedSearchInput` does **not** have `errors`, `hideDetails`, `isRequired`, or `label`/`labelText` (as a form label) parameters. It is not a form field — do not pass validation state to it. -- In button mode, a 40×40 rounded icon button renders. Tapping it opens an `OverlayEntry` with a scale animation anchored to the button position. Pressing Escape or tapping outside closes it. -- In field mode (`asField: true`), the widget is a plain `SizedBox` with height 40 and width `maxWidth`. There is no overlay — the field is always visible. -- `value` is non-nullable (`String`, not `String?`). Always initialize state to `''` not `null`. -- The debounce timer is cancelled on each keystroke and reset. `onSearch` fires after the debounce expires OR immediately on keyboard submit (Enter key), which also closes the overlay in button mode. -- `position: .left` means the overlay expands to the left (use when the button is on the right side of a toolbar). `position: .right` expands to the right. -- `customChild` wraps its widget in an `InkWell` that triggers the same overlay logic as the default button. - -### Common patterns - -```dart -// Toolbar with search on the right edge — expands leftward -Row( - children: [ - const Spacer(), - ThemedSearchInput( - value: query, - labelText: context.i18n.t('general.search'), - position: ThemedSearchPosition.left, - onSearch: (value) { - query = value; - if (context.mounted) setState(() {}); - }, - ), - ], -) - -// Inline search field with no debounce -ThemedSearchInput( - value: query, - labelText: context.i18n.t('general.search'), - asField: true, - maxWidth: 400, - debounce: null, - onSearch: (value) { - query = value; - if (context.mounted) setState(() {}); - }, -) - -// Custom trigger widget -ThemedSearchInput( - value: query, - labelText: context.i18n.t('general.search'), - customChild: ThemedButton( - label: context.i18n.t('general.search'), - icon: LayrzIcons.solarOutlineMagnifier, - style: ThemedButtonStyle.outlined, - onTap: () {}, - ), - onSearch: (value) { - query = value; - if (context.mounted) setState(() {}); - }, -) -``` - ---- - -## Integrating with layrz forms (onChanged + errors pattern) - -```dart -// ThemedTextInput in a form -ThemedTextInput( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, - errors: context.getErrors(key: 'fieldName'), - onChanged: (value) { - object.fieldName = value; - if (context.mounted) onChanged.call(); - }, -) - -const SizedBox(height: 10), - -// ThemedPasswordInput in the same form -ThemedPasswordInput( - labelText: context.i18n.t('entity.password'), - value: object.password, - errors: context.getErrors(key: 'password'), - onChanged: (value) { - object.password = value; - if (context.mounted) onChanged.call(); - }, -) -``` - -Rules: -- Always guard `onChanged` with `if (context.mounted)` before calling the parent callback. -- Always use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. -- Always pass `errors: context.getErrors(key: 'fieldName')` so server-side errors render. -- Separate stacked inputs with `const SizedBox(height: 10)`. -- `ThemedSearchInput` is NOT a form field. Do not pass `getErrors` or `isRequired` to it. - ---- - -## Choosing between the three - -| Situation | Use | -|---|---| -| Any free-text field in a form (name, email, URL, notes) | `ThemedTextInput` | -| Password creation or update | `ThemedPasswordInput` (with `showLevels: true`, default) | -| Password login | `ThemedPasswordInput` (with `showLevels: false`, `autofillHints: [AutofillHints.password]`) | -| Autocomplete / typeahead with a fixed string list | `ThemedTextInput` with `enableCombobox: true` and `choices` | -| Global or list search — minimal footprint in a toolbar | `ThemedSearchInput` (button mode, default) | -| Prominent search bar always visible | `ThemedSearchInput` with `asField: true` | -| Multiline textarea (notes, comments, description) | `ThemedTextInput` with `maxLines > 1` | - -Decision questions: -1. Is this a password? → `ThemedPasswordInput`. -2. Is this a search (not tied to form validation)? → `ThemedSearchInput`. -3. Everything else → `ThemedTextInput`. diff --git a/.claude-plugin/skills/themed-table2/SKILL.md b/.claude-plugin/skills/themed-table2/SKILL.md deleted file mode 100644 index a3fe9ce..0000000 --- a/.claude-plugin/skills/themed-table2/SKILL.md +++ /dev/null @@ -1,433 +0,0 @@ ---- -name: themed-table2 -description: ThemedTable2 and ThemedColumn2 — large-dataset virtualized table with sort, search, multiselect and actions ---- - -## Overview - -`ThemedTable2` is the layrz_theme table widget designed for **large datasets** (tested up to 55,000+ rows). It uses Flutter's `ListView.builder` with a fixed `itemExtent` for virtualized rendering, and offloads sort operations to a background isolate via `compute()`. - -| Feature | Details | -|---|---| -| Rendering | Virtualized — only visible rows are built | -| Sort | Background isolate via `compute()`, non-blocking | -| Search | Debounced 600ms, searches all column values | -| Multiselect | Optional checkbox column with bulk actions | -| Actions | Per-row action buttons (icon-only on desktop, menu on mobile) | -| Scroll sync | Header + content + multiselect + actions scroll in sync | - -**When to use:** Any time you display a list of domain objects in a table format. Handles both small and large datasets — it's the standard table for all modules. - ---- - -## ThemedColumn2\ - -Defines a single column: its header, how to extract the display value from `T`, and optional rich rendering. - -### Key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `headerText` | `String` | required | Column header label | -| `valueBuilder` | `String Function(T)` | required | Extracts the plain string value — used for sort, search, and cell display. **Must be isolate-safe** (see Isolate Safety below) | -| `richTextBuilder` | `List Function(T)?` | `null` | Custom rich rendering for the cell. Does NOT affect sort — sort always uses `valueBuilder` | -| `width` | `double?` | `null` | Fixed width in pixels. If `null`, column takes a flexible share of available space | -| `alignment` | `Alignment` | `.centerLeft` | Cell content alignment | -| `isSortable` | `bool` | `true` | Whether clicking the header triggers sort | -| `onTap` | `void Function(T)?` | `null` | Per-cell tap handler. Overrides `onTapDefaultBehavior` | -| `customSort` | `int Function(T a, T b, bool ascending)?` | `null` | Custom comparator. **Must be isolate-safe** (see Isolate Safety below) | - -### Minimal column - -```dart -ThemedColumn2( - headerText: 'Name', - valueBuilder: (item) => item.name, -) -``` - -### Fixed-width column - -```dart -ThemedColumn2( - headerText: 'ID', - width: 80, - valueBuilder: (item) => item.id ?? 'N/A', -) -``` - -### Column with rich cell rendering - -```dart -// State — precompute labels map outside the build method -final Map statusLabels = { - 'active': i18n.t('status.active'), - 'inactive': i18n.t('status.inactive'), -}; - -// Widget -ThemedColumn2( - headerText: i18n.t('asset.status'), - valueBuilder: (item) => statusLabels[item.status] ?? item.status ?? 'N/A', - richTextBuilder: (item) => [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: StatusChip(status: item.status), - ), - ], -) -``` - -### Column with custom sort - -```dart -ThemedColumn2( - headerText: 'Plate', - valueBuilder: (item) => item.plate ?? 'N/A', - customSort: (a, b, ascending) { - // ascending == true means A→Z - final result = (a.plate ?? '').compareTo(b.plate ?? ''); - return ascending ? result : -result; - }, -) -``` - ---- - -## ThemedTable2\ - -### Key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `items` | `List` | required | Full dataset. Pass all items — filtering and sorting happen internally | -| `columns` | `List>` | required | At least one column required | -| `actionsCount` | `int` | `0` | Max number of actions per row. Set to `0` to hide the actions column. Must match the actual number returned by `actionsBuilder` | -| `actionsBuilder` | `List Function(T)?` | `null` | Required when `actionsCount > 0` | -| `canSearch` | `bool` | `true` | Shows the search input above the table | -| `hasMultiselect` | `bool` | `true` | Shows the checkbox column. Requires `multiselectActions` | -| `multiselectActions` | `List` | `[]` | Bulk action buttons shown when rows are selected. Required when `hasMultiselect: true` | -| `multiselectValue` | `ValueNotifier>?` | `null` | External notifier to read/control the selection from outside | -| `onTapDefaultBehavior` | `ThemedTable2OnTapBehavior` | `.copyToClipboard` | What happens when a cell without `onTap` is tapped | -| `controller` | `ThemedTable2Controller?` | `null` | Programmatic control: trigger sort or refresh from outside | -| `populateDelay` | `Duration` | `150ms` | Delay before showing data (gives the loading spinner time to appear) | -| `minColumnWidth` | `double` | `250` | Minimum width for flex columns | -| `headerHeight` | `double` | `40` | Header row height | -| `reloadOnDidUpdate` | `bool` | `false` | Forces reload on hot reload (debug only) | - ---- - -## Minimal usage - -```dart -// State -final List _assets = store.assets; - -// Widget -ThemedTable2( - items: _assets, - actionsCount: 0, - hasMultiselect: false, - columns: [ - ThemedColumn2( - headerText: 'Name', - valueBuilder: (item) => item.name, - ), - ThemedColumn2( - headerText: 'Plate', - valueBuilder: (item) => item.plate ?? 'N/A', - ), - ], -) -``` - ---- - -## Common patterns - -### With search + actions - -```dart -// State -final List _items = store.assets; -bool _isLoading = false; - -// Widget -ThemedTable2( - items: _items, - canSearch: true, - actionsCount: 2, - hasMultiselect: false, - columns: [ - ThemedColumn2( - headerText: i18n.t('asset.name'), - valueBuilder: (item) => item.name, - ), - ThemedColumn2( - headerText: i18n.t('asset.plate'), - width: 150, - valueBuilder: (item) => item.plate ?? 'N/A', - ), - ], - actionsBuilder: (item) => [ - ThemedActionButton.edit( - labelText: i18n.t('actions.edit'), - isLoading: _isLoading, - onTap: () => _onEdit(item), - ), - ThemedActionButton.delete( - labelText: i18n.t('actions.delete'), - isLoading: _isLoading, - onTap: () => _onDelete(item), - ), - ], -) -``` - -### With multiselect - -```dart -// State -final ValueNotifier> _selected = ValueNotifier([]); - -// Widget -ThemedTable2( - items: _items, - hasMultiselect: true, - actionsCount: 0, - multiselectValue: _selected, - multiselectActions: [ - ThemedActionButton( - icon: LayrzIcons.solarOutlineTrashBin, - labelText: i18n.t('actions.deleteSelected'), - color: Colors.red, - onTap: () => _onDeleteSelected(_selected.value), - ), - ], - columns: [ /* ... */ ], -) -``` - -### With richTextBuilder - -```dart -// State — precompute labels to avoid capturing i18n in valueBuilder -final Map _fuelLabels = { - for (final type in AtsFuelSubType.values) - if (type != AtsFuelSubType.unknown) - type.toJson(): i18n.t(type.getLocaleKey()), -}; - -// Widget -ThemedColumn2( - headerText: i18n.t('cacl.product'), - // valueBuilder must return a plain string — used for sort and search - valueBuilder: (item) => _fuelLabels[item.product] ?? 'N/A', - // richTextBuilder only affects visual rendering — not sort - richTextBuilder: (item) { - final type = AtsFuelSubType.fromJson(item.product ?? ''); - if (type == AtsFuelSubType.unknown) return [const TextSpan(text: 'N/A')]; - return [WidgetSpan(child: type.chip(i18n: i18n))]; - }, -) -``` - -### With programmatic controller - -```dart -// State -final _controller = ThemedTable2Controller(); - -@override -void dispose() { - _controller.dispose(); - super.dispose(); -} - -// Widget -ThemedTable2( - items: _items, - controller: _controller, - columns: [ /* ... */ ], - actionsCount: 0, - hasMultiselect: false, -) - -// Programmatic sort (column index 0, ascending) -_controller.sort(columnIndex: 0, ascending: true); - -// Programmatic refresh (re-runs filter + sort with current state) -_controller.refresh(); -``` - -### Disable copy-to-clipboard on cell tap - -```dart -ThemedTable2( - items: _items, - onTapDefaultBehavior: ThemedTable2OnTapBehavior.none, - columns: [ /* ... */ ], - actionsCount: 0, - hasMultiselect: false, -) -``` - ---- - -## ⚠️ Isolate Safety — CRITICAL - -`valueBuilder` and `customSort` are executed inside a background isolate via `compute()`. **They cannot capture any Flutter objects from the widget tree.** If they do, the app will crash at runtime with: - -``` -Invalid argument(s): Illegal argument in isolate message: object is unsendable -``` - -### What you CANNOT capture in valueBuilder or customSort - -- `BuildContext` (directly or indirectly) -- `LayrzAppLocalizations` / `i18n` — it's an `InheritedWidget` that holds a reference to `BuildContext` -- `State` objects -- Streams, `AnimationController`, `ValueNotifier` -- Any Flutter widget or element - -### Safe patterns - -```dart -// ❌ CRASH — i18n captures BuildContext -ThemedColumn2( - headerText: i18n.t('asset.status'), - valueBuilder: (item) => i18n.t('status.${item.status}'), -) - -// ✅ SAFE — precompute a simple Map of strings outside the column -final Map statusLabels = { - 'active': i18n.t('status.active'), - 'inactive': i18n.t('status.inactive'), - 'archived': i18n.t('status.archived'), -}; - -ThemedColumn2( - headerText: i18n.t('asset.status'), - valueBuilder: (item) => statusLabels[item.status] ?? item.status ?? 'N/A', -) - -// ✅ SAFE — pure function, no captured Flutter objects -ThemedColumn2( - headerText: 'Name', - valueBuilder: (item) => item.name.toUpperCase(), -) - -// ✅ SAFE — customSort with no captured state -ThemedColumn2( - headerText: 'Created', - valueBuilder: (item) => item.createdAt?.toIso8601String() ?? '', - customSort: (a, b, ascending) { - final dtA = a.createdAt ?? DateTime(0); - final dtB = b.createdAt ?? DateTime(0); - return ascending ? dtA.compareTo(dtB) : dtB.compareTo(dtA); - }, -) -``` - -### Rule of thumb - -> If you need translated labels in a column, build a `Map` from `i18n` **before** the `ThemedTable2(...)` call and reference that map inside `valueBuilder`. The map is a plain Dart object — sendable. `i18n` itself is not. - ---- - -## Anti-patterns - -| Anti-pattern | Why it's wrong | Fix | -|---|---|---| -| `valueBuilder: (item) => i18n.t(item.key)` | Captures `i18n` → `BuildContext` → non-sendable, runtime crash | Precompute a `Map` from `i18n` outside the column | -| `customSort` capturing `context` or `store` | Same crash as above | Use only item fields inside `customSort` | -| `actionsCount: 2` without `actionsBuilder` | Assert failure at construction | Always provide `actionsBuilder` when `actionsCount > 0` | -| `hasMultiselect: true` with empty `multiselectActions` | Assert failure at construction | Always provide at least one `multiselectAction` | -| Two columns with the same `headerText` | Header tooltip and sort icon display are ambiguous | Use distinct `headerText` values, or add `width` to differentiate | -| Calling `ThemedTable2` without wrapping in `Expanded` inside a `Column` | Table needs bounded height to render — will overflow or crash layout | Always wrap in `Expanded` or give it a fixed `height` | -| Using `item.hashCode` as a key in external Maps | `hashCode` can collide; use `identityHashCode(item)` for identity-based keys | Use `identityHashCode(item)` or a unique field like `item.id` | - ---- - -## Controller API - -```dart -final controller = ThemedTable2Controller(); - -// Sort by column index -controller.sort(columnIndex: 0, ascending: true); - -// Force re-filter and re-sort (useful when items changed externally) -controller.refresh(); - -// Always dispose -controller.dispose(); -``` - -The controller communicates via an event bus (`ThemedTable2SortEvent`, `ThemedTable2RefreshEvent`). Wire it up via the `controller` parameter on `ThemedTable2`. - ---- - -## Integration in a module view - -```dart -class _AssetsViewState extends State { - // State — precompute translated labels for isolate-safe valueBuilders - late final Map _categoryLabels = { - for (final cat in store.categories) - cat.id: cat.name, - }; - - @override - Widget build(BuildContext context) { - final i18n = LayrzAppLocalizations.of(context); - - return Expanded( - child: ThemedTable2( - items: store.assets, - canSearch: true, - actionsCount: 3, - hasMultiselect: true, - multiselectActions: [ - ThemedActionButton( - icon: LayrzIcons.solarOutlineTrashBin, - labelText: i18n.t('actions.deleteSelected'), - color: Colors.red, - onTap: _onDeleteSelected, - ), - ], - columns: [ - ThemedColumn2( - headerText: 'ID', - width: 80, - valueBuilder: (item) => item.id ?? 'N/A', - ), - ThemedColumn2( - headerText: i18n.t('asset.name'), - valueBuilder: (item) => item.name, - ), - ThemedColumn2( - headerText: i18n.t('asset.category'), - // Safe: _categoryLabels is a plain Map, not a Flutter object - valueBuilder: (item) => _categoryLabels[item.categoryId] ?? 'N/A', - ), - ], - actionsBuilder: (item) => [ - ThemedActionButton.show( - labelText: i18n.t('actions.show'), - onTap: () => _onShow(item), - ), - ThemedActionButton.edit( - labelText: i18n.t('actions.edit'), - onTap: () => _onEdit(item), - ), - ThemedActionButton.delete( - labelText: i18n.t('actions.delete'), - onTap: () => _onDelete(item), - ), - ], - ), - ); - } -} -``` diff --git a/.claude-plugin/skills/themed-tabview/SKILL.md b/.claude-plugin/skills/themed-tabview/SKILL.md deleted file mode 100644 index 97117c1..0000000 --- a/.claude-plugin/skills/themed-tabview/SKILL.md +++ /dev/null @@ -1,577 +0,0 @@ ---- -name: themed-tabview -description: ThemedTabView and ThemedTab — Material 3 styled tab navigation with flexible layouts and callbacks ---- - -## Overview - -`ThemedTabView` is the layrz_theme tab navigation widget. It combines a horizontal tab bar with a content view that switches based on the selected tab. All tabs are built upfront (not lazy-loaded), making it suitable for a small to medium number of tabs. - -| Feature | Details | -|---|---| -| Tab Styles | Two styles: `filledTonal` (default, Material 3 filled buttons) and `underline` (text-based with underline) | -| Scrollable TabBar | Horizontal scroll for many tabs | -| Optional Arrows | Left/right navigation buttons for non-scrollable contexts | -| Additional Widgets | Extra widgets (filters, buttons) can be placed next to the tab bar | -| Callbacks | `onTabIndex` fires when tab selection changes | -| State Persistence | `persistTabPosition` controls whether selected tab is preserved on widget rebuild | -| Custom Alignment | Control padding and alignment of the tab bar and content area | -| Icons & Labels | Tabs support leading/trailing icons alongside text labels | - -**When to use:** Any horizontal navigation scenario — settings tabs, data views, dialogs with multiple sections. Keep tab count reasonable (< 10 recommended). - ---- - -## ThemedTab - -Defines a single tab's appearance and content. - -### Key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | required (OR `label`) | Tab label text. Use for simple text labels. **Either `labelText` or `label` must be provided** | -| `label` | `Widget?` | required (OR `labelText`) | Custom label widget. Use for complex label layouts (e.g., with badges) | -| `child` | `Widget` | `SizedBox()` | Content widget displayed when this tab is active. Required in practice | -| `leadingIcon` | `IconData?` | `null` | Icon displayed before the label. Use `leading` for custom widgets | -| `leading` | `Widget?` | `null` | Custom widget displayed before the label. Prefer `leadingIcon` for simplicity | -| `trailingIcon` | `IconData?` | `null` | Icon displayed after the label. Use `trailing` for custom widgets | -| `trailing` | `Widget?` | `null` | Custom widget displayed after the label. Prefer `trailingIcon` for simplicity | -| `iconSize` | `double` | `30` | Size for leading/trailing icons | -| `padding` | `EdgeInsets` | `all(10)` | Padding around the tab's entire label area | -| `color` | `Color?` | `null` | Override the tab's color (used for active state detection). If not provided, uses the theme's primary color | -| `style` | `ThemedTabStyle` | `.filledTonal` | Visual style. Inherited from parent `ThemedTabView.style` via `overrideStyle()` | - -### Minimal tab - -```dart -ThemedTab( - labelText: 'Settings', - child: SettingsPanel(), -) -``` - -### Tab with leading icon - -```dart -ThemedTab( - labelText: 'Dashboard', - leadingIcon: Icons.dashboard, - child: DashboardView(), -) -``` - -### Tab with custom label widget - -```dart -ThemedTab( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.notifications), - const SizedBox(width: 8), - const Text('Alerts'), - // Custom badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(10), - ), - child: const Text('3', style: TextStyle(color: Colors.white, fontSize: 10)), - ), - ], - ), - child: AlertsView(), -) -``` - ---- - -## ThemedTabView - -The container widget that manages tab selection and displays both the tab bar and content area. - -### Key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `tabs` | `List` | required | List of tabs to display. All tabs are built upfront (not lazy). Minimum 1 tab | -| `style` | `ThemedTabStyle` | `.filledTonal` | Visual style for all tabs. Options: `.filledTonal`, `.underline` | -| `initialPosition` | `int` | `0` | Starting tab index. **Clamped to valid range** — invalid indices don't crash | -| `onTabIndex` | `Function(int)?` | `null` | Callback fired when user taps a different tab. Receives the new tab index (0-based) | -| `persistTabPosition` | `bool` | `true` | If `true`, the selected tab is remembered when the widget list changes. If `false`, resets to tab 0 when `tabs.length` changes | -| `animationDuration` | `Duration` | `250ms` | Duration of the tab switch animation | -| `showArrows` | `bool` | `false` | If `true`, left/right arrow buttons appear for manual tab navigation | -| `wrapArrowNavigation` | `bool` | `false` | If `true`, arrows wrap around: left arrow on first tab goes to last, right arrow on last tab goes to first. If `false`, arrows are disabled at boundaries. Only applies when `showArrows: true` | -| `additionalWidgets` | `List` | `[]` | Extra widgets (filters, search, etc.) placed next to the tab bar on the right | -| `padding` | `EdgeInsetsGeometry` | `all(10)` | Padding around the entire tab view (bar + content) | -| `separatorPadding` | `EdgeInsetsGeometry` | `only(top: 10)` | Padding between the tab bar and content area | -| `crossAxisAlignment` | `CrossAxisAlignment` | `.start` | Vertical alignment of the content area | -| `mainAxisAlignment` | `MainAxisAlignment` | `.start` | Horizontal alignment of the content area (rarely used) | -| `physics` | `ScrollPhysics?` | `null` | Scroll physics for the `TabBarView` content area | - -### Minimal tab view - -```dart -ThemedTabView( - tabs: [ - ThemedTab( - labelText: 'Home', - child: HomePage(), - ), - ThemedTab( - labelText: 'Settings', - child: SettingsPage(), - ), - ], -) -``` - -### With arrows and custom styling - -```dart -ThemedTabView( - showArrows: true, - style: .underline, - initialPosition: 1, - tabs: [ - ThemedTab(labelText: 'Tab A', child: ContentA()), - ThemedTab(labelText: 'Tab B', child: ContentB()), - ThemedTab(labelText: 'Tab C', child: ContentC()), - ], -) -``` - -### With circular arrow navigation - -```dart -ThemedTabView( - showArrows: true, - wrapArrowNavigation: true, // Wrap around at boundaries - tabs: [ - ThemedTab(labelText: 'Tab 1', child: Content1()), - ThemedTab(labelText: 'Tab 2', child: Content2()), - ThemedTab(labelText: 'Tab 3', child: Content3()), - ], -) -// Left arrow on Tab 1 → Tab 3 -// Right arrow on Tab 3 → Tab 1 -``` - -### With callbacks and additional widgets - -```dart -ThemedTabView( - onTabIndex: (index) { - print('User switched to tab $index'); - // Update analytics, state, etc. - }, - additionalWidgets: [ - SearchBar(), - FilterButton(), - ], - tabs: [ - ThemedTab(labelText: 'All', child: AllItemsView()), - ThemedTab(labelText: 'Favorites', child: FavoritesView()), - ], -) -``` - -### With icons and custom padding - -```dart -ThemedTabView( - padding: const EdgeInsets.all(20), - separatorPadding: const EdgeInsets.symmetric(vertical: 16), - tabs: [ - ThemedTab( - labelText: 'Profile', - leadingIcon: Icons.person, - child: ProfileView(), - ), - ThemedTab( - labelText: 'Orders', - leadingIcon: Icons.shopping_bag, - child: OrdersView(), - ), - ], -) -``` - ---- - -## Styling - -### ThemedTabStyle enum - -```dart -enum ThemedTabStyle { - filledTonal, // Material 3 filled tonal button (default) - underline, // Text-based with bottom underline when active -} -``` - -### filledTonal style - -- Active tab: filled background with 20% opacity of the tab color -- Inactive tab: transparent background -- Animation: smooth 200ms fade when tab changes -- Best for: Dashboard-style multi-tab interfaces - -### underline style - -- Active tab: bottom underline (via TabBar indicator) -- Inactive tab: no underline -- Animation: smooth 250ms scroll animation -- Best for: Document-style tabs, minimal design - ---- - -## Gotchas & Edge Cases - -### 1. **All Tabs Are Built Upfront** - -`ThemedTabView` builds all tab content widgets immediately, not lazily. This is fine for 2–5 tabs, but for 10+ tabs or expensive content widgets, consider: -- Lazy loading within each tab's `child` widget -- Using `visibility`-aware widgets to defer expensive work - -```dart -// Good: Each tab's content is lightweight, or loads data on first interaction -ThemedTab( - labelText: 'Analytics', - child: Builder( - builder: (context) { - // This builder only runs when the widget is first mounted - // Content loading happens inside this widget via FutureBuilder, StreamBuilder, etc. - return AnalyticsPanel(); - }, - ), -) -``` - -### 2. **Invalid initialPosition is Clamped, Not Rejected** - -If `initialPosition >= tabs.length`, it's clamped to the last valid index. No exception is thrown. - -```dart -// initialPosition: 10, but only 3 tabs → starts at tab 2 (not crash) -ThemedTabView( - initialPosition: 10, - tabs: [tab1, tab2, tab3], -) -``` - -### 3. **persistTabPosition Requires tabs.length Change** - -`persistTabPosition: false` only resets to tab 0 if the **number of tabs changes**. If the widget rebuilds with the same `tabs.length`, the tab index is preserved. - -```dart -// Scenario 1: persistTabPosition: true + tabs.length changes → remembers old index -// Scenario 2: persistTabPosition: false + tabs.length changes → resets to tab 0 -// Scenario 3: Widget rebuilds but tabs.length unchanged → index is preserved (regardless of persistTabPosition) -``` - -### 4. **onTabIndex Doesn't Fire on Initial Load** - -The `onTabIndex` callback is only called when the user **changes** the tab, not when the widget first mounts. - -```dart -// This callback won't fire initially; you need to handle initialPosition separately -onTabIndex: (index) { - print('User switched to $index'); - // NOT called if initialPosition was 0 and no user interaction yet -} -``` - -### 5. **Arrow Button State Updates Reactively During Navigation** - -When `showArrows: true` and `wrapArrowNavigation: false` (default), arrow buttons automatically enable/disable at tab boundaries. This is reactive — the button state updates every time the tab index changes, even without an `onTabIndex` callback. The widget rebuilds internally to reflect the current position. - -```dart -// Scenario: Currently on first tab, right arrow enabled, left arrow disabled -// User taps right arrow → navigates to second tab → both arrows become enabled (automatically) -// User taps right arrow → navigates to third tab (last) → left arrow enabled, right arrow disabled - -ThemedTabView( - showArrows: true, - wrapArrowNavigation: false, // Default: arrows disabled at boundaries - tabs: [tab1, tab2, tab3], -) -// The arrow disabled/enabled state is always correct, no manual state management needed -``` - -If you need `wrapArrowNavigation: true` for circular navigation, both arrows stay enabled at all times. - -### 6. **Color Detection is RGB-Based** - -The active tab highlight color is determined by comparing the tab's `color` to the theme's primary color (RGB + Alpha match required). If your tabs don't have explicit colors, they use `DefaultTextStyle.of(context).style.color`, which might be `null` and default to primary. - -```dart -// Custom colors for different tabs -ThemedTab( - labelText: 'Custom', - color: Colors.purple, // Will show as active if this matches the primary color - child: CustomView(), -) -``` - -### 7. **Tab Bar Text is RichText** - -Tab labels are rendered with `RichText` and `TextSpan`, not plain `Text`. This means: -- Custom styling per part of the label (e.g., icon + text) is easy -- Finding tabs by text in widget tests requires custom finders (see Testing below) - ---- - -## Best Practices - -### 1. **Use `persistTabPosition: true` for Settings/Navigation Tabs** - -If users might apply settings or make selections in a tab, remember their position when they return. - -```dart -ThemedTabView( - persistTabPosition: true, // Preserve tab selection across rebuilds - tabs: [ - ThemedTab(labelText: 'General', child: GeneralSettings()), - ThemedTab(labelText: 'Advanced', child: AdvancedSettings()), - ], -) -``` - -### 2. **Use `persistTabPosition: false` for Filtered/Dynamic Tab Lists** - -If the tab list changes dynamically (e.g., filtering by category), reset to the first tab. - -```dart -ThemedTabView( - persistTabPosition: false, // Reset when categories change - tabs: activeCategories.map((cat) => - ThemedTab(labelText: cat.name, child: CategoryView(cat)) - ).toList(), -) -``` - -### 3. **Keep Tabs Lightweight; Defer Expensive Work** - -Don't do heavy computation in tab content build methods. Use `FutureBuilder` or `StreamBuilder` inside each tab's `child`. - -```dart -// Bad: Expensive work in the child widget's build method -ThemedTab( - labelText: 'Reports', - child: ReportGenerator(), // ← This runs upfront for ALL tabs -) - -// Good: Lazy loading inside the tab -ThemedTab( - labelText: 'Reports', - child: FutureBuilder( - future: _loadReports(), - builder: (context, snapshot) { - if (!snapshot.hasData) return const CircularProgressIndicator(); - return ReportView(snapshot.data!); - }, - ), -) -``` - -### 4. **Use Arrows Only When Tab Bar Doesn't Scroll** - -The `showArrows` buttons are for non-scrollable tab bar scenarios. If your tabs fit on screen or the `TabBar` is already scrollable, skip arrows. - -```dart -// With arrows: controlled, button-based navigation -ThemedTabView( - showArrows: true, - tabs: many_tabs, -) - -// Without arrows: natural scrolling (TabBar is isScrollable: true) -ThemedTabView( - showArrows: false, // Let TabBar handle horizontal scroll - tabs: many_tabs, -) -``` - -### 4b. **Choose Between Linear and Wrap Arrow Navigation** - -When using `showArrows: true`, decide if arrows should disable at boundaries or wrap around: - -```dart -// Linear navigation (default): arrows disabled at first/last tabs -ThemedTabView( - showArrows: true, - wrapArrowNavigation: false, - tabs: [tab1, tab2, tab3], -) -// On first tab: left arrow disabled, right arrow enabled -// On last tab: left arrow enabled, right arrow disabled - -// Circular navigation: arrows wrap around -ThemedTabView( - showArrows: true, - wrapArrowNavigation: true, - tabs: [tab1, tab2, tab3], -) -// On first tab: left arrow goes to last tab, right arrow goes to second tab -// On last tab: left arrow goes to previous tab, right arrow goes to first tab -// Both arrows always enabled -``` - -### 5. **Pass Constants for Theme Consistency** - -Use inherited theme values for spacing and animation durations rather than magic numbers. - -```dart -ThemedTabView( - animationDuration: Theme.of(context).pageTransitionsTheme.buildTransitions == null - ? const Duration(milliseconds: 250) - : const Duration(milliseconds: 300), - padding: const EdgeInsets.all(16), // Match Material 3 spacing - tabs: tabs, -) -``` - -### 6. **Combine with LayoutBuilder for Responsive Tabs** - -If tab list or additional widgets should change based on screen size, wrap in `LayoutBuilder`. - -```dart -LayoutBuilder( - builder: (context, constraints) { - return ThemedTabView( - showArrows: constraints.maxWidth < 600, // Show arrows on mobile - additionalWidgets: constraints.maxWidth > 960 - ? [SearchBar(), FilterButton()] - : [], - tabs: tabs, - ); - }, -) -``` - ---- - -## Testing - -When writing widget tests for tabs: - -1. **Find tabs by RichText content**, not plain text: - ```dart - Finder findTabByText(String text) { - return find.byWidgetPredicate((widget) { - if (widget is RichText) { - return widget.text.toPlainText().contains(text); - } - return false; - }); - } - - await tester.tap(findTabByText('Settings')); - await tester.pumpAndSettle(); - ``` - -2. **Verify tab content changes** after tapping: - ```dart - expect(find.text('Settings Content'), findsOneWidget); - ``` - -3. **Test callbacks**: - ```dart - int? lastIndex; - await tester.pumpWidget( - ThemedTabView( - onTabIndex: (index) { lastIndex = index; }, - tabs: tabs, - ), - ); - await tester.tap(findTabByText('Tab 2')); - await tester.pumpAndSettle(); - expect(lastIndex, equals(1)); - ``` - ---- - -## Common Patterns - -### Pattern: Analytics Tracking - -```dart -ThemedTabView( - onTabIndex: (index) { - analytics.logEvent( - name: 'tab_switched', - parameters: {'tab_index': index, 'tab_name': tabs[index].labelText}, - ); - }, - tabs: tabs, -) -``` - -### Pattern: Save User Preference - -```dart -ThemedTabView( - initialPosition: savedTabIndex, // Load from SharedPreferences - onTabIndex: (index) { - prefs.setInt('last_tab', index); // Save on change - }, - tabs: tabs, -) -``` - -### Pattern: Conditional Tab Visibility - -```dart -ThemedTabView( - tabs: [ - if (user.isAdmin) ...[ - ThemedTab(labelText: 'Admin Panel', child: AdminView()), - ], - if (showDebugTabs) ...[ - ThemedTab(labelText: 'Debug', child: DebugView()), - ], - ThemedTab(labelText: 'Profile', child: ProfileView()), - ], -) -``` - -### Pattern: Dynamic Tab Title with Badge - -```dart -ThemedTab( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Notifications'), - if (notificationCount > 0) ...[ - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '$notificationCount', - style: const TextStyle(color: Colors.white, fontSize: 10), - ), - ), - ], - ], - ), - child: NotificationsView(), -) -``` - ---- - -## Summary - -- **Use `ThemedTabView` for horizontal tab navigation** with Material 3 styling -- **Keep tabs lightweight**; defer expensive work to each tab's content widget -- **Use `persistTabPosition: true` for settings**, `false` for dynamic tab lists -- **Test with custom RichText finders**, not plain text finds -- **Combine with `LayoutBuilder` for responsive designs** diff --git a/.claude-plugin/skills/time-pickers/SKILL.md b/.claude-plugin/skills/time-pickers/SKILL.md deleted file mode 100644 index cf08233..0000000 --- a/.claude-plugin/skills/time-pickers/SKILL.md +++ /dev/null @@ -1,283 +0,0 @@ ---- -name: time-pickers -description: Use ThemedTimePicker or ThemedTimeRangePicker in a layrz Flutter widget. Apply when adding a time-of-day selection field. ---- - -## Overview - -Two components cover all time-of-day selection needs: - -| Component | State type | `onChanged` signature | When to use | -|---|---|---|---| -| `ThemedTimePicker` | `TimeOfDay?` | `void Function(TimeOfDay)` | Single time value | -| `ThemedTimeRangePicker` | `List` (empty or exactly 2) | `void Function(List)` | Start + end time pair | - -Both open a custom dialog with hour/minute spinners (+/− buttons on desktop, direct keyboard input on mobile). Never use Flutter's built-in `showTimePicker` — always use these components. - ---- - -## ThemedTimePicker — single time value - -### Minimal usage - -```dart -// State -TimeOfDay? selectedTime; - -// Widget -ThemedTimePicker( - labelText: context.i18n.t('entity.fieldName'), - value: selectedTime, - onChanged: (time) { - if (context.mounted) setState(() => selectedTime = time); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Mutually exclusive with `label`. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Mutually exclusive with `labelText`. | -| `value` | `TimeOfDay?` | `null` | Currently selected time; `null` renders placeholder | -| `onChanged` | `void Function(TimeOfDay)?` | `null` | Callback — receives the confirmed time (never null) | -| `use24HourFormat` | `bool` | `false` | `true` = 24 h spinners; `false` = 12 h + AM/PM toggle | -| `pattern` | `String?` | `null` | Display format string. Defaults to `'%H:%M'` (24 h) or `'%I:%M %p'` (12 h) | -| `disableBlink` | `bool` | `false` | Disables the 700 ms blink animation on the hour/minute display | -| `disabled` | `bool` | `false` | Greys out and disables tap | -| `errors` | `List` | `[]` | Validation error messages shown below the field | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row | -| `dense` | `bool` | `false` | Reduces vertical padding | -| `padding` | `EdgeInsets?` | `null` | Outer padding override | -| `placeholder` | `String?` | `null` | Placeholder text shown when `value` is null | -| `prefixText` | `String?` | `null` | Static text prefix inside the field | -| `prefixIcon` | `IconData?` | `null` | Icon prefix. Mutually exclusive with `prefixWidget`. | -| `prefixWidget` | `Widget?` | `null` | Widget prefix. Mutually exclusive with `prefixIcon`. | -| `onPrefixTap` | `VoidCallback?` | `null` | Callback when the prefix is tapped | -| `customChild` | `Widget?` | `null` | Replaces the text field entirely; tapping it opens the dialog | -| `hoverColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set | -| `focusColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set | -| `splashColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set | -| `highlightColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set | -| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Only applies when `customChild` is set | -| `translations` | `Map` | see below | Fallback strings when `LayrzAppLocalizations` is absent | -| `overridesLayrzTranslations` | `bool` | `false` | Forces `translations` map over `LayrzAppLocalizations` | - -### Behavior notes - -- The dialog opens via `showDialog`. It initializes the spinner to `value` if set, or `TimeOfDay.now()` if `value` is null. -- `onChanged` is only called when the user taps **Save** — not on every spinner change. -- The suffix icon (`LayrzIcons.solarOutlineClockSquare`) is always rendered; it is not configurable. -- On desktop the dialog shows +/− buttons alongside each spinner. On mobile (width < `kSmallGrid`) the buttons are hidden and the user types digits directly. -- `disableBlink: true` is useful in automated tests or accessibility-focused contexts where the 700 ms blink animation is distracting. - -### Common patterns - -```dart -// 24-hour mode -ThemedTimePicker( - labelText: context.i18n.t('schedule.startTime'), - value: model.startTime, - use24HourFormat: true, - onChanged: (time) { - model.startTime = time; - if (context.mounted) onChanged.call(); - }, -) - -// Custom display format (hours only) -ThemedTimePicker( - labelText: context.i18n.t('shift.hour'), - value: model.hour, - use24HourFormat: true, - pattern: '%H:00', - onChanged: (time) { - model.hour = TimeOfDay(hour: time.hour, minute: 0); - if (context.mounted) onChanged.call(); - }, -) - -// Custom trigger widget -ThemedTimePicker( - labelText: context.i18n.t('alarm.time'), - value: selectedTime, - customChild: Chip(label: Text(selectedTime?.format(context) ?? 'Set time')), - onChanged: (time) { - if (context.mounted) setState(() => selectedTime = time); - }, -) -``` - ---- - -## ThemedTimeRangePicker — start + end time pair - -### Minimal usage - -```dart -// State — always empty or exactly 2 elements -List timeRange = []; - -// Widget -ThemedTimeRangePicker( - labelText: context.i18n.t('entity.timeRange'), - value: timeRange, - onChanged: (range) { - if (context.mounted) setState(() => timeRange = range); - }, -) -``` - -### Constructor — key parameters - -| Parameter | Type | Default | Notes | -|---|---|---|---| -| `labelText` | `String?` | — | Required unless `label` is provided. Mutually exclusive with `label`. | -| `label` | `Widget?` | — | Required unless `labelText` is provided. Mutually exclusive with `labelText`. | -| `value` | `List` | `const []` | Must be empty or exactly 2 elements — enforced by assert | -| `onChanged` | `void Function(List)?` | `null` | Callback — receives sorted `[start, end]` pair; never called with 0 or 1 elements | -| `use24HourFormat` | `bool` | `false` | Propagated to both inner time utility widgets | -| `pattern` | `String?` | `null` | Display format. Defaults to `'%H:%M'` (24 h) or `'%I:%M %p'` (12 h) | -| `disableBlink` | `bool` | `false` | Disables blink animation in both spinners | -| `disabled` | `bool` | `false` | Greys out and disables tap | -| `errors` | `List` | `[]` | Validation error messages shown below the field | -| `hideDetails` | `bool` | `false` | Hides the errors/hints row | -| `padding` | `EdgeInsets?` | `null` | Outer padding override | -| `placeholder` | `String?` | `null` | Placeholder text when `value` is empty | -| `prefixText` | `String?` | `null` | Static text prefix inside the field | -| `prefixIcon` | `IconData?` | `null` | Icon prefix. Mutually exclusive with `prefixWidget`. | -| `prefixWidget` | `Widget?` | `null` | Widget prefix. Mutually exclusive with `prefixIcon`. | -| `onPrefixTap` | `VoidCallback?` | `null` | Callback when the prefix is tapped | -| `customChild` | `Widget?` | `null` | Replaces the text field entirely; tapping it opens the dialog | -| `hoverColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set | -| `focusColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set | -| `splashColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set | -| `highlightColor` | `Color` | `Colors.transparent` | Only applies when `customChild` is set | -| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Only applies when `customChild` is set | -| `translations` | `Map` | see below | Fallback strings when `LayrzAppLocalizations` is absent | -| `overridesLayrzTranslations` | `bool` | `false` | Forces `translations` map over `LayrzAppLocalizations` | - -### Behavior notes - -- The dialog renders two `_ThemedTimeUtility` spinners stacked vertically (Start / End), each updating an independent local `TimeOfDay?` variable. -- **Auto-sort**: before calling `onChanged`, the two values are sorted by hour then minute. The list you receive in `onChanged` is always `[earliest, latest]` — you do not need to sort manually. -- `onChanged` is only called if **both** start and end have been set when the user taps Save. If either is still null, the callback is not fired. -- The displayed field text is `"HH:MM - HH:MM"` (formatted with `pattern`). When `value` is empty the field is blank/placeholder. -- Dialog constraints: `maxWidth: 400`, `maxHeight: 430` (24 h) or `550` (12 h — extra height for AM/PM toggles). - -### Common patterns - -```dart -// 24-hour range with form integration -ThemedTimeRangePicker( - labelText: context.i18n.t('shift.operatingHours'), - value: model.operatingHours, - use24HourFormat: true, - errors: context.getErrors(key: 'operatingHours'), - onChanged: (range) { - model.operatingHours = range; - if (context.mounted) onChanged.call(); - }, -) - -// Pre-populate with existing range (must be exactly 2 elements) -final existingRange = [TimeOfDay(hour: 8, minute: 0), TimeOfDay(hour: 17, minute: 0)]; - -ThemedTimeRangePicker( - labelText: context.i18n.t('schedule.window'), - value: existingRange, - onChanged: (range) { - if (context.mounted) setState(() => selectedRange = range); - }, -) -``` - ---- - -## 12h vs 24h format - -| Aspect | 12 h (`use24HourFormat: false`, default) | 24 h (`use24HourFormat: true`) | -|---|---|---| -| Hours spinner range | 1–12 (period-relative) | 0–23 | -| AM/PM toggle | Shown below spinners | Hidden | -| Default `pattern` | `'%I:%M %p'` | `'%H:%M'` | -| Dialog max height (range) | 550 px | 430 px | -| `TimeOfDay.hour` returned | Always 0–23 (Flutter internal) | Always 0–23 (Flutter internal) | - -The `pattern` parameter uses `DateTime.format()` from the `layrz_theme` extension — not `DateFormat`. Use `%I` for 12 h hours, `%H` for 24 h hours, `%M` for minutes, `%p` for AM/PM. - -To override only the display pattern without changing the spinner behavior, pass `pattern` independently of `use24HourFormat`: - -```dart -// 24 h spinners but display as "08h30" -ThemedTimePicker( - labelText: context.i18n.t('departure.time'), - value: model.departureTime, - use24HourFormat: true, - pattern: '%Hh%M', - onChanged: (time) { - model.departureTime = time; - if (context.mounted) onChanged.call(); - }, -) -``` - ---- - -## Translation keys - -Both components resolve text via `LayrzAppLocalizations` first, then fall back to the `translations` map, then fall back to the key string itself. - -| Key | Default English | Used by | -|---|---|---| -| `actions.cancel` | `Cancel` | Both | -| `actions.save` | `Save` | Both | -| `layrz.timePicker.hours` | `Hours` | Both (column label) | -| `layrz.timePicker.minutes` | `Minutes` | Both (column label) | -| `layrz.timePicker.start` | `Start time` | `ThemedTimeRangePicker` only | -| `layrz.timePicker.end` | `End time` | `ThemedTimeRangePicker` only | - -When `LayrzAppLocalizations` is configured project-wide you do not need to pass `translations`. Supply it only in isolated widgets or tests that lack the localizations delegate. - -To force your own strings over the app-wide delegate (rare), set `overridesLayrzTranslations: true` and provide all required keys in `translations`. - ---- - -## Integrating with layrz forms - -### Single time - -```dart -ThemedTimePicker( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, - errors: context.getErrors(key: 'fieldName'), - onChanged: (time) { - object.fieldName = time; - if (context.mounted) onChanged.call(); - }, -) -``` - -### Time range - -```dart -ThemedTimeRangePicker( - labelText: context.i18n.t('entity.fieldName'), - value: object.fieldName, // List, empty or exactly 2 elements - errors: context.getErrors(key: 'fieldName'), - onChanged: (range) { - object.fieldName = range; // already sorted [start, end] - if (context.mounted) onChanged.call(); - }, -) -``` - -### Guards and constraints - -- Always guard `onChanged` with `if (context.mounted)` before calling any state mutation or external callback. -- `label` and `labelText` are mutually exclusive — the constructor enforces this with an assert. Pick one. -- `prefixIcon` and `prefixWidget` are mutually exclusive — never supply both. -- The `value` of `ThemedTimeRangePicker` must satisfy `value.length == 0 || value.length == 2`. Never pass a single-element list; the assert will throw in debug mode. -- Do not call Flutter's `showTimePicker` anywhere in the codebase. Use `ThemedTimePicker` exclusively. diff --git a/.claude/plugin.json b/.claude/plugin.json new file mode 100644 index 0000000..b7a2c2c --- /dev/null +++ b/.claude/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "layrz-theme", + "description": "Claude Code skills for layrz-theme Flutter widget library", + "version": "7.5.27", + "author": { + "name": "Golden M, Inc.", + "url": "https://github.com/goldenm-software" + }, + "repository": "https://github.com/goldenm-software/layrz_theme", + "license": "MIT", + "skills": "./.claude/skills/" +} diff --git a/.claude/skills/responsive-col/SKILL.md b/.claude/skills/responsive-col/SKILL.md new file mode 100644 index 0000000..59f48be --- /dev/null +++ b/.claude/skills/responsive-col/SKILL.md @@ -0,0 +1,73 @@ +--- +name: responsive-col +description: Use ResponsiveCol in a layrz Flutter widget. Apply when defining a column's width at different breakpoints inside a ResponsiveRow. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.col6`, `.start`, `.left`) — never write the fully-qualified form (`Sizes.col6`, `WrapAlignment.start`). + +> **Full constructor, Sizes enum, breakpoints, and examples:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Defining width at different screen sizes for a child inside a `ResponsiveRow` +- Any widget that needs to be full-width on mobile and narrower on larger screens + +Always a child of `ResponsiveRow`. Never used standalone. + +--- + +## Minimal usage + +```dart +ResponsiveCol( + xs: .col12, // full width on mobile + md: .col6, // half width on desktop + child: MyWidget(), +) +``` + +--- + +## Key behaviors + +- `xs` defaults to `.col12` but should always be set explicitly — it's the fallback for all other breakpoints. +- Fallback chain: `xl ?? lg ?? md ?? sm ?? xs` — only set breakpoints that differ. +- Uses `LayoutBuilder` internally — breakpoint is evaluated against the col's own available width, not the screen width. +- Width formula: `(containerWidth / 12) * gridSize`. + +--- + +## Breakpoints + +| Param | Constant | Range | +|---|---|---| +| `xs` | `kExtraSmallGrid = 600` | < 600 px | +| `sm` | `kSmallGrid = 960` | 600–959 px | +| `md` | `kMediumGrid = 1264` | 960–1263 px | +| `lg` | `kLargeGrid = 1904` | 1264–1903 px | +| `xl` | — | ≥ 1904 px | + +--- + +## Common patterns + +```dart +// All breakpoints explicit +ResponsiveCol( + xs: .col12, + sm: .col6, + md: .col4, + lg: .col3, + xl: .col2, + child: ProductCard(), +) + +// Lazy — only set what changes +ResponsiveCol( + xs: .col12, // mobile: full width + lg: .col6, // desktop: half (sm and md also get col12 via fallback) + child: SectionWidget(), +) +``` diff --git a/.claude/skills/responsive-col/references/api.md b/.claude/skills/responsive-col/references/api.md new file mode 100644 index 0000000..6843b56 --- /dev/null +++ b/.claude/skills/responsive-col/references/api.md @@ -0,0 +1,147 @@ +# ResponsiveCol — API Reference + +Sources: +- `ResponsiveCol` — `lib/src/grid/src/col.dart` line 3 +- `Sizes` enum + `SizesExt` — `lib/src/grid/src/sizes.dart` line 11 +- Breakpoint constants — `lib/src/theme/src/constants.dart` + +--- + +## Breakpoint constants + +| Constant | Value | Param | Range | +|---|---|---|---| +| `kExtraSmallGrid` | `600` | `xs` | < 600 px | +| `kSmallGrid` | `960` | `sm` | 600–959 px | +| `kMediumGrid` | `1264` | `md` | 960–1263 px | +| `kLargeGrid` | `1904` | `lg` | 1264–1903 px | +| — | — | `xl` | ≥ 1904 px | + +Fallback chain (from `_currentSize`): + +``` +width < 600 → xs +600 ≤ width < 960 → sm ?? xs +960 ≤ width < 1264 → md ?? sm ?? xs +1264 ≤ width < 1904 → lg ?? md ?? sm ?? xs +width ≥ 1904 → xl ?? lg ?? md ?? sm ?? xs +``` + +--- + +## Constructor + +```dart +const ResponsiveCol({ + super.key, + this.xs = .col12, // Sizes — base fallback for all breakpoints + this.sm, // Sizes? + this.md, // Sizes? + this.lg, // Sizes? + this.xl, // Sizes? + required this.child, // Widget +}) +``` + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `xs` | `Sizes` | `.col12` | < 600 px. Always set explicitly — fallback for all other breakpoints | +| `sm` | `Sizes?` | `null` | 600–959 px. Falls back to `xs` | +| `md` | `Sizes?` | `null` | 960–1263 px. Falls back to `sm → xs` | +| `lg` | `Sizes?` | `null` | 1264–1903 px. Falls back to `md → sm → xs` | +| `xl` | `Sizes?` | `null` | ≥ 1904 px. Falls back to `lg → md → sm → xs` | +| `child` | `Widget` | required | | + +Rendered via `LayoutBuilder` — breakpoint is evaluated against the col's own constraint width. +Width formula: `(containerWidth / 12) * gridSize` + +--- + +## Sizes enum + +Source: `lib/src/grid/src/sizes.dart` line 11 + +```dart +enum Sizes { col1, col2, col3, col4, col5, col6, col7, col8, col9, col10, col11, col12 } +``` + +| Value | Columns | % of parent | +|---|---|---| +| `.col1` | 1 | 8.3% | +| `.col2` | 2 | 16.7% | +| `.col3` | 3 | 25% | +| `.col4` | 4 | 33.3% | +| `.col6` | 6 | 50% | +| `.col8` | 8 | 66.7% | +| `.col9` | 9 | 75% | +| `.col12` | 12 | 100% | + +### SizesExt extension + +```dart +extension SizesExt on Sizes { + double boxWidth(double width) // (width / 12) * gridSize + int get gridSize // column count 1–12 +} +``` + +--- + +## Examples + +```dart +// Minimum — full on mobile, half on desktop +ResponsiveCol( + xs: .col12, + md: .col6, + child: MyWidget(), +) + +// All breakpoints explicit +ResponsiveCol( + xs: .col12, + sm: .col6, + md: .col4, + lg: .col3, + xl: .col2, + child: ProductCard(), +) + +// Lazy — only set what changes from xs +ResponsiveCol( + xs: .col12, // mobile + tablet: full width (sm and md fall back to xs) + lg: .col6, // desktop: half width + child: SectionWidget(), +) + +// Sidebar — narrow on large screens +ResponsiveCol( + xs: .col12, + lg: .col3, + child: Sidebar(), +) + +// Content area — remaining space after sidebar +ResponsiveCol( + xs: .col12, + lg: .col9, + child: MainContent(), +) + +// Divider spanning full width +const ResponsiveCol(child: Divider()) + +// Real example from example app — code editor side by side +ResponsiveCol( + xs: .col12, + md: .col6, + child: ThemedCodeEditor( + labelText: 'Python example', + language: LayrzSupportedLanguage.python, + value: _pythonCode, + onChanged: (val) => setState(() => _pythonCode = val), + ), +) +``` diff --git a/.claude/skills/responsive-row/SKILL.md b/.claude/skills/responsive-row/SKILL.md new file mode 100644 index 0000000..5daf0b7 --- /dev/null +++ b/.claude/skills/responsive-row/SKILL.md @@ -0,0 +1,69 @@ +--- +name: responsive-row +description: Use ResponsiveRow in a layrz Flutter widget. Apply when wrapping ResponsiveCol children in a responsive 12-column grid container. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.col6`, `.start`, `.left`) — never write the fully-qualified form (`Sizes.col6`, `WrapAlignment.start`). + +> **Full constructor and examples:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Wrapping a set of `ResponsiveCol` children in a flex container that reflows automatically +- Needs spacing, horizontal alignment, or vertical alignment between columns +- Building from a dynamic list → use `ResponsiveRow.builder` + +Always paired with `ResponsiveCol` as children. For breakpoint logic and `Sizes` enum, see the `responsive-col` skill. + +--- + +## Minimal usage + +```dart +ResponsiveRow( + children: [ + ResponsiveCol(xs: .col12, md: .col6, child: WidgetA()), + ResponsiveCol(xs: .col12, md: .col6, child: WidgetB()), + ], +) +``` + +--- + +## Key behaviors + +- Renders as `SizedBox(width: double.infinity, child: Wrap(...))` — always full parent width. +- `children` only accepts `List` — use `ResponsiveCol(child: Divider())` for dividers. +- `builder` takes `ResponsiveCol Function(int)` — not `Widget Function(BuildContext, int)`. +- `spacing` is horizontal gap between columns in pixels (default `0`). + +--- + +## Common patterns + +```dart +// With spacing and center alignment +ResponsiveRow( + spacing: 16, + mainAxisAlignment: .center, + crossAxisAlignment: .center, + children: [ + ResponsiveCol(xs: .col12, md: .col6, child: WidgetA()), + ResponsiveCol(xs: .col12, md: .col6, child: WidgetB()), + ], +) + +// Dynamic list with builder +ResponsiveRow.builder( + spacing: 16, + itemCount: items.length, + itemBuilder: (index) => ResponsiveCol( + xs: .col12, + sm: .col6, + lg: .col4, + child: ItemCard(item: items[index]), + ), +) +``` diff --git a/.claude/skills/responsive-row/references/api.md b/.claude/skills/responsive-row/references/api.md new file mode 100644 index 0000000..e09da55 --- /dev/null +++ b/.claude/skills/responsive-row/references/api.md @@ -0,0 +1,105 @@ +# ResponsiveRow — API Reference + +Source: `lib/src/grid/src/row.dart` — `ResponsiveRow` class line 3 + +--- + +## Constructor + +```dart +const ResponsiveRow({ + super.key, + required this.children, // List + this.mainAxisAlignment = .start, + this.crossAxisAlignment = .start, + this.spacing = 0, // double +}) +``` + +## Static builder + +```dart +ResponsiveRow.builder({ + required int itemCount, + required ResponsiveCol Function(int) itemBuilder, // NOT IndexedWidgetBuilder + WrapAlignment mainAxisAlignment = .start, + WrapCrossAlignment crossAxisAlignment = .start, + double spacing = 0, +}) +``` + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `children` | `List` | required | Only `ResponsiveCol`. For dividers: `ResponsiveCol(child: Divider())` | +| `mainAxisAlignment` | `WrapAlignment` | `.start` | Horizontal alignment of columns | +| `crossAxisAlignment` | `WrapCrossAlignment` | `.start` | Vertical alignment of columns | +| `spacing` | `double` | `0` | Horizontal gap between columns in pixels | + +Renders as `SizedBox(width: double.infinity, child: Wrap(...))` — always full parent width. + +--- + +## Examples + +```dart +// Basic row +ResponsiveRow( + children: [ + ResponsiveCol(xs: .col12, md: .col6, child: WidgetA()), + ResponsiveCol(xs: .col12, md: .col6, child: WidgetB()), + ], +) + +// Sidebar + content (3/9 split) +ResponsiveRow( + children: [ + ResponsiveCol(xs: .col12, lg: .col3, child: Sidebar()), + ResponsiveCol(xs: .col12, lg: .col9, child: MainContent()), + ], +) + +// Responsive form — two fields per row on desktop +ResponsiveRow( + spacing: 12, + children: [ + ResponsiveCol(xs: .col12, md: .col6, child: ThemedTextInput(labelText: 'First Name', ...)), + ResponsiveCol(xs: .col12, md: .col6, child: ThemedTextInput(labelText: 'Last Name', ...)), + ResponsiveCol(xs: .col12, child: ThemedTextInput(labelText: 'Email', ...)), + ], +) + +// Dynamic list with builder +ResponsiveRow.builder( + spacing: 16, + itemCount: items.length, + itemBuilder: (index) => ResponsiveCol( + xs: .col12, + sm: .col6, + lg: .col4, + child: ItemCard(item: items[index]), + ), +) + +// Centered card grid +ResponsiveRow( + spacing: 16, + mainAxisAlignment: .center, + crossAxisAlignment: .center, + children: [ + ResponsiveCol(xs: .col12, sm: .col6, md: .col4, child: CardA()), + ResponsiveCol(xs: .col12, sm: .col6, md: .col4, child: CardB()), + ResponsiveCol(xs: .col12, sm: .col6, md: .col4, child: CardC()), + ], +) + +// Divider inside a row +ResponsiveRow( + children: [ + ResponsiveCol(xs: .col12, md: .col6, child: WidgetA()), + const ResponsiveCol(child: Divider()), + ResponsiveCol(xs: .col12, md: .col6, child: WidgetB()), + ], +) +``` diff --git a/.claude/skills/theme-generator/SKILL.md b/.claude/skills/theme-generator/SKILL.md new file mode 100644 index 0000000..8d916ff --- /dev/null +++ b/.claude/skills/theme-generator/SKILL.md @@ -0,0 +1,107 @@ +--- +name: theme-generator +description: Use generateLightTheme and generateDarkTheme in a layrz Flutter app. Apply when wiring MaterialApp.theme / darkTheme or customizing primary color and fonts. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.system`, `.dark`) — never write the fully-qualified form (`ThemeMode.system`). + +> **Full function signatures and parameter reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Wiring `MaterialApp(theme:, darkTheme:)` with the Layrz design standard +- Applying a custom primary color via `mainColor` instead of the default `kPrimaryColor` (#001e60) +- Using a Layrz API palette name (`'BLUE'`, `'TEAL'`, `'RED'`, etc.) via the `theme` parameter +- Replacing the default fonts (Cabin / Fira Sans Condensed) with custom `AppFont` instances via `titleFont` / `bodyFont` +- Never build `ThemeData` from scratch — always use these generators to stay consistent with the Layrz design standard + +--- + +## Minimal usage + +```dart +MaterialApp( + theme: generateLightTheme(), + darkTheme: generateDarkTheme(), + themeMode: .system, + // ... +) +``` + +--- + +## Key behaviors + +- Both functions share the same four parameters: `theme`, `mainColor`, `titleFont`, `bodyFont`. +- `theme` defaults to `"CUSTOM"` — when `"CUSTOM"`, `mainColor` is used to generate the palette. For any other Layrz API name, `mainColor` is ignored. +- `mainColor` defaults to `kPrimaryColor` (`Color(0xFF001e60)`). +- Title font defaults to **Cabin** from Google Fonts; body font defaults to **Fira Sans Condensed** from Google Fonts. +- For non-Google-Fonts `AppFont` values, call `await preloadFont(AppFont(...))` for each font **before** `runApp(...)`. +- Both return `ThemeData` with `useMaterial3: true`. +- Light theme uses `kLightBackgroundColor` for scaffold/canvas/dialog/card backgrounds. +- Dark theme uses `kDarkBackgroundColor` for scaffold/canvas/dialog/card backgrounds. + +--- + +## Common patterns + +```dart +// Custom primary color (CUSTOM palette) +MaterialApp( + theme: generateLightTheme(mainColor: const Color(0xFF0057B7)), + darkTheme: generateDarkTheme(mainColor: const Color(0xFF0057B7)), + themeMode: .system, +) +``` + +```dart +// Layrz API palette name — mainColor is ignored +MaterialApp( + theme: generateLightTheme(theme: 'TEAL'), + darkTheme: generateDarkTheme(theme: 'TEAL'), + themeMode: .system, +) +``` + +```dart +// Custom fonts loaded before runApp +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await preloadFont(AppFont(name: 'Inter', source: FontSource.googleFonts)); + await preloadFont(AppFont(name: 'Roboto Mono', source: FontSource.googleFonts)); + runApp(const MyApp()); +} + +// Then in MaterialApp: +MaterialApp( + theme: generateLightTheme( + titleFont: AppFont(name: 'Inter', source: FontSource.googleFonts), + bodyFont: AppFont(name: 'Roboto Mono', source: FontSource.googleFonts), + ), + darkTheme: generateDarkTheme( + titleFont: AppFont(name: 'Inter', source: FontSource.googleFonts), + bodyFont: AppFont(name: 'Roboto Mono', source: FontSource.googleFonts), + ), + themeMode: .system, +) +``` + +```dart +// App-controlled theme mode (e.g. user preference stored in state) +MaterialApp( + theme: generateLightTheme(), + darkTheme: generateDarkTheme(), + themeMode: store.isDarkMode ? .dark : .light, +) +``` + +--- + +## App setup conventions + +- Always pass both `theme:` and `darkTheme:` so system dark mode works correctly. +- Use `themeMode: .system` unless the app has an explicit user-controlled toggle. +- Call `preloadFont` before `runApp` for any non-Google-Fonts `AppFont`; repeat for each font (title and body separately). +- Keep `theme` and `mainColor` identical between the light and dark calls. diff --git a/.claude/skills/theme-generator/references/api.md b/.claude/skills/theme-generator/references/api.md new file mode 100644 index 0000000..0c21d89 --- /dev/null +++ b/.claude/skills/theme-generator/references/api.md @@ -0,0 +1,159 @@ +# Theme Generator — API Reference + +Source: `lib/src/theme/src/light_theme.dart` +Source: `lib/src/theme/src/dark_theme.dart` + +- `generateLightTheme` function — `light_theme.dart` line 6 +- `generateDarkTheme` function — `dark_theme.dart` line 6 + +--- + +## Examples + +```dart +// Defaults — Layrz primary blue, Cabin + Fira Sans Condensed fonts +MaterialApp( + theme: generateLightTheme(), + darkTheme: generateDarkTheme(), + themeMode: ThemeMode.system, +) +``` + +```dart +// Custom primary color +MaterialApp( + theme: generateLightTheme(mainColor: const Color(0xFF0057B7)), + darkTheme: generateDarkTheme(mainColor: const Color(0xFF0057B7)), + themeMode: .system, +) +``` + +```dart +// Layrz API palette name +MaterialApp( + theme: generateLightTheme(theme: 'TEAL'), + darkTheme: generateDarkTheme(theme: 'TEAL'), + themeMode: .system, +) +``` + +```dart +// Custom fonts — preload before runApp +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await preloadFont(AppFont(name: 'Inter', source: FontSource.googleFonts)); + await preloadFont(AppFont(name: 'Roboto Mono', source: FontSource.googleFonts)); + runApp(const MyApp()); +} + +MaterialApp( + theme: generateLightTheme( + titleFont: AppFont(name: 'Inter', source: FontSource.googleFonts), + bodyFont: AppFont(name: 'Roboto Mono', source: FontSource.googleFonts), + ), + darkTheme: generateDarkTheme( + titleFont: AppFont(name: 'Inter', source: FontSource.googleFonts), + bodyFont: AppFont(name: 'Roboto Mono', source: FontSource.googleFonts), + ), + themeMode: .system, +) +``` + +```dart +// User-controlled theme mode +MaterialApp( + theme: generateLightTheme(mainColor: const Color(0xFFFF8200)), + darkTheme: generateDarkTheme(mainColor: const Color(0xFFFF8200)), + themeMode: store.isDarkMode ? .dark : .light, +) +``` + +--- + +## Signatures + +```dart +ThemeData generateLightTheme({ + String theme = "CUSTOM", + Color mainColor = kPrimaryColor, + AppFont? titleFont, + AppFont? bodyFont, +}) +``` + +```dart +ThemeData generateDarkTheme({ + String theme = "CUSTOM", + Color mainColor = kPrimaryColor, + AppFont? titleFont, + AppFont? bodyFont, +}) +``` + +--- + +## Parameters + +Both functions share an identical parameter set. + +| Parameter | Type | Default | Notes | +|---|---|---|---| +| `theme` | `String` | `"CUSTOM"` | Layrz API palette name. When `"CUSTOM"`, `mainColor` is used. Any unrecognized value falls back to `kPrimaryColor`. | +| `mainColor` | `Color` | `kPrimaryColor` | Only applied when `theme == "CUSTOM"`. Ignored for named palettes. | +| `titleFont` | `AppFont?` | `null` | Falls back to **Cabin** (Google Fonts). Requires `await preloadFont(...)` before `runApp` if not from Google Fonts. | +| `bodyFont` | `AppFont?` | `null` | Falls back to **Fira Sans Condensed** (Google Fonts). Requires `await preloadFont(...)` before `runApp` if not from Google Fonts. | + +--- + +## Returns + +`ThemeData` configured with `useMaterial3: true`. + +| Function | `Brightness` | Background constant | +|---|---|---| +| `generateLightTheme` | `Brightness.light` | `kLightBackgroundColor` | +| `generateDarkTheme` | `Brightness.dark` | `kDarkBackgroundColor` | + +Both configure: tooltip, input decoration, tab bar, dialog, app bar, bottom sheet, divider, scrollbar, slider, checkbox, switch, radio, elevated button, list tile, card, and data table themes. + +--- + +## Supported `theme` values + +These names are resolved by `getThemeColor` (`lib/src/helpers/src/get_theme_color.dart`): + +| Value | Maps to | +|---|---| +| `'CUSTOM'` | Generated swatch from `mainColor` | +| `'PINK'` | `Colors.pink` | +| `'RED'` | `Colors.red` | +| `'DEEPORANGE'` | `Colors.deepOrange` | +| `'ORANGE'` | `Colors.orange` | +| `'AMBER'` | `Colors.amber` | +| `'YELLOW'` | `Colors.yellow` | +| `'LIME'` | `Colors.lime` | +| `'LIGHTGREEN'` | `Colors.lightGreen` | +| `'GREEN'` | `Colors.green` | +| `'TEAL'` | `Colors.teal` | +| `'CYAN'` | `Colors.cyan` | +| `'LIGHTBLUE'` | `Colors.lightBlue` | +| `'BLUE'` | `Colors.blue` | +| `'INDIGO'` | `Colors.indigo` | +| `'DEEPBLUE'` | `Colors.deepPurple` | +| `'PURPLE'` | `Colors.purple` | +| `'BLUEGREY'` | `Colors.blueGrey` | +| `'GREY'` | `Colors.grey` | +| `'BROWN'` | `Colors.brown` | +| _(any other)_ | Falls back to `kPrimaryColor` swatch | + +--- + +## Related helpers + +- `kPrimaryColor` — default primary color (`Color(0xFF001e60)`) +- `kAccentColor` — accent color (`Color(0xFFFF8200)`) +- `kLightBackgroundColor` — light mode scaffold/canvas background +- `kDarkBackgroundColor` — dark mode scaffold/canvas background +- `preloadFont(AppFont)` — async font loader; call before `runApp` for non-Google-Fonts fonts +- `getThemeColor({required String theme, Color color})` — resolves palette name to `MaterialColor` +- `ThemedFontHandler.generateFont(...)` — builds the `TextTheme` from title/body font config diff --git a/.claude/skills/themed-alert/SKILL.md b/.claude/skills/themed-alert/SKILL.md new file mode 100644 index 0000000..fe6edf4 --- /dev/null +++ b/.claude/skills/themed-alert/SKILL.md @@ -0,0 +1,107 @@ +--- +name: themed-alert +description: Use ThemedAlert in a layrz Flutter widget. Apply when rendering an inline status callout — info/success/warning/danger/context/custom severities with five visual styles (.layrz, .filledTonal, .filled, .outlined, .filledIcon). +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.info`, `.danger`, `.filledTonal`, `.filledIcon`) — never write the fully-qualified form (`ThemedAlertType.info`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Inline status messaging: form-level validation summaries, page-level banners, dialog body callouts, empty-state hints. +- Choose `type` by **semantic severity:** + - `.info` — neutral informational hint (default). + - `.success` — confirmation that an action completed successfully. + - `.warning` — reversible caution; user can still proceed. + - `.danger` — blocking or destructive condition; user must act before continuing. + - `.context` — muted/grey note; low-emphasis metadata. + - `.custom` — only when you must override both icon and color. Requires `icon` and `color`. +- Choose `style` by **surface context:** + - `.layrz` — subtle tonal chip + plain text on transparent background; good default. + - `.filledTonal` — soft 20% alpha background; use for moderate emphasis inside forms. + - `.filled` — solid type-color background; use for strong emphasis, error banners. + - `.outlined` — transparent background with type-color border; use inside cards. + - `.filledIcon` — two-column layout (filled icon column + white body); use on dashboards. +- **`ThemedAlert` is a pure `StatelessWidget` — there is no `ThemedAlert.show()` or dialog helper.** Place it directly inside a `Column`, `AlertDialog.content`, `SnackBar.content`, or any other layout widget. +- Use `ThemedAlertIcon` when you need just the colored icon badge without a title or description. + +--- + +## Minimal usage + +```dart +ThemedAlert( + type: .warning, + title: context.i18n.t('entity.warning.title'), + description: context.i18n.t('entity.warning.description'), +) +``` + +--- + +## Key behaviors + +- `title` and `description` are **required** — no factory variant exists without them. +- `maxLines` (default 3) clips `description` with an ellipsis; bump it if the message is expected to be longer. +- `.custom` type **requires** both `color` and `icon` — the widget has no fallback values for custom type. +- `style` changes only the surface chrome (background, border); the semantic color always comes from `type`. +- `iconSize` defaults to **25** when `style` is `.filledIcon`, **22** otherwise. Override explicitly only when layout demands it. +- In `.filled` style, text color is auto-contrasted via `validateColor(typeColor)` — do not manually set text colors. + +--- + +## Common patterns + +```dart +// 1. Info banner — default subtle style +ThemedAlert( + title: context.i18n.t('onboarding.tip.title'), + description: context.i18n.t('onboarding.tip.description'), +) + +// 2. Filled danger alert inside an AlertDialog +AlertDialog( + content: ThemedAlert( + type: .danger, + style: .filled, + title: context.i18n.t('errors.deleteTitle'), + description: context.i18n.t('errors.deleteDescription'), + ), + actions: [...], +) + +// 3. Outlined success summary inside a Card +Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: ThemedAlert( + type: .success, + style: .outlined, + title: context.i18n.t('export.done.title'), + description: context.i18n.t('export.done.description'), + ), + ), +) + +// 4. Custom type with project-specific icon and color +ThemedAlert( + type: .custom, + color: const Color(0xFF6A0DAD), + icon: LayrzIcons.solarOutlineShieldCheck, + title: context.i18n.t('security.verified.title'), + description: context.i18n.t('security.verified.description'), +) +``` + +--- + +## Usage conventions + +- Localize `title` and `description` via `context.i18n.t('...')` — never hardcode strings. +- Don't stack multiple alerts of the same severity; collapse them into one with a combined description. +- Keep descriptions short enough to fit in 3 lines. If content is inherently longer, set `maxLines` explicitly rather than leaving the default clip. +- For action-triggered feedback (e.g. after a form submit), prefer wrapping `ThemedAlert` inside `SnackBar.content` so it auto-dismisses rather than cluttering the page layout. +- Separate `ThemedAlert` from surrounding inputs or cards with `SizedBox(height: 10)`. diff --git a/.claude/skills/themed-alert/references/api.md b/.claude/skills/themed-alert/references/api.md new file mode 100644 index 0000000..5fcbd4e --- /dev/null +++ b/.claude/skills/themed-alert/references/api.md @@ -0,0 +1,165 @@ +# ThemedAlert — API Reference + +Source: `lib/src/alerts/` + +- `ThemedAlert` class — `src/alert.dart` line 37 +- `ThemedAlertType` enum — `src/type.dart` +- `ThemedAlertStyle` enum — `src/style.dart` +- `ThemedAlertIcon` class — `src/icon.dart` line 24 + +--- + +## Examples + +```dart +// Default — info type, layrz style +ThemedAlert( + title: 'Heads up', + description: 'This action cannot be undone after 30 days.', +) + +// Warning with filledTonal background +ThemedAlert( + type: .warning, + style: .filledTonal, + title: 'Low disk space', + description: 'You have less than 500 MB remaining.', +) + +// Danger with filled (solid) background +ThemedAlert( + type: .danger, + style: .filled, + title: 'Account suspended', + description: 'Your account has been suspended due to policy violations.', +) + +// Success outlined (inside a Card) +ThemedAlert( + type: .success, + style: .outlined, + title: 'Export complete', + description: 'Your file has been exported and is ready to download.', +) + +// FilledIcon style — two-column layout +ThemedAlert( + type: .info, + style: .filledIcon, + title: 'New version available', + description: 'Version 3.2 introduces improved performance and bug fixes.', +) + +// Custom type — override icon and color +ThemedAlert( + type: .custom, + color: const Color(0xFF6A0DAD), + icon: LayrzIcons.solarOutlineShieldCheck, + title: 'Identity verified', + description: 'Your identity has been verified successfully.', +) + +// Extended description with bumped maxLines +ThemedAlert( + type: .context, + title: 'About this feature', + description: 'This feature is in beta. Some behaviors may change without ' + 'notice. Please report any issues to the support team.', + maxLines: 5, +) +``` + +--- + +## Constructor + +```dart +const ThemedAlert({ + super.key, + this.type = .info, + required this.title, + required this.description, + this.maxLines = 3, + this.style = .layrz, + this.color, + this.icon, + this.iconSize, +}); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `type` | `ThemedAlertType` | `.info` | Semantic severity. Drives the default icon and color. See enum table below. | +| `title` | `String` | — | **Required.** Bold heading text of the alert. | +| `description` | `String` | — | **Required.** Body text. Clipped at `maxLines` with an ellipsis. | +| `maxLines` | `int` | `3` | Maximum lines for `description`. Increase when the message is known to be longer. | +| `style` | `ThemedAlertStyle` | `.layrz` | Visual surface treatment. See enum table below. | +| `color` | `Color?` | `null` | Used **only** when `type == .custom`. Has no effect for other types. | +| `icon` | `IconData?` | `null` | Used **only** when `type == .custom`. Has no effect for other types. | +| `iconSize` | `double?` | `null` | Icon size. Defaults to `25` when `style == .filledIcon`, `22` otherwise. | + +--- + +## `ThemedAlertType` enum + +| Value | Default icon | Default color | Notes | +|---|---|---|---| +| `.info` | `solarOutlineInfoSquare` | `Colors.blue` | Neutral informational hint. Default type. | +| `.success` | `solarOutlineCheckSquare` | `Colors.green` | Confirmation that an action completed. | +| `.warning` | `solarOutlineDangerSquare` | `Colors.orange` | Reversible caution; user can still proceed. | +| `.danger` | `solarOutlineCloseSquare` | `Colors.red` | Blocking or destructive condition. | +| `.context` | `solarOutlineMenuDotsSquare` | `Colors.grey` | Muted, low-emphasis metadata note. | +| `.custom` | `null` (must set `icon`) | `null` (must set `color`) | Both `icon` and `color` are **required** when using this type. | + +--- + +## `ThemedAlertStyle` enum + +| Value | Description | +|---|---| +| `.layrz` | Soft tonal icon chip + plain text on a transparent background. Default. | +| `.filledTonal` | Background filled at ~20% alpha of the type color; text and icon in type color. | +| `.filled` | Solid type-color background. Text color is auto-contrasted via `validateColor(typeColor)`. | +| `.outlined` | Transparent background with a 1 px type-color border. | +| `.filledIcon` | Two-column layout: icon column filled with type color; body column uses `scaffoldBackgroundColor`. | + +--- + +## Companion — `ThemedAlertIcon` + +Standalone colored icon badge matching the alert's type palette. Use when you need just the badge without a title or description — for example, as a status indicator in a table row. + +```dart +const ThemedAlertIcon({ + super.key, + this.type = .info, + this.size = 30, + this.iconSize = 20, + this.padding = const EdgeInsets.all(5), + this.color, + this.icon, +}); +``` + +| Property | Type | Default | Notes | +|---|---|---|---| +| `type` | `ThemedAlertType` | `.info` | Drives the icon and background color. | +| `size` | `double` | `30` | Diameter of the circular badge. | +| `iconSize` | `double` | `20` | Icon size inside the badge. | +| `padding` | `EdgeInsetsGeometry` | `EdgeInsets.all(5)` | Internal padding around the icon. | +| `color` | `Color?` | `null` | Used only when `type == .custom`. | +| `icon` | `IconData?` | `null` | Used only when `type == .custom`. | + +--- + +## Behavior notes + +- **`.filledIcon` layout:** renders a `Row` with two columns — the left column is a `Container` filled with the type color and the right column uses the scaffold's background color. The icon column width is determined by `iconSize + padding`. +- **`.filled` text contrast:** body text color is computed via `validateColor(typeColor)`, which selects black or white for maximum legibility — do not manually set text color inside a `.filled` alert. +- **`.filledTonal` alpha:** background is `typeColor.withAlpha(51)` (≈ 20% opacity on any surface). +- **`.outlined` border:** 1 px `BoxDecoration` border using the type color; no background fill. +- **No dialog helper:** `ThemedAlert` is intentionally a pure layout widget with no `show()` or imperative API. Wrap it in `showDialog`, `ScaffoldMessenger.showSnackBar`, or any other mechanism the caller controls. diff --git a/.claude/skills/themed-avatar-picker/SKILL.md b/.claude/skills/themed-avatar-picker/SKILL.md new file mode 100644 index 0000000..d701df2 --- /dev/null +++ b/.claude/skills/themed-avatar-picker/SKILL.md @@ -0,0 +1,81 @@ +--- +name: themed-avatar-picker +description: Use ThemedAvatarPicker in a layrz Flutter widget. Apply when adding an image avatar/photo upload field that opens a system image picker and stores result as base64. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- User profile photo / entity avatar upload +- Any single image selection that stores result as a base64 data URI + +For general file uploads (non-image, or needing raw bytes) → use `ThemedFilePicker`. + +--- + +## Minimal usage + +```dart +ThemedAvatarPicker( + labelText: context.i18n.t('entity.avatar'), + value: avatarBase64, + errors: context.getErrors(key: 'avatar'), + onChanged: (value) { + avatarBase64 = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Displays a 100×100 rounded square that shows the current avatar or an upload icon. +- Tapping opens the system image picker (images only, single file). +- `onChanged` fires with a `"data:;base64,"` string on selection, or `null` when cleared. +- A red ✕ button appears top-right when an image is loaded — tapping it clears the value. +- `disabled: true` shows a lock icon and prevents opening the picker. +- `customChild` wraps any widget in an `InkWell` that opens the picker. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// With custom child trigger +ThemedAvatarPicker( + labelText: context.i18n.t('entity.avatar'), + value: avatarBase64, + customChild: CircleAvatar( + backgroundImage: avatarBase64 != null ? NetworkImage(avatarBase64!) : null, + child: avatarBase64 == null ? const Icon(Icons.person) : null, + ), + onChanged: (value) { + avatarBase64 = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedAvatarPicker( + labelText: context.i18n.t('entity.avatar'), + value: avatarBase64, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-avatar-picker/references/api.md b/.claude/skills/themed-avatar-picker/references/api.md new file mode 100644 index 0000000..b0fc623 --- /dev/null +++ b/.claude/skills/themed-avatar-picker/references/api.md @@ -0,0 +1,87 @@ +# ThemedAvatarPicker — API Reference + +Source: `lib/src/inputs/src/pickers/general/avatar.dart` + +- `ThemedAvatarPicker` class — line 3 + +--- + +## Examples + +```dart +// Basic avatar picker +ThemedAvatarPicker( + labelText: 'Avatar', + value: avatarBase64, + errors: context.getErrors(key: 'avatar'), + onChanged: (value) => setState(() => avatarBase64 = value), +) + +// Custom child trigger +ThemedAvatarPicker( + labelText: 'Photo', + value: avatarBase64, + customChild: CircleAvatar(radius: 30), + onChanged: (value) => setState(() => avatarBase64 = value), +) + +// Disabled +ThemedAvatarPicker( + labelText: 'Avatar', + value: avatarBase64, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedAvatarPicker({ + super.key, + this.labelText, + this.label, + this.value, + this.onChanged, + this.disabled = false, + this.errors = const [], + this.hideDetails = false, + this.customChild, + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `String?` | `null` | Base64 data URI or URL of the current avatar | +| `onChanged` | `void Function(String?)?` | `null` | Fires with base64 data URI on select, `null` on clear | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents opening the picker; shows lock icon | +| `errors` | `List` | `[]` | Validation messages shown below the widget | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | + +--- + +## Behavior notes + +- Default UI: 100×100 rounded square with elevation, centered label above. +- Upload icon shown when no value; avatar image shown when value is set. +- Red ✕ button (top-right) clears the avatar — fires `onChanged(null)`. +- Only image files are accepted (system picker configured with `FileType.image`). +- Result is always a `"data:;base64,"` string. diff --git a/.claude/skills/themed-button/SKILL.md b/.claude/skills/themed-button/SKILL.md new file mode 100644 index 0000000..f210bc6 --- /dev/null +++ b/.claude/skills/themed-button/SKILL.md @@ -0,0 +1,118 @@ +--- +name: themed-button +description: Use ThemedButton in a layrz Flutter widget. Apply when rendering any tappable action — primary/secondary CTAs, icon-only FABs, destructive actions, cooldown buttons, or the six semantic factories (.save, .cancel, .info, .show, .edit, .delete). +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.filledTonal`, `.fab`, `.outlined`) — never write the fully-qualified form (`ThemedButtonStyle.filledTonal`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Any tappable action: CTA buttons, form submit/cancel, confirmation dialogs, detail pages. +- **Prefer the six semantic factories** (`.save`, `.cancel`, `.info`, `.show`, `.edit`, `.delete`) for CRUD screens — they wire up the correct icon, color, and style automatically. +- Use FAB-variant styles (`.fab`, `.filledTonalFab`, `.elevatedFab`, etc.) when the button is icon-only; `labelText` becomes the tooltip. +- Use `isCooldown` for rate-limited actions (e.g. OTP resend, export trigger) where the user must wait before retrying. +- Use `onLongPress` for destructive or confirm-before-act patterns — note it is **mutually exclusive with `onTap`**. +- **Do not use** for static icons with no interaction — use a plain `Icon` instead. +- **Do not use** for on/off toggles — use `ThemedAnimatedCheckbox` instead. +- **Do not use** for a row of actions on a list item — use `ThemedActionsButtons` instead (collapses to FAB overlay on mobile automatically). + +--- + +## Minimal usage + +```dart +// Semantic factory — recommended for CRUD save +ThemedButton.save( + labelText: context.i18n.t('actions.save'), + isLoading: isSaving, + onTap: () async { + await save(); + if (context.mounted) onSaved.call(); + }, +) +``` + +--- + +## Key behaviors + +- **`label` XOR `labelText`** — exactly one must be provided (compile-time assert). Use `labelText` (String) for text; use `label` (Widget) for custom content. +- **`onTap` XOR `onLongPress`** — providing both throws an assert. +- `isLoading: true` replaces the button content with a circular spinner; `loadingBackgroundColor` / `loadingForegroundColor` override the spinner colors. +- `isCooldown: true` locks the button for `cooldownDuration` (default 5 s) after a tap, then fires `onCooldownFinish`. Set `showCooldownRemainingDuration: false` to hide the countdown overlay. +- `isDisabled: true` applies a grey tint via `ThemedButton.getDisabledColor(isDark, style)` and suppresses `onTap`. +- **FAB variants** (`.fab`, `.filledTonalFab`, etc.) are icon-only — they use `labelText` as the tooltip. Set `tooltipEnabled: false` to suppress it. +- `height` defaults to `ThemedButton.defaultHeight` (40). Minimum is 30; `iconSize` must be ≤ `height` and `fontSize` must be ≤ `height`. + +--- + +## Common patterns + +```dart +// 1. Primary filledTonal with icon (default style) +ThemedButton( + labelText: context.i18n.t('actions.export'), + icon: LayrzIcons.solarOutlineExport, + onTap: () async { + await export(); + if (context.mounted) onExported.call(); + }, +) + +// 2. Icon-only FAB (label becomes tooltip) +ThemedButton( + labelText: context.i18n.t('actions.add'), + icon: LayrzIcons.solarOutlineAddSquare, + style: .fab, + onTap: onAdd, +) + +// 3. Cooldown button (e.g. resend OTP) +ThemedButton( + labelText: context.i18n.t('auth.resendCode'), + icon: LayrzIcons.solarOutlineRestart, + isCooldown: true, + cooldownDuration: const Duration(seconds: 30), + onCooldownFinish: onCooldownEnd, + onTap: onResend, +) + +// 4. Async loading state +ThemedButton( + labelText: context.i18n.t('actions.submit'), + isLoading: isSubmitting, + onTap: onSubmit, +) + +// 5. Semantic factory row — save + cancel +Row( + children: [ + ThemedButton.cancel( + labelText: context.i18n.t('actions.cancel'), + isMobile: isMobile, + onTap: onCancel, + ), + const SizedBox(width: 10), + ThemedButton.save( + labelText: context.i18n.t('actions.save'), + isMobile: isMobile, + isLoading: isSaving, + onTap: onSave, + ), + ], +) +``` + +--- + +## Form conventions + +- Localize every label with `context.i18n.t('...')` — never hardcode strings. +- Always guard async callbacks: `if (context.mounted) callback.call();` +- Use the six semantic factories on CRUD detail pages — they encode the project's icon/color conventions. +- Separate stacked buttons with `SizedBox(height: 10)`; separate buttons in a `Row` with `SizedBox(width: 10)`. +- Pass `isMobile: isMobile` (or `isMobile: context.isMobile`) to semantic factories so they switch to FAB style on small screens automatically. diff --git a/.claude/skills/themed-button/references/api.md b/.claude/skills/themed-button/references/api.md new file mode 100644 index 0000000..811b8fc --- /dev/null +++ b/.claude/skills/themed-button/references/api.md @@ -0,0 +1,251 @@ +# ThemedButton — API Reference + +Source: `lib/src/buttons/src/button.dart` + +- `ThemedButton` class — line 122 +- `ThemedButtonStyle` enum — line 1324 + +--- + +## Examples + +```dart +// Default filledTonal with text + icon +ThemedButton( + labelText: 'Export', + icon: LayrzIcons.solarOutlineExport, + onTap: () => doExport(), +) + +// Filled style — solid background +ThemedButton( + labelText: 'Confirm', + style: .filled, + onTap: () => confirm(), +) + +// Outlined style +ThemedButton( + labelText: 'More info', + icon: LayrzIcons.solarOutlineInfoSquare, + style: .outlined, + onTap: () => showInfo(), +) + +// Text-only (no background or border) +ThemedButton( + labelText: 'Skip', + style: .text, + onTap: () => skip(), +) + +// FAB — icon only, label becomes tooltip +ThemedButton( + labelText: 'Add', + icon: LayrzIcons.solarOutlineAddSquare, + style: .fab, + onTap: () => add(), +) + +// FilledTonal FAB — icon only +ThemedButton( + labelText: 'Upload', + icon: LayrzIcons.solarOutlineUpload, + style: .filledTonalFab, + onTap: () => upload(), +) + +// Cooldown button — locked for 30 s after tap +ThemedButton( + labelText: 'Resend code', + isCooldown: true, + cooldownDuration: const Duration(seconds: 30), + onCooldownFinish: onCooldownDone, + onTap: () => resend(), +) + +// Long-press (mutually exclusive with onTap) +ThemedButton( + labelText: 'Delete', + icon: LayrzIcons.solarOutlineTrashBinMinimalistic2, + color: Colors.red, + onLongPress: () => confirmDelete(), +) + +// Disabled +ThemedButton( + labelText: 'Submit', + isDisabled: true, + onTap: () => submit(), +) + +// Loading state with custom spinner colors +ThemedButton( + labelText: 'Processing', + isLoading: true, + loadingBackgroundColor: Colors.blue.shade100, + loadingForegroundColor: Colors.blue, + onTap: () => process(), +) + +// Semantic factory — save +ThemedButton.save( + labelText: 'Save', + isMobile: false, + isLoading: isSaving, + onTap: () => save(), +) + +// Semantic factory — delete +ThemedButton.delete( + labelText: 'Delete', + isMobile: true, + onTap: () => delete(), +) +``` + +--- + +## Constructor + +```dart +const ThemedButton({ + super.key, + this.label, + this.labelText, + this.icon, + this.onTap, + this.isLoading = false, + this.color, + this.style = .filledTonal, + this.isCooldown = false, + this.cooldownDuration = const Duration(seconds: 5), + this.onCooldownFinish, + this.hintText, + this.width, + this.isDisabled = false, + this.tooltipPosition = .bottom, + this.fontSize = 14, + this.tooltipEnabled = true, + this.showCooldownRemainingDuration = true, + this.height = defaultHeight, + this.iconSize = 22, + this.iconSeparatorSize = 8, + this.loadingBackgroundColor, + this.loadingForegroundColor, + this.customLongPressDuration = const Duration(milliseconds: 500), + this.onLongPress, +}) : assert(label != null || labelText != null, "You must provide a label or labelText, not both or none."), + assert(height >= 30, "Height must be greater than 30"), + assert(iconSize >= 0, "Icon size must be greater than 0"), + assert(iconSize <= height, "Icon size must be less than or equal to height"), + assert(fontSize >= 0, "Font size must be greater than 0"), + assert(fontSize <= height, "Font size must be less than or equal to height"), + assert(!(onTap != null && onLongPress != null), "You must provide either onTap or onLongPress, not both."); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `label` | `Widget?` | `null` | Custom widget label. Mutually exclusive with `labelText` — provide exactly one. | +| `labelText` | `String?` | `null` | **Required** (unless `label` provided). Plain-text label. FAB variants use this as tooltip text. | +| `icon` | `IconData?` | `null` | Icon displayed before the label. Required for FAB-variant styles to render correctly. | +| `onTap` | `VoidCallback?` | `null` | Tap handler. Mutually exclusive with `onLongPress`. | +| `isLoading` | `bool` | `false` | Replaces content with a circular spinner. | +| `color` | `Color?` | `null` | Overrides the button's accent color. Defaults to `Theme.of(context).colorScheme.primary`. | +| `style` | `ThemedButtonStyle` | `.filledTonal` | Visual variant. See enum table below. | +| `isCooldown` | `bool` | `false` | Locks button after a tap for `cooldownDuration`. | +| `cooldownDuration` | `Duration` | `Duration(seconds: 5)` | How long the button stays locked after a tap when `isCooldown` is true. | +| `onCooldownFinish` | `VoidCallback?` | `null` | Called when the cooldown period ends. | +| `hintText` | `String?` | `null` | Additional tooltip hint shown alongside the label tooltip. | +| `width` | `double?` | `null` | Fixed width. When null the button sizes to its content. | +| `isDisabled` | `bool` | `false` | Disables interaction and applies `ThemedButton.getDisabledColor(isDark, style)`. | +| `tooltipPosition` | `ThemedTooltipPosition` | `.bottom` | Where the tooltip appears relative to the button. | +| `fontSize` | `double` | `14` | Label font size. Must be ≥ 0 and ≤ `height`. | +| `tooltipEnabled` | `bool` | `true` | Set to `false` to suppress the tooltip entirely (e.g. when label is already visible). | +| `showCooldownRemainingDuration` | `bool` | `true` | Shows a countdown overlay during cooldown. Set to `false` to hide the number. | +| `height` | `double` | `40` (`defaultHeight`) | Button height. Minimum 30. `iconSize` and `fontSize` must not exceed this. | +| `iconSize` | `double` | `22` | Icon size in logical pixels. Must be ≥ 0 and ≤ `height`. | +| `iconSeparatorSize` | `double` | `8` | Gap between icon and label. | +| `loadingBackgroundColor` | `Color?` | `null` | Spinner track color while `isLoading` is true. | +| `loadingForegroundColor` | `Color?` | `null` | Spinner indicator color while `isLoading` is true. | +| `customLongPressDuration` | `Duration` | `Duration(milliseconds: 500)` | How long to hold before `onLongPress` fires. | +| `onLongPress` | `VoidCallback?` | `null` | Long-press handler. Mutually exclusive with `onTap`. | + +--- + +## Factory constructors + +All six semantic factories share the same parameter shape: + +```dart +factory ThemedButton.({ + bool isMobile = false, + required VoidCallback onTap, + required String labelText, + bool isLoading = false, + bool isDisabled = false, + bool isCooldown = false, + VoidCallback? onCooldownFinish, +}); +``` + +`isMobile: true` switches the style to the FAB variant (icon-only with tooltip), `false` renders the labelled variant. + +| Factory | Icon | Color | Desktop style | Mobile style | +|---|---|---|---|---| +| `.save` | `solarOutlineInboxIn` | `Colors.green` | `.filledTonal` | `.filledTonalFab` | +| `.cancel` | `solarOutlineCloseSquare` | `Colors.red` | `.text` | `.fab` | +| `.info` | `solarOutlineInfoSquare` | `Colors.blue` | `.filledTonal` | `.filledTonalFab` | +| `.show` | `solarOutlineEyeScan` | `Colors.blue` | `.filledTonal` | `.filledTonalFab` | +| `.edit` | `solarOutlinePenNewSquare` | `Colors.orange` | `.filledTonal` | `.filledTonalFab` | +| `.delete` | `solarOutlineTrashBinMinimalistic2` | `Colors.red` | `.filledTonal` | `.filledTonalFab` | + +> **Deprecated:** `ThemedButton.legacyLoading(...)` — do not use; prefer the primary constructor. + +--- + +## `ThemedButtonStyle` enum + +| Value | FAB counterpart | Description | +|---|---|---| +| `.elevated` | `.elevatedFab` | Raised surface with shadow; primary color tint. | +| `.filled` | `.filledFab` | Solid primary color background; contrast text via `validateColor(...)`. | +| `.filledTonal` | `.filledTonalFab` | Soft tonal background (secondary container); default style. | +| `.outlined` | `.outlinedFab` | Transparent background with 1 px primary-color border. | +| `.text` | `.fab` | No background or border; text/icon in primary color. | +| `.outlinedTonal` | `.outlinedTonalFab` | Transparent background with 1 px tonal border. | + +FAB counterparts (right column) render **icon-only** and use `labelText` as the `ThemedTooltip` message. + +--- + +## Static members + +| Member | Signature | Notes | +|---|---|---| +| `defaultHeight` | `static const double defaultHeight = 40` | Used as the default for the `height` parameter. | +| `getDisabledColor` | `static Color getDisabledColor(bool isDark, ThemedButtonStyle style)` | Returns the appropriate grey shade for a disabled button based on dark mode and style. Used internally when `isDisabled: true`. | + +--- + +## Companion widgets + +The `buttons` barrel (`lib/src/buttons/buttons.dart`) also exports: + +- **`ThemedActionsButtons`** — renders a row of actions on desktop and collapses to a single FAB with an overlay menu on mobile. Use this instead of a manual `Row` of `ThemedButton`s on list items. +- **`ThemedActionButton`** — data class (not a widget) used by `ThemedActionsButtons`; mirrors `ThemedButton`'s parameters plus dynamic `notifier`/builder callbacks. +- **`ThemedAnimatedCheckbox`** — animated checkbox widget; use for boolean toggles instead of a button. + +--- + +## Behavior notes + +- **Loading animation:** when `isLoading` flips to `true`, button content cross-fades to a `CircularProgressIndicator.adaptive`. The button width is preserved so the layout does not jump. +- **Cooldown countdown:** a `Stack` overlays the remaining seconds as text centred on the button surface while locked. Suppress with `showCooldownRemainingDuration: false`. +- **Tooltip for FAB variants:** `ThemedTooltip` wraps the icon with `message: labelText`. Setting `tooltipEnabled: false` removes the wrapper entirely. +- **Long-press timing:** `customLongPressDuration` is passed to a `GestureDetector`; the default 500 ms is the Material long-press threshold. +- **Disabled vs loading:** both suppress `onTap`; `isDisabled` persists until the flag is cleared, `isLoading` is expected to be reset when the async operation completes. diff --git a/.claude/skills/themed-checkbox-input/SKILL.md b/.claude/skills/themed-checkbox-input/SKILL.md new file mode 100644 index 0000000..8fb00ae --- /dev/null +++ b/.claude/skills/themed-checkbox-input/SKILL.md @@ -0,0 +1,103 @@ +--- +name: themed-checkbox-input +description: Use ThemedCheckboxInput in a layrz Flutter widget. Apply when adding a boolean toggle — supports checkbox, switch, and field (dropdown) styles. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.asSwitch`, `.asFlutterCheckbox`) — never write the fully-qualified form (`ThemedCheckboxInputStyle.asSwitch`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Boolean yes/no toggle in a form → `.asFlutterCheckbox` (default) or `.asCheckbox2` +- Toggle switch (Material Switch widget) → `.asSwitch` +- Boolean rendered as a dropdown select field → `.asField` + +Never use raw `Checkbox`, `Switch`, or `SwitchListTile` — always use this widget for boolean inputs. + +--- + +## Minimal usage + +```dart +ThemedCheckboxInput( + labelText: context.i18n.t('entity.isActive'), + value: isActive, + errors: context.getErrors(key: 'isActive'), + onChanged: (value) { + isActive = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Default style is `.asFlutterCheckbox` — standard Flutter `Checkbox` widget. +- `.asCheckbox2` uses the custom `ThemedAnimatedCheckbox` (newer design). +- `.asSwitch` renders a Material `Switch` with the label to the right. +- `.asField` delegates to `ThemedSelectInput` with Yes/No options — uses i18n keys `helpers.true` / `helpers.false`. +- `value` syncs to internal state on `didUpdateWidget`. +- Tapping the label text also toggles the value (GestureDetector wraps the label in switch/checkbox modes). +- Exactly one of `label` / `labelText` must be set — assert enforced (both null is allowed). + +--- + +## Common patterns + +```dart +// Switch style +ThemedCheckboxInput( + labelText: context.i18n.t('entity.notifications'), + value: notificationsEnabled, + style: .asSwitch, + errors: context.getErrors(key: 'notifications'), + onChanged: (value) { + notificationsEnabled = value; + if (context.mounted) onChanged.call(); + }, +) + +// New design checkbox +ThemedCheckboxInput( + labelText: context.i18n.t('entity.acceptTerms'), + value: acceptTerms, + style: .asCheckbox2, + onChanged: (value) { + acceptTerms = value; + if (context.mounted) onChanged.call(); + }, +) + +// Dropdown field style (shows Yes/No picker) +ThemedCheckboxInput( + labelText: context.i18n.t('entity.isPublic'), + value: isPublic, + style: .asField, + errors: context.getErrors(key: 'isPublic'), + onChanged: (value) { + isPublic = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedCheckboxInput( + labelText: context.i18n.t('entity.isVerified'), + value: isVerified, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-checkbox-input/references/api.md b/.claude/skills/themed-checkbox-input/references/api.md new file mode 100644 index 0000000..2232bd7 --- /dev/null +++ b/.claude/skills/themed-checkbox-input/references/api.md @@ -0,0 +1,107 @@ +# ThemedCheckboxInput — API Reference + +Source: `lib/src/inputs/src/general/checkbox_input.dart` + +- `ThemedCheckboxInput` class — line 22 +- `ThemedCheckboxInputStyle` enum — line 3 + +--- + +## Examples + +```dart +// Default checkbox +ThemedCheckboxInput( + labelText: 'Active', + value: isActive, + onChanged: (value) => setState(() => isActive = value), +) + +// Switch style +ThemedCheckboxInput( + labelText: 'Notifications', + value: notificationsEnabled, + style: .asSwitch, + onChanged: (value) => setState(() => notificationsEnabled = value), +) + +// New design checkbox +ThemedCheckboxInput( + labelText: 'Accept terms', + value: acceptTerms, + style: .asCheckbox2, + onChanged: (value) => setState(() => acceptTerms = value), +) + +// Dropdown field (Yes/No picker) +ThemedCheckboxInput( + labelText: 'Is public', + value: isPublic, + style: .asField, + errors: context.getErrors(key: 'isPublic'), + onChanged: (value) => setState(() => isPublic = value), +) + +// Disabled +ThemedCheckboxInput( + labelText: 'Is verified', + value: isVerified, + disabled: true, +) + +// With validation errors +ThemedCheckboxInput( + labelText: 'Accept terms', + value: acceptTerms, + errors: ['You must accept the terms'], + onChanged: (value) => setState(() => acceptTerms = value), +) +``` + +--- + +## Constructor + +```dart +const ThemedCheckboxInput({ + super.key, + this.labelText, + this.label, + this.disabled = false, + this.onChanged, + this.value = false, + this.errors = const [], + this.hideDetails = false, + this.padding = const EdgeInsets.all(10), + this.dense = false, + this.style = .asFlutterCheckbox, +}) : assert(label == null || labelText == null); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents toggling; no visual disabled state on the label | +| `onChanged` | `void Function(bool)?` | `null` | Fires with new bool value | +| `value` | `bool` | `false` | Synced to internal state on `didUpdateWidget` | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets` | `EdgeInsets.all(10)` | Outer padding around the whole widget | +| `dense` | `bool` | `false` | Reduces vertical padding | +| `style` | `ThemedCheckboxInputStyle` | `.asFlutterCheckbox` | Visual rendering style | + +--- + +## ThemedCheckboxInputStyle enum + +| Value | Description | +|---|---| +| `.asFlutterCheckbox` | Standard Flutter `Checkbox` widget (default) | +| `.asCheckbox2` | Custom `ThemedAnimatedCheckbox` — newer design | +| `.asSwitch` | Material `Switch` with label to the right | +| `.asField` | Delegates to `ThemedSelectInput` with Yes/No dropdown | diff --git a/.claude/skills/themed-color-picker/SKILL.md b/.claude/skills/themed-color-picker/SKILL.md new file mode 100644 index 0000000..a555dc5 --- /dev/null +++ b/.claude/skills/themed-color-picker/SKILL.md @@ -0,0 +1,106 @@ +--- +name: themed-color-picker +description: Use ThemedColorPicker in a layrz Flutter widget. Apply when adding a color selection field — opens a dialog with wheel and/or palette pickers. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.both`, `.wheel`) — never write the fully-qualified form (`ColorPickerType.wheel`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Any field where the user must pick a `Color` value +- Inline trigger via the standard field UI (color swatch prefix + palette icon suffix) +- Custom trigger via `customChild` (wraps any widget in an `InkWell`) + +--- + +## Minimal usage + +```dart +ThemedColorPicker( + labelText: context.i18n.t('entity.color'), + value: color, + errors: context.getErrors(key: 'color'), + onChanged: (value) { + color = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Default picker types: `[.both, .wheel]` — shows both the palette grid and the color wheel. +- Falls back to `kPrimaryColor` when `value` is `null`. +- The field displays a colored swatch as a prefix and the hex string as text (readonly). +- `customChild` mode: any widget can be used as the trigger; the dialog still opens on tap. +- `saveText` / `cancelText` default to `"OK"` / `"Cancel"` — override with i18n strings. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Wheel only (no palette) +ThemedColorPicker( + labelText: context.i18n.t('entity.color'), + value: color, + enabledTypes: const [.wheel], + onChanged: (value) { + color = value; + if (context.mounted) onChanged.call(); + }, +) + +// Custom child trigger +ThemedColorPicker( + labelText: context.i18n.t('entity.color'), + value: color, + customChild: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + ), + onChanged: (value) { + color = value; + if (context.mounted) onChanged.call(); + }, +) + +// Localized button labels +ThemedColorPicker( + labelText: context.i18n.t('entity.color'), + value: color, + saveText: context.i18n.t('actions.save'), + cancelText: context.i18n.t('actions.cancel'), + onChanged: (value) { + color = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedColorPicker( + labelText: context.i18n.t('entity.color'), + value: color, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-color-picker/references/api.md b/.claude/skills/themed-color-picker/references/api.md new file mode 100644 index 0000000..e1e1ea9 --- /dev/null +++ b/.claude/skills/themed-color-picker/references/api.md @@ -0,0 +1,146 @@ +# ThemedColorPicker — API Reference + +Source: `lib/src/inputs/src/pickers/general/color.dart` + +- `ThemedColorPicker` class — line 3 + +--- + +## Examples + +```dart +// Standard field — wheel + palette +ThemedColorPicker( + labelText: 'Color', + value: color, + errors: context.getErrors(key: 'color'), + onChanged: (value) => setState(() => color = value), +) + +// Wheel only +ThemedColorPicker( + labelText: 'Color', + value: color, + enabledTypes: const [.wheel], + onChanged: (value) => setState(() => color = value), +) + +// Palette only (both + primary + accent + bw) +ThemedColorPicker( + labelText: 'Color', + value: color, + enabledTypes: const [.both, .primary, .accent, .bw], + onChanged: (value) => setState(() => color = value), +) + +// Custom child trigger (colored box) +ThemedColorPicker( + labelText: 'Color', + value: color, + customChild: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + ), + onChanged: (value) => setState(() => color = value), +) + +// Localized button labels +ThemedColorPicker( + labelText: 'Color', + value: color, + saveText: context.i18n.t('actions.save'), + cancelText: context.i18n.t('actions.cancel'), + onChanged: (value) => setState(() => color = value), +) + +// Disabled +ThemedColorPicker( + labelText: 'Color', + value: color, + disabled: true, +) + +// Wider dialog +ThemedColorPicker( + labelText: 'Color', + value: color, + maxWidth: 600, + onChanged: (value) => setState(() => color = value), +) +``` + +--- + +## Constructor + +```dart +const ThemedColorPicker({ + super.key, + this.labelText, + this.label, + this.disabled = false, + this.onChanged, + this.value, + this.errors = const [], + this.hideDetails = false, + this.padding, + this.dense = false, + this.onPrefixTap, + this.placeholder, + this.saveText = "OK", + this.cancelText = "Cancel", + this.enabledTypes = const [.both, .wheel], + this.customChild, + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.maxWidth = 400, +}) : assert((label == null) != (labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `onChanged` | `void Function(Color)?` | `null` | Fires with the selected `Color` | +| `value` | `Color?` | `null` | Falls back to `kPrimaryColor` when null | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Defaults to `ThemedTextInput.outerPadding` (`EdgeInsets.all(10)`) | +| `dense` | `bool` | `false` | Reduces vertical padding | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the color swatch prefix | +| `placeholder` | `String?` | `null` | Hint text shown in the text field | +| `saveText` | `String` | `"OK"` | Label for the save button in the picker dialog | +| `cancelText` | `String` | `"Cancel"` | Label for the cancel button in the picker dialog | +| `enabledTypes` | `List` | `[.both, .wheel]` | Which picker tabs are enabled | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` that opens the dialog | +| `hoverColor` | `Color` | `Colors.transparent` | Hover color for `customChild` `InkWell` | +| `focusColor` | `Color` | `Colors.transparent` | Focus color for `customChild` `InkWell` | +| `splashColor` | `Color` | `Colors.transparent` | Splash color for `customChild` `InkWell` | +| `highlightColor` | `Color` | `Colors.transparent` | Highlight color for `customChild` `InkWell` | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | Border radius for `customChild` `InkWell` | +| `maxWidth` | `double` | `400` | Max width of the picker dialog | + +--- + +## ColorPickerType enum (from `flex_color_picker` package) + +| Value | Description | +|---|---| +| `.both` | Both primary and accent color palette | +| `.primary` | Primary color palette only | +| `.accent` | Accent color palette only | +| `.bw` | Black and white palette | +| `.custom` | Custom color palette | +| `.wheel` | HSV color wheel | diff --git a/.claude/skills/themed-date-picker/SKILL.md b/.claude/skills/themed-date-picker/SKILL.md new file mode 100644 index 0000000..a25d817 --- /dev/null +++ b/.claude/skills/themed-date-picker/SKILL.md @@ -0,0 +1,106 @@ +--- +name: themed-date-picker +description: Use ThemedDatePicker in a layrz Flutter widget. Apply when adding a single date selection field — opens a calendar dialog. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Single date selection (year + month + day) +- Inline calendar dialog triggered from a text field + +For date ranges → use `ThemedDateRangePicker`. For month/year only → use `ThemedMonthPicker`. + +--- + +## Minimal usage + +```dart +ThemedDatePicker( + labelText: context.i18n.t('entity.date'), + value: selectedDate, + errors: context.getErrors(key: 'date'), + onChanged: (value) { + selectedDate = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Displays selected date formatted with `pattern` (default `'%Y-%m-%d'`). +- Opens a `ThemedCalendar` dialog (400×400 max) on tap; suffix is a calendar icon. +- `disabledDays` blocks specific dates in the calendar. +- `firstDay` / `lastDay` set hard limits — all dates outside the range are disabled. +- Timezone-aware: if `value` is `TZDateTime`, the selected date is returned in the same timezone. +- `customChild` wraps any widget in an `InkWell` that opens the calendar. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// With date limits +ThemedDatePicker( + labelText: context.i18n.t('entity.startDate'), + value: startDate, + firstDay: DateTime(2020, 1, 1), + lastDay: DateTime.now(), + errors: context.getErrors(key: 'startDate'), + onChanged: (value) { + startDate = value; + if (context.mounted) onChanged.call(); + }, +) + +// Custom date format +ThemedDatePicker( + labelText: context.i18n.t('entity.date'), + value: selectedDate, + pattern: '%d/%m/%Y', + onChanged: (value) { + selectedDate = value; + if (context.mounted) onChanged.call(); + }, +) + +// Custom child trigger +ThemedDatePicker( + labelText: context.i18n.t('entity.date'), + value: selectedDate, + customChild: ThemedButton( + labelText: selectedDate?.toString() ?? 'Pick date', + icon: LayrzIcons.solarOutlineCalendar, + onTap: () {}, + ), + onChanged: (value) { + selectedDate = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedDatePicker( + labelText: context.i18n.t('entity.date'), + value: selectedDate, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-date-picker/references/api.md b/.claude/skills/themed-date-picker/references/api.md new file mode 100644 index 0000000..926bc5b --- /dev/null +++ b/.claude/skills/themed-date-picker/references/api.md @@ -0,0 +1,146 @@ +# ThemedDatePicker — API Reference + +Source: `lib/src/inputs/src/pickers/date/single.dart` + +- `ThemedDatePicker` class — line 3 + +--- + +## Examples + +```dart +// Basic single date +ThemedDatePicker( + labelText: 'Date', + value: selectedDate, + errors: context.getErrors(key: 'date'), + onChanged: (value) => setState(() => selectedDate = value), +) + +// With date limits +ThemedDatePicker( + labelText: 'Start date', + value: startDate, + firstDay: DateTime(2020, 1, 1), + lastDay: DateTime.now(), + onChanged: (value) => setState(() => startDate = value), +) + +// Custom format +ThemedDatePicker( + labelText: 'Date', + value: selectedDate, + pattern: '%d/%m/%Y', + onChanged: (value) => setState(() => selectedDate = value), +) + +// Specific disabled days +ThemedDatePicker( + labelText: 'Date', + value: selectedDate, + disabledDays: [ + DateTime(2025, 1, 1), + DateTime(2025, 12, 25), + ], + onChanged: (value) => setState(() => selectedDate = value), +) + +// Custom child trigger +ThemedDatePicker( + labelText: 'Date', + value: selectedDate, + customChild: Container( + padding: const EdgeInsets.all(8), + child: Text(selectedDate?.toString() ?? 'Pick a date'), + ), + onChanged: (value) => setState(() => selectedDate = value), +) + +// With prefix icon +ThemedDatePicker( + labelText: 'Date', + value: selectedDate, + prefixIcon: LayrzIcons.solarOutlineCalendar, + onChanged: (value) => setState(() => selectedDate = value), +) + +// Disabled +ThemedDatePicker( + labelText: 'Date', + value: selectedDate, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedDatePicker({ + super.key, + this.value, + this.onChanged, + this.labelText, + this.label, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.customChild, + this.disabled = false, + this.translations = const { + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'layrz.monthPicker.year': 'Year {year}', + 'layrz.monthPicker.back': 'Previous year', + 'layrz.monthPicker.next': 'Next year', + }, + this.overridesLayrzTranslations = false, + this.disabledDays = const [], + this.pattern = '%Y-%m-%d', + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.errors = const [], + this.hideDetails = false, + this.padding, + this.firstDay, + this.lastDay, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `DateTime?` | `null` | Selected date; supports `TZDateTime` | +| `onChanged` | `void Function(DateTime)?` | `null` | Fires with the selected date | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text in the text field | +| `prefixText` | `String?` | `null` | Inline text prefix | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the prefix | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `disabled` | `bool` | `false` | Prevents opening the calendar | +| `translations` | `Map` | (see above) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map even when i18n is present | +| `disabledDays` | `List` | `[]` | Specific dates blocked in the calendar | +| `pattern` | `String` | `'%Y-%m-%d'` | Display format (strftime-style) | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | +| `firstDay` | `DateTime?` | `null` | Hard lower bound — all earlier dates disabled | +| `lastDay` | `DateTime?` | `null` | Hard upper bound — all later dates disabled | diff --git a/.claude/skills/themed-date-range-picker/SKILL.md b/.claude/skills/themed-date-range-picker/SKILL.md new file mode 100644 index 0000000..82489c0 --- /dev/null +++ b/.claude/skills/themed-date-range-picker/SKILL.md @@ -0,0 +1,101 @@ +--- +name: themed-date-range-picker +description: Use ThemedDateRangePicker in a layrz Flutter widget. Apply when adding a date range selection field — user taps two dates to define start and end. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Date range selection (start date + end date) +- User taps first date, then second date to complete the range + +For a single date → use `ThemedDatePicker`. For month ranges → use `ThemedMonthRangePicker`. + +--- + +## Minimal usage + +```dart +ThemedDateRangePicker( + labelText: context.i18n.t('entity.dateRange'), + value: dateRange, + errors: context.getErrors(key: 'dateRange'), + onChanged: (value) { + dateRange = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- `value` is `List` — must be empty or exactly 2 elements (assert enforced). +- Displays as `"startDate - endDate"` formatted with `pattern` (default `'%Y-%m-%d'`). +- Two-tap selection: first tap sets the anchor date, second tap completes the range (order doesn't matter — earlier date becomes start). +- `firstDay` / `lastDay` set hard calendar limits. +- Timezone-aware: if start date is `TZDateTime`, the result is returned in the same timezone. +- `customChild` wraps any widget in an `InkWell` that opens the picker dialog. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// With date limits +ThemedDateRangePicker( + labelText: context.i18n.t('entity.period'), + value: period, + firstDay: DateTime(2020, 1, 1), + lastDay: DateTime.now(), + errors: context.getErrors(key: 'period'), + onChanged: (value) { + period = value; + if (context.mounted) onChanged.call(); + }, +) + +// Custom date format +ThemedDateRangePicker( + labelText: context.i18n.t('entity.dateRange'), + value: dateRange, + pattern: '%d/%m/%Y', + onChanged: (value) { + dateRange = value; + if (context.mounted) onChanged.call(); + }, +) + +// Empty initial value (no selection) +ThemedDateRangePicker( + labelText: context.i18n.t('entity.dateRange'), + value: const [], + onChanged: (value) { + dateRange = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedDateRangePicker( + labelText: context.i18n.t('entity.dateRange'), + value: dateRange, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-date-range-picker/references/api.md b/.claude/skills/themed-date-range-picker/references/api.md new file mode 100644 index 0000000..ee54d04 --- /dev/null +++ b/.claude/skills/themed-date-range-picker/references/api.md @@ -0,0 +1,139 @@ +# ThemedDateRangePicker — API Reference + +Source: `lib/src/inputs/src/pickers/date/range.dart` + +- `ThemedDateRangePicker` class — line 3 + +--- + +## Examples + +```dart +// Basic date range (empty initial) +ThemedDateRangePicker( + labelText: 'Date range', + value: const [], + errors: context.getErrors(key: 'dateRange'), + onChanged: (value) => setState(() => dateRange = value), +) + +// Pre-filled range +ThemedDateRangePicker( + labelText: 'Date range', + value: [DateTime(2025, 1, 1), DateTime(2025, 1, 31)], + onChanged: (value) => setState(() => dateRange = value), +) + +// With date limits +ThemedDateRangePicker( + labelText: 'Report period', + value: dateRange, + firstDay: DateTime(2020, 1, 1), + lastDay: DateTime.now(), + onChanged: (value) => setState(() => dateRange = value), +) + +// Custom format +ThemedDateRangePicker( + labelText: 'Date range', + value: dateRange, + pattern: '%d/%m/%Y', + onChanged: (value) => setState(() => dateRange = value), +) + +// Custom child trigger +ThemedDateRangePicker( + labelText: 'Date range', + value: dateRange, + customChild: Text(dateRange.isEmpty ? 'Select range' : '${dateRange.first} – ${dateRange.last}'), + onChanged: (value) => setState(() => dateRange = value), +) + +// Disabled +ThemedDateRangePicker( + labelText: 'Date range', + value: dateRange, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedDateRangePicker({ + super.key, + this.value = const [], + this.onChanged, + this.labelText, + this.label, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.customChild, + this.disabled = false, + this.translations = const { + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'layrz.monthPicker.year': 'Year {year}', + 'layrz.monthPicker.back': 'Previous year', + 'layrz.monthPicker.next': 'Next year', + }, + this.overridesLayrzTranslations = false, + this.pattern = '%Y-%m-%d', + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.errors = const [], + this.hideDetails = false, + this.padding, + this.firstDay, + this.lastDay, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)), + assert(value.length == 0 || value.length == 2); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `List` | `[]` | Empty or exactly 2 elements (assert enforced) | +| `onChanged` | `void Function(List)?` | `null` | Fires with `[start, end]` list | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text in the text field | +| `prefixText` | `String?` | `null` | Inline text prefix | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the prefix | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `disabled` | `bool` | `false` | Prevents opening the calendar | +| `translations` | `Map` | (see above) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map even when i18n is present | +| `pattern` | `String` | `'%Y-%m-%d'` | Display format for both dates (strftime-style) | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | +| `firstDay` | `DateTime?` | `null` | Hard lower bound — all earlier dates disabled | +| `lastDay` | `DateTime?` | `null` | Hard upper bound — all later dates disabled | + +--- + +## Selection behavior + +1. First tap → sets anchor date (highlighted on calendar). +2. Second tap → completes the range. If second < first, they are automatically swapped. +3. Result is always `[earlier, later]`. +4. Timezone: if `value.first` is `TZDateTime`, all result dates are coerced to the same timezone. diff --git a/.claude/skills/themed-datetime-picker/SKILL.md b/.claude/skills/themed-datetime-picker/SKILL.md new file mode 100644 index 0000000..6bc2e80 --- /dev/null +++ b/.claude/skills/themed-datetime-picker/SKILL.md @@ -0,0 +1,103 @@ +--- +name: themed-datetime-picker +description: Use ThemedDateTimePicker in a layrz Flutter widget. Apply when adding a single date+time selection field — opens a tabbed dialog with calendar and time picker. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Single date + time selection (year, month, day, hour, minute) +- Tab-based dialog: "Date" tab (calendar) + "Time" tab (hour/minute sliders) shown together + +For date only → use `ThemedDatePicker`. For date+time in two separate sequential dialogs → use `ThemedDateTimeSteppedPicker`. For date+time ranges → use `ThemedDateTimeRangePicker`. + +--- + +## Minimal usage + +```dart +ThemedDateTimePicker( + labelText: context.i18n.t('entity.datetime'), + value: selectedDatetime, + errors: context.getErrors(key: 'datetime'), + onChanged: (value) { + selectedDatetime = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Dialog has two tabs: **Date** (calendar) and **Time** (hour/minute sliders) — user switches between them freely before saving. +- Default time format: 12-hour (`%I:%M %p`). Set `use24HourFormat: true` for 24-hour (`%H:%M`). +- Display pattern is `'$datePattern$patternSeparator$timePattern'` — default `'%Y-%m-%d %I:%M %p'`. +- `firstDay` / `lastDay` constrain the calendar. +- Timezone-aware: if `value` is `TZDateTime`, the result is returned in the same timezone. +- `customChild` wraps any widget in an `InkWell` that opens the dialog. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// 24-hour format +ThemedDateTimePicker( + labelText: context.i18n.t('entity.datetime'), + value: selectedDatetime, + use24HourFormat: true, + errors: context.getErrors(key: 'datetime'), + onChanged: (value) { + selectedDatetime = value; + if (context.mounted) onChanged.call(); + }, +) + +// With date limits +ThemedDateTimePicker( + labelText: context.i18n.t('entity.scheduledAt'), + value: scheduledAt, + firstDay: DateTime.now(), + lastDay: DateTime.now().add(const Duration(days: 365)), + onChanged: (value) { + scheduledAt = value; + if (context.mounted) onChanged.call(); + }, +) + +// Custom display format +ThemedDateTimePicker( + labelText: context.i18n.t('entity.datetime'), + value: selectedDatetime, + datePattern: '%d/%m/%Y', + use24HourFormat: true, + onChanged: (value) { + selectedDatetime = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedDateTimePicker( + labelText: context.i18n.t('entity.datetime'), + value: selectedDatetime, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-datetime-picker/references/api.md b/.claude/skills/themed-datetime-picker/references/api.md new file mode 100644 index 0000000..d79b0be --- /dev/null +++ b/.claude/skills/themed-datetime-picker/references/api.md @@ -0,0 +1,162 @@ +# ThemedDateTimePicker — API Reference + +Source: `lib/src/inputs/src/pickers/datetime/single.dart` + +- `ThemedDateTimePicker` class — line 3 + +--- + +## Examples + +```dart +// Basic datetime (12-hour format) +ThemedDateTimePicker( + labelText: 'Date & time', + value: selectedDatetime, + errors: context.getErrors(key: 'datetime'), + onChanged: (value) => setState(() => selectedDatetime = value), +) + +// 24-hour format +ThemedDateTimePicker( + labelText: 'Date & time', + value: selectedDatetime, + use24HourFormat: true, + onChanged: (value) => setState(() => selectedDatetime = value), +) + +// With date limits +ThemedDateTimePicker( + labelText: 'Scheduled at', + value: scheduledAt, + firstDay: DateTime.now(), + lastDay: DateTime.now().add(const Duration(days: 365)), + onChanged: (value) => setState(() => scheduledAt = value), +) + +// Custom date format +ThemedDateTimePicker( + labelText: 'Date & time', + value: selectedDatetime, + datePattern: '%d/%m/%Y', + use24HourFormat: true, + onChanged: (value) => setState(() => selectedDatetime = value), +) + +// Custom separator between date and time +ThemedDateTimePicker( + labelText: 'Date & time', + value: selectedDatetime, + patternSeparator: ' | ', + onChanged: (value) => setState(() => selectedDatetime = value), +) + +// Custom child trigger +ThemedDateTimePicker( + labelText: 'Date & time', + value: selectedDatetime, + customChild: Text(selectedDatetime?.toString() ?? 'Pick date & time'), + onChanged: (value) => setState(() => selectedDatetime = value), +) + +// Disabled +ThemedDateTimePicker( + labelText: 'Date & time', + value: selectedDatetime, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedDateTimePicker({ + super.key, + this.value, + this.onChanged, + this.labelText, + this.label, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.customChild, + this.disabled = false, + this.translations = const { ... }, // see Translations section below + this.overridesLayrzTranslations = false, + this.disabledDays = const [], + this.datePattern = '%Y-%m-%d', + this.timePattern, + this.use24HourFormat = false, + this.patternSeparator = ' ', + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.errors = const [], + this.hideDetails = false, + this.padding, + this.firstDay, + this.lastDay, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `DateTime?` | `null` | Supports `TZDateTime` | +| `onChanged` | `void Function(DateTime)?` | `null` | Fires with the selected datetime | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text in the text field | +| `prefixText` | `String?` | `null` | Inline text prefix | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the prefix | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | +| `disabledDays` | `List` | `[]` | Specific dates blocked in the calendar | +| `datePattern` | `String` | `'%Y-%m-%d'` | Date portion display format | +| `timePattern` | `String?` | `null` | Time portion format; overrides `use24HourFormat` when set | +| `use24HourFormat` | `bool` | `false` | `true` → `%H:%M`, `false` → `%I:%M %p` | +| `patternSeparator` | `String` | `' '` | Separator between date and time portions | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | +| `firstDay` | `DateTime?` | `null` | Hard lower bound — earlier dates disabled | +| `lastDay` | `DateTime?` | `null` | Hard upper bound — later dates disabled | + +--- + +## Translations map keys + +| Key | Default | +|---|---| +| `actions.cancel` | `'Cancel'` | +| `actions.save` | `'Save'` | +| `layrz.monthPicker.year` | `'Year {year}'` | +| `layrz.monthPicker.back` | `'Previous year'` | +| `layrz.monthPicker.next` | `'Next year'` | +| `layrz.datetimePicker.date` | `'Date'` | +| `layrz.datetimePicker.time` | `'Time'` | +| `layrz.timePicker.hours` | `'Hours'` | +| `layrz.timePicker.minutes` | `'Minutes'` | +| `layrz.calendar.month.back` | `'Previous month'` | +| `layrz.calendar.month.next` | `'Next month'` | +| `layrz.calendar.today` | `'Today'` | +| `layrz.calendar.month` | `'View as month'` | +| `layrz.calendar.pickMonth` | `'Pick a month'` | diff --git a/.claude/skills/themed-datetime-range-picker/SKILL.md b/.claude/skills/themed-datetime-range-picker/SKILL.md new file mode 100644 index 0000000..36f5540 --- /dev/null +++ b/.claude/skills/themed-datetime-range-picker/SKILL.md @@ -0,0 +1,102 @@ +--- +name: themed-datetime-range-picker +description: Use ThemedDateTimeRangePicker in a layrz Flutter widget. Apply when adding a date+time range selection field — picks start and end datetimes with separate time controls. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Date+time range selection (start datetime + end datetime) +- Dialog shows a two-tap calendar (first tap = start date, second tap = end date) with separate start/end time sliders + +For date-only ranges → use `ThemedDateRangePicker`. For a single datetime → use `ThemedDateTimePicker`. + +--- + +## Minimal usage + +```dart +ThemedDateTimeRangePicker( + labelText: context.i18n.t('entity.datetimeRange'), + value: datetimeRange, + errors: context.getErrors(key: 'datetimeRange'), + onChanged: (value) { + datetimeRange = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- `value` is `List` — must be empty or exactly 2 elements (assert enforced). +- Dialog has two tabs: **Date** (two-tap range calendar) and **Time** (two independent time sliders for start and end). +- Result is always sorted `[earlier, later]`. +- Default time format: 12-hour. Set `use24HourFormat: true` for 24-hour. +- Display: `"startDatetime - endDatetime"` formatted with the pattern. +- Timezone-aware: if `value.first` is `TZDateTime`, the result is returned in the same timezone. +- `customChild` wraps any widget in an `InkWell` that opens the dialog. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// 24-hour format +ThemedDateTimeRangePicker( + labelText: context.i18n.t('entity.period'), + value: period, + use24HourFormat: true, + errors: context.getErrors(key: 'period'), + onChanged: (value) { + period = value; + if (context.mounted) onChanged.call(); + }, +) + +// With date limits +ThemedDateTimeRangePicker( + labelText: context.i18n.t('entity.period'), + value: period, + firstDay: DateTime(2020, 1, 1), + lastDay: DateTime.now(), + onChanged: (value) { + period = value; + if (context.mounted) onChanged.call(); + }, +) + +// Empty initial value +ThemedDateTimeRangePicker( + labelText: context.i18n.t('entity.period'), + value: const [], + onChanged: (value) { + period = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedDateTimeRangePicker( + labelText: context.i18n.t('entity.period'), + value: period, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-datetime-range-picker/references/api.md b/.claude/skills/themed-datetime-range-picker/references/api.md new file mode 100644 index 0000000..b18dc3f --- /dev/null +++ b/.claude/skills/themed-datetime-range-picker/references/api.md @@ -0,0 +1,149 @@ +# ThemedDateTimeRangePicker — API Reference + +Source: `lib/src/inputs/src/pickers/datetime/range.dart` + +- `ThemedDateTimeRangePicker` class — line 3 +- `ThemedDateTimeRangeDialog` class (internal dialog widget) — line ~170 + +--- + +## Examples + +```dart +// Basic datetime range (empty initial) +ThemedDateTimeRangePicker( + labelText: 'Period', + value: const [], + errors: context.getErrors(key: 'period'), + onChanged: (value) => setState(() => period = value), +) + +// Pre-filled range +ThemedDateTimeRangePicker( + labelText: 'Period', + value: [DateTime(2025, 1, 1, 8, 0), DateTime(2025, 1, 31, 18, 0)], + onChanged: (value) => setState(() => period = value), +) + +// 24-hour format +ThemedDateTimeRangePicker( + labelText: 'Period', + value: period, + use24HourFormat: true, + onChanged: (value) => setState(() => period = value), +) + +// With date limits +ThemedDateTimeRangePicker( + labelText: 'Report period', + value: period, + firstDay: DateTime(2020, 1, 1), + lastDay: DateTime.now(), + onChanged: (value) => setState(() => period = value), +) + +// Custom date format +ThemedDateTimeRangePicker( + labelText: 'Period', + value: period, + datePattern: '%d/%m/%Y', + use24HourFormat: true, + onChanged: (value) => setState(() => period = value), +) + +// Disabled +ThemedDateTimeRangePicker( + labelText: 'Period', + value: period, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedDateTimeRangePicker({ + super.key, + this.value = const [], + this.onChanged, + this.labelText, + this.label, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.customChild, + this.disabled = false, + this.translations = const { ... }, // see Translations section + this.overridesLayrzTranslations = false, + this.disabledDays = const [], + this.datePattern = '%Y-%m-%d', + this.timePattern, + this.use24HourFormat = false, + this.patternSeparator = ' ', + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.errors = const [], + this.hideDetails = false, + this.padding, + this.firstDay, + this.lastDay, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)), + assert(value.length == 0 || value.length == 2); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `List` | `[]` | Empty or exactly 2 elements (assert enforced) | +| `onChanged` | `void Function(List)?` | `null` | Fires with sorted `[start, end]` | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text in the text field | +| `prefixText` | `String?` | `null` | Inline text prefix | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the prefix | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | +| `disabledDays` | `List` | `[]` | Specific dates blocked in the calendar | +| `datePattern` | `String` | `'%Y-%m-%d'` | Date portion display format | +| `timePattern` | `String?` | `null` | Time portion format; overrides `use24HourFormat` when set | +| `use24HourFormat` | `bool` | `false` | `true` → `%H:%M`, `false` → `%I:%M %p` | +| `patternSeparator` | `String` | `' '` | Separator between date and time portions | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | +| `firstDay` | `DateTime?` | `null` | Hard lower bound — earlier dates disabled | +| `lastDay` | `DateTime?` | `null` | Hard upper bound — later dates disabled | + +--- + +## Dialog behavior + +- Tab 1 (Date): two-tap calendar. First tap sets start date, second tap sets end date. +- Tab 2 (Time): two independent time sliders — one for start time, one for end time. +- Save result is always sorted `[earlier, later]`. +- Timezone: if `value.first` is `TZDateTime`, result dates are coerced to the same timezone. + +--- + +## Translations map keys + +Same as `ThemedDateTimePicker` — see `themed-datetime-picker/references/api.md`. diff --git a/.claude/skills/themed-datetime-stepped-picker/SKILL.md b/.claude/skills/themed-datetime-stepped-picker/SKILL.md new file mode 100644 index 0000000..3926758 --- /dev/null +++ b/.claude/skills/themed-datetime-stepped-picker/SKILL.md @@ -0,0 +1,93 @@ +--- +name: themed-datetime-stepped-picker +description: Use ThemedDateTimeSteppedPicker in a layrz Flutter widget. Apply when adding a date+time field that opens the calendar first, then the time picker as a second separate dialog. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Single date+time selection where you prefer two sequential dialogs (calendar → time picker) +- Mobile-friendly UX: each step is focused and full-dialog + +For a single tabbed dialog (calendar + time in one) → use `ThemedDateTimePicker`. For date+time ranges → use `ThemedDateTimeRangePicker`. + +--- + +## Minimal usage + +```dart +ThemedDateTimeSteppedPicker( + labelText: context.i18n.t('entity.datetime'), + value: selectedDatetime, + errors: context.getErrors(key: 'datetime'), + onChanged: (value) { + selectedDatetime = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- **Step 1:** Calendar dialog opens → user picks a date. +- **Step 2:** Time picker dialog opens automatically after date selection → user picks hour/minute. +- `disableTimePickerBlink: true` turns off the blinking cursor in the time picker. +- Default time format: 12-hour. Set `use24HourFormat: true` for 24-hour. +- Display pattern same as `ThemedDateTimePicker`: `'$datePattern$patternSeparator$timePattern'`. +- `firstDay` / `lastDay` constrain the calendar step. +- `customChild` wraps any widget in an `InkWell` that opens the flow. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// 24-hour format, no blink +ThemedDateTimeSteppedPicker( + labelText: context.i18n.t('entity.datetime'), + value: selectedDatetime, + use24HourFormat: true, + disableTimePickerBlink: true, + errors: context.getErrors(key: 'datetime'), + onChanged: (value) { + selectedDatetime = value; + if (context.mounted) onChanged.call(); + }, +) + +// With date limits +ThemedDateTimeSteppedPicker( + labelText: context.i18n.t('entity.scheduledAt'), + value: scheduledAt, + firstDay: DateTime.now(), + lastDay: DateTime.now().add(const Duration(days: 365)), + onChanged: (value) { + scheduledAt = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedDateTimeSteppedPicker( + labelText: context.i18n.t('entity.datetime'), + value: selectedDatetime, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-datetime-stepped-picker/references/api.md b/.claude/skills/themed-datetime-stepped-picker/references/api.md new file mode 100644 index 0000000..4350b59 --- /dev/null +++ b/.claude/skills/themed-datetime-stepped-picker/references/api.md @@ -0,0 +1,150 @@ +# ThemedDateTimeSteppedPicker — API Reference + +Source: `lib/src/inputs/src/pickers/datetime/single_stepped.dart` + +- `ThemedDateTimeSteppedPicker` class — line 3 + +--- + +## Examples + +```dart +// Basic stepped picker (12-hour) +ThemedDateTimeSteppedPicker( + labelText: 'Date & time', + value: selectedDatetime, + errors: context.getErrors(key: 'datetime'), + onChanged: (value) => setState(() => selectedDatetime = value), +) + +// 24-hour, no blink +ThemedDateTimeSteppedPicker( + labelText: 'Date & time', + value: selectedDatetime, + use24HourFormat: true, + disableTimePickerBlink: true, + onChanged: (value) => setState(() => selectedDatetime = value), +) + +// With date limits +ThemedDateTimeSteppedPicker( + labelText: 'Scheduled at', + value: scheduledAt, + firstDay: DateTime.now(), + lastDay: DateTime.now().add(const Duration(days: 365)), + onChanged: (value) => setState(() => scheduledAt = value), +) + +// Custom date format +ThemedDateTimeSteppedPicker( + labelText: 'Date & time', + value: selectedDatetime, + datePattern: '%d/%m/%Y', + use24HourFormat: true, + onChanged: (value) => setState(() => selectedDatetime = value), +) + +// Custom child trigger +ThemedDateTimeSteppedPicker( + labelText: 'Date & time', + value: selectedDatetime, + customChild: Text(selectedDatetime?.toString() ?? 'Pick date & time'), + onChanged: (value) => setState(() => selectedDatetime = value), +) + +// Disabled +ThemedDateTimeSteppedPicker( + labelText: 'Date & time', + value: selectedDatetime, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedDateTimeSteppedPicker({ + super.key, + this.value, + this.onChanged, + this.labelText, + this.label, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.customChild, + this.disabled = false, + this.translations = const { ... }, // see Translations section + this.overridesLayrzTranslations = false, + this.disabledDays = const [], + this.datePattern = '%Y-%m-%d', + this.timePattern, + this.use24HourFormat = false, + this.patternSeparator = ' ', + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.errors = const [], + this.hideDetails = false, + this.padding, + this.disableTimePickerBlink = false, + this.firstDay, + this.lastDay, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `DateTime?` | `null` | Supports `TZDateTime` | +| `onChanged` | `void Function(DateTime)?` | `null` | Fires with the selected datetime | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text in the text field | +| `prefixText` | `String?` | `null` | Inline text prefix | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the prefix | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | +| `disabledDays` | `List` | `[]` | Specific dates blocked in the calendar step | +| `datePattern` | `String` | `'%Y-%m-%d'` | Date portion display format | +| `timePattern` | `String?` | `null` | Time portion format; overrides `use24HourFormat` when set | +| `use24HourFormat` | `bool` | `false` | `true` → `%H:%M`, `false` → `%I:%M %p` | +| `patternSeparator` | `String` | `' '` | Separator between date and time portions | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | +| `disableTimePickerBlink` | `bool` | `false` | Disables the blinking cursor in the time picker dialog | +| `firstDay` | `DateTime?` | `null` | Hard lower bound — earlier dates disabled | +| `lastDay` | `DateTime?` | `null` | Hard upper bound — later dates disabled | + +--- + +## Stepped flow + +1. Calendar dialog opens (400×400 max). User selects a day → dialog closes. +2. Time picker dialog opens immediately. User selects hour/minute → dialog closes. +3. `onChanged` fires with the combined `DateTime`. + +--- + +## Translations map keys + +Same as `ThemedDateTimePicker` — see `themed-datetime-picker/references/api.md`. diff --git a/.claude/skills/themed-dual-list-input/SKILL.md b/.claude/skills/themed-dual-list-input/SKILL.md new file mode 100644 index 0000000..b7730e6 --- /dev/null +++ b/.claude/skills/themed-dual-list-input/SKILL.md @@ -0,0 +1,96 @@ +--- +name: themed-dual-list-input +description: Use ThemedDualListInput in a layrz Flutter widget. Apply when adding a two-panel list field where users move items between an "Available" and a "Selected" list. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Multi-value selection where the user needs to see both available and selected items simultaneously +- Value type: `List` (IDs or values of selected items) + +For a compact multi-select dialog → use `ThemedMultiSelectInput`. For single-value selection → use `ThemedSelectInput`. + +--- + +## Minimal usage + +```dart +ThemedDualListInput( + labelText: context.i18n.t('entity.items'), + items: sourceList.map((e) => ThemedSelectItem(value: e.id, label: e.name)).toList(), + value: selectedIds, + errors: context.getErrors(key: 'items'), + onChanged: (items) { + selectedIds = items.map((e) => e.value).nonNulls.toList(); + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Renders two side-by-side scrollable lists: Available (left) and Selected (right). +- Items move between lists via individual row buttons or "Toggle all" buttons. +- On mobile (width < `kExtraSmallGrid`), the lists stack vertically; height is multiplied by `mobileScaleFactor`. +- `onChanged` fires with the full list of selected `ThemedSelectItem` — extract values with `.map((e) => e.value).nonNulls.toList()`. +- `compareFunction` overrides item equality; default uses `==`. +- `availableListName` / `selectedListName` customize the column headers. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Custom list headers +ThemedDualListInput( + labelText: context.i18n.t('asset.pois'), + items: pois.map((p) => ThemedSelectItem(value: p.id, label: p.name)).toList(), + value: selectedPoiIds, + availableListName: context.i18n.t('helpers.available'), + selectedListName: context.i18n.t('helpers.selected'), + errors: context.getErrors(key: 'pois'), + onChanged: (items) { + selectedPoiIds = items.map((e) => e.value).nonNulls.toList(); + if (context.mounted) onChanged.call(); + }, +) + +// Custom equality (e.g. comparing by a nested property) +ThemedDualListInput( + labelText: context.i18n.t('entity.items'), + items: sourceList.map((e) => ThemedSelectItem(value: e, label: e.name)).toList(), + value: selectedItems, + compareFunction: (a, b) => a?.id == b?.id, + onChanged: (items) { + selectedItems = items.map((e) => e.value).nonNulls.toList(); + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedDualListInput( + labelText: context.i18n.t('entity.items'), + items: sourceList.map((e) => ThemedSelectItem(value: e.id, label: e.name)).toList(), + value: selectedIds, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Use `.nonNulls` to filter nulls from the mapped values. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-dual-list-input/references/api.md b/.claude/skills/themed-dual-list-input/references/api.md new file mode 100644 index 0000000..5cea9d3 --- /dev/null +++ b/.claude/skills/themed-dual-list-input/references/api.md @@ -0,0 +1,139 @@ +# ThemedDualListInput — API Reference + +Source: `lib/src/inputs/src/general/duallist_input.dart` + +- `ThemedDualListInput` class — line 3 + +--- + +## Examples + +```dart +// Basic dual list +ThemedDualListInput( + labelText: 'Items', + items: sourceList.map((e) => ThemedSelectItem(value: e.id, label: e.name)).toList(), + value: selectedIds, + errors: context.getErrors(key: 'items'), + onChanged: (items) => setState(() { + selectedIds = items.map((e) => e.value).nonNulls.toList(); + }), +) + +// Custom headers +ThemedDualListInput( + labelText: 'POIs', + items: pois.map((p) => ThemedSelectItem(value: p.id, label: p.name)).toList(), + value: selectedPoiIds, + availableListName: 'Available POIs', + selectedListName: 'Assigned POIs', + onChanged: (items) => setState(() { + selectedPoiIds = items.map((e) => e.value).nonNulls.toList(); + }), +) + +// Custom equality +ThemedDualListInput( + labelText: 'Items', + items: models.map((m) => ThemedSelectItem(value: m, label: m.name)).toList(), + value: selectedModels, + compareFunction: (a, b) => a?.id == b?.id, + onChanged: (items) => setState(() { + selectedModels = items.map((e) => e.value).nonNulls.toList(); + }), +) + +// Disabled +ThemedDualListInput( + labelText: 'Items', + items: sourceList.map((e) => ThemedSelectItem(value: e.id, label: e.name)).toList(), + value: selectedIds, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedDualListInput({ + super.key, + this.labelText, + this.label, + required this.items, + this.onChanged, + this.value, + this.disabled = false, + this.errors = const [], + this.translations = const { + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'layrz.duallist.search': 'Search in {name}', + 'layrz.duallist.toggleToSelected': 'Toggle all to selected', + 'layrz.duallist.toggleToAvailable': 'Toggle all to available', + }, + this.overridesLayrzTranslations = false, + this.height = 400, + this.availableListName = 'Available', + this.selectedListName = 'Selected', + this.mobileScaleFactor = 2, + this.compareFunction, + this.itemExtent = 50, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `items` | `List>` | required | All available items (both panels) | +| `value` | `List?` | `null` | Currently selected values | +| `onChanged` | `void Function(List>)?` | `null` | Fires with the full selected item list | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents item movement | +| `errors` | `List` | `[]` | Validation messages shown below the widget | +| `height` | `double` | `400` | Height of each list panel (desktop) | +| `availableListName` | `String` | `'Available'` | Header label for the left (available) panel | +| `selectedListName` | `String` | `'Selected'` | Header label for the right (selected) panel | +| `mobileScaleFactor` | `double` | `2` | Multiplied by `height` on mobile (< `kExtraSmallGrid`) for stacked layout | +| `compareFunction` | `bool Function(T?, T?)?` | `null` | Custom equality; defaults to `==` | +| `itemExtent` | `double` | `50` | Fixed row height in each list | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | + +--- + +## Translations map keys + +| Key | Default | Notes | +|---|---|---| +| `actions.cancel` | `'Cancel'` | Cancel button | +| `actions.save` | `'Save'` | Save button | +| `layrz.duallist.search` | `'Search in {name}'` | Search field placeholder; `{name}` = list panel name | +| `layrz.duallist.toggleToSelected` | `'Toggle all to selected'` | Move-all-right button tooltip | +| `layrz.duallist.toggleToAvailable` | `'Toggle all to available'` | Move-all-left button tooltip | + +--- + +## ThemedSelectItem + +```dart +ThemedSelectItem( + value: T, // the stored value + label: String, // displayed text + // onTap: VoidCallback? // optional tap handler inside dialog +) +``` + +--- + +## Layout behavior + +- Desktop: two panels side-by-side with arrow buttons between them. +- Mobile (width < `kExtraSmallGrid`): panels stack vertically; total height = `height * mobileScaleFactor`. +- Each panel has an independent search field. +- Individual row buttons move one item at a time; "Toggle all" buttons move all items at once. diff --git a/.claude/skills/themed-duration-input/SKILL.md b/.claude/skills/themed-duration-input/SKILL.md new file mode 100644 index 0000000..b7126d1 --- /dev/null +++ b/.claude/skills/themed-duration-input/SKILL.md @@ -0,0 +1,104 @@ +--- +name: themed-duration-input +description: Use ThemedDurationInput in a layrz Flutter widget. Apply when adding a time span field (timeout, interval, shift length) that stores a Duration value. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Time span fields stored as `Duration`: timeouts, intervals, retention periods, shift lengths +- Value type: `Duration?` +- Prefer this over storing duration as a plain integer (seconds/milliseconds) — gives the user explicit unit controls and a human-readable display. + +For scalar numeric fields → use `ThemedNumberInput`. + +--- + +## Minimal usage + +```dart +ThemedDurationInput( + labelText: context.i18n.t('entity.timeout'), + value: timeout, + errors: context.getErrors(key: 'timeout'), + onChanged: (value) { + timeout = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Readonly text field — tapping opens a dialog with one `ThemedNumberInput` per visible time unit. +- Default units: days, hours, minutes, seconds (`kThemedDurationSupported`). +- `visibleValues` restricts which units appear; must be a subset of `[day, hour, minute, second]`. +- Dialog has three actions: **Cancel** (discard), **Reset** (zero all units, stay open), **Save** (commit + close). +- `onChanged` fires only on Save — not on Cancel or intermediate changes. +- Display text is humanized using the active `LayrzAppLocalizations` locale. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Hours and minutes only +ThemedDurationInput( + labelText: context.i18n.t('entity.shiftLength'), + value: shiftLength, + visibleValues: const [ThemedUnits.hour, ThemedUnits.minute], + errors: context.getErrors(key: 'shiftLength'), + onChanged: (value) { + shiftLength = value; + if (context.mounted) onChanged.call(); + }, +) + +// Days only (e.g. retention/expiration windows) +ThemedDurationInput( + labelText: context.i18n.t('entity.retentionPeriod'), + value: retentionPeriod, + visibleValues: const [ThemedUnits.day], + errors: context.getErrors(key: 'retentionPeriod'), + onChanged: (value) { + retentionPeriod = value; + if (context.mounted) onChanged.call(); + }, +) + +// Full granularity with prefix icon +ThemedDurationInput( + labelText: context.i18n.t('entity.connectionTimeout'), + value: connectionTimeout, + prefixIcon: LayrzIcons.solarOutlineClockCircle, + errors: context.getErrors(key: 'connectionTimeout'), + onChanged: (value) { + connectionTimeout = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedDurationInput( + labelText: context.i18n.t('entity.timeout'), + value: timeout, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-duration-input/references/api.md b/.claude/skills/themed-duration-input/references/api.md new file mode 100644 index 0000000..1486e67 --- /dev/null +++ b/.claude/skills/themed-duration-input/references/api.md @@ -0,0 +1,118 @@ +# ThemedDurationInput — API Reference + +Source: `lib/src/inputs/src/general/duration_input.dart` + +- `ThemedDurationInput` class +- `kThemedDurationSupported` constant — `[ThemedUnits.day, ThemedUnits.hour, ThemedUnits.minute, ThemedUnits.second]` + +--- + +## Examples + +```dart +// Basic duration (all units) +ThemedDurationInput( + labelText: 'Timeout', + value: timeout, + errors: context.getErrors(key: 'timeout'), + onChanged: (value) => setState(() => timeout = value), +) + +// Hours and minutes only +ThemedDurationInput( + labelText: 'Shift length', + value: shiftLength, + visibleValues: const [ThemedUnits.hour, ThemedUnits.minute], + onChanged: (value) => setState(() => shiftLength = value), +) + +// Days only +ThemedDurationInput( + labelText: 'Retention period', + value: retentionPeriod, + visibleValues: const [ThemedUnits.day], + onChanged: (value) => setState(() => retentionPeriod = value), +) + +// With prefix icon +ThemedDurationInput( + labelText: 'Connection timeout', + value: connectionTimeout, + prefixIcon: LayrzIcons.solarOutlineClockCircle, + onChanged: (value) => setState(() => connectionTimeout = value), +) + +// Disabled +ThemedDurationInput( + labelText: 'Timeout', + value: timeout, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +ThemedDurationInput({ + super.key, + this.value, + this.onChanged, + this.errors = const [], + this.labelText, + this.label, + this.suffixIcon, + this.prefixIcon, + this.padding, + this.disabled = false, + this.visibleValues = kThemedDurationSupported, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)), + assert( + visibleValues.every(kThemedDurationSupported.contains), + 'The visible values provided has an unsupported value', + ); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `Duration?` | `null` | Current duration value | +| `onChanged` | `Function(Duration?)?` | `null` | Fires only on Save tap in dialog | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents opening the dialog | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `padding` | `EdgeInsets?` | `null` | Outer padding | +| `prefixIcon` | `IconData?` | `null` | Icon before the text field | +| `suffixIcon` | `IconData?` | `null` | Icon after the text field | +| `visibleValues` | `List` | `kThemedDurationSupported` | Units shown in dialog; must be subset of `[day, hour, minute, second]` | + +--- + +## kThemedDurationSupported + +```dart +const kThemedDurationSupported = [ + ThemedUnits.day, + ThemedUnits.hour, + ThemedUnits.minute, + ThemedUnits.second, +]; +``` + +Passing any other `ThemedUnits` value (year, month, week, millisecond) throws an assertion. + +--- + +## Dialog behavior + +- Always readonly field — tapping opens the duration dialog. +- Dialog actions: **Cancel** (discard, close), **Reset** (zero all units, stay open), **Save** (commit + close). +- `onChanged` fires only on Save. +- One `ThemedNumberInput` per unit in `visibleValues`. +- Single unit: full width. Two or more units: two-column `ResponsiveRow` layout. +- Display text is humanized using `Duration.humanize(...)` with the active `LayrzAppLocalizations` locale. diff --git a/.claude/skills/themed-dynamic-avatar-input/SKILL.md b/.claude/skills/themed-dynamic-avatar-input/SKILL.md new file mode 100644 index 0000000..f0f7f30 --- /dev/null +++ b/.claude/skills/themed-dynamic-avatar-input/SKILL.md @@ -0,0 +1,101 @@ +--- +name: themed-dynamic-avatar-input +description: Use ThemedDynamicAvatarInput in a layrz Flutter widget. Apply when adding a flexible avatar field that lets the user choose from URL, base64 image upload, icon, or emoji. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Entity needs a visual identity that can be one of multiple types (image URL, file upload, icon, or emoji) +- Value type: `AvatarInput?` + +For icon-only → use `ThemedIconPicker`. For emoji-only → use `ThemedEmojiPicker`. For avatar image upload only → use `ThemedAvatarPicker`. + +--- + +## Minimal usage + +```dart +ThemedDynamicAvatarInput( + labelText: context.i18n.t('entity.avatar'), + value: selectedAvatar, + errors: context.getErrors(key: 'avatar'), + onChanged: (avatar) { + selectedAvatar = avatar; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Opens a tabbed dialog (`ThemedTabView`) with one tab per enabled type plus an always-present `none` tab. +- Default enabled types: `[AvatarType.url, AvatarType.base64, AvatarType.icon, AvatarType.emoji]`. +- `AvatarType.none` is always prepended automatically — no need to add it to `enabledTypes`. +- `onChanged` fires immediately when the user picks a value in any tab — no separate Save/Cancel. +- Initial tab is determined by matching `value.type` against `enabledTypes`. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## AvatarType tabs + +| Type | Content | +|---|---| +| `none` | No avatar; always shown | +| `url` | Text input for an image URL | +| `base64` | File picker → base64 image upload | +| `icon` | Searchable Layrz icon grid | +| `emoji` | Group-filtered emoji grid | + +--- + +## Common patterns + +```dart +// Icon or emoji only +ThemedDynamicAvatarInput( + labelText: context.i18n.t('layer.avatar'), + value: selectedAvatar, + enabledTypes: [AvatarType.icon, AvatarType.emoji], + onChanged: (avatar) { + selectedAvatar = avatar; + if (context.mounted) onChanged.call(); + }, +) + +// Full avatar with all types +ThemedDynamicAvatarInput( + labelText: context.i18n.t('asset.avatar'), + value: selectedAvatar, + errors: context.getErrors(key: 'avatar'), + onChanged: (avatar) { + selectedAvatar = avatar; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedDynamicAvatarInput( + labelText: context.i18n.t('entity.avatar'), + value: selectedAvatar, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. +- Do NOT combine with `ThemedIconPicker` or `ThemedEmojiPicker` on the same form field — the avatar input already has those tabs built in. diff --git a/.claude/skills/themed-dynamic-avatar-input/references/api.md b/.claude/skills/themed-dynamic-avatar-input/references/api.md new file mode 100644 index 0000000..e9c4560 --- /dev/null +++ b/.claude/skills/themed-dynamic-avatar-input/references/api.md @@ -0,0 +1,115 @@ +# ThemedDynamicAvatarInput — API Reference + +Source: `lib/src/inputs/src/general/dynamic_avatar_input.dart` + +- `ThemedDynamicAvatarInput` class + +--- + +## Examples + +```dart +// Full avatar — all types +ThemedDynamicAvatarInput( + labelText: 'Avatar', + value: selectedAvatar, + errors: context.getErrors(key: 'avatar'), + onChanged: (avatar) => setState(() => selectedAvatar = avatar), +) + +// Icon or emoji only +ThemedDynamicAvatarInput( + labelText: 'Avatar', + value: selectedAvatar, + enabledTypes: [AvatarType.icon, AvatarType.emoji], + onChanged: (avatar) => setState(() => selectedAvatar = avatar), +) + +// Disabled +ThemedDynamicAvatarInput( + labelText: 'Avatar', + value: selectedAvatar, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedDynamicAvatarInput({ + super.key, + this.labelText, + this.label, + this.value, + this.onChanged, + this.disabled = false, + this.errors = const [], + this.hideDetails = false, + this.padding, + this.enabledTypes = const [ + AvatarType.url, + AvatarType.base64, + AvatarType.icon, + AvatarType.emoji, + ], + this.heightFactor = 0.7, + this.maxHeight = 350, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `AvatarInput?` | `null` | Current avatar; `null` treated as `AvatarInput()` (type = none) | +| `onChanged` | `void Function(AvatarInput?)?` | `null` | Fires immediately when user picks in any tab | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents opening the dialog | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding | +| `enabledTypes` | `List` | `[url, base64, icon, emoji]` | Tabs shown in dialog; `none` is always prepended automatically | +| `heightFactor` | `double` | `0.7` | Reserved for future use | +| `maxHeight` | `double` | `350` | Reserved for future use | + +--- + +## AvatarInput structure (from `layrz_models`) + +```dart +class AvatarInput { + AvatarType type; // none | url | base64 | icon | emoji + String? url; // for type == url + String? base64; // for type == base64 (data URI or raw base64) + LayrzIcon? icon; // for type == icon + String? emoji; // for type == emoji (single char) +} +``` + +Only one field is non-null at a time — the dialog clears the others on tab switch. + +--- + +## AvatarType values + +| Value | Tab content | i18n key suffix | +|---|---|---| +| `AvatarType.none` | No avatar — always shown, auto-prepended | `helpers.dynamicAvatar.types.none` | +| `AvatarType.url` | Text input for an image URL | `helpers.dynamicAvatar.types.URL` | +| `AvatarType.base64` | `ThemedAvatarPicker` (file → base64) | `helpers.dynamicAvatar.types.BASE64` | +| `AvatarType.icon` | Searchable Layrz icon grid | `helpers.dynamicAvatar.types.icon` | +| `AvatarType.emoji` | Group-filtered emoji grid | `helpers.dynamicAvatar.types.emoji` | + +--- + +## Dialog behavior + +- Uses `ThemedTabView` — one tab per `enabledTypes` entry plus the auto-prepended `none` tab. +- Initial tab determined by matching `value.type` in `enabledTypes`. +- `onChanged` fires inline (immediately on pick) — no Cancel/Save flow at the dialog level. +- The field preview (`ThemedAvatar`) reflects current state in real time. diff --git a/.claude/skills/themed-dynamic-credentials-input/SKILL.md b/.claude/skills/themed-dynamic-credentials-input/SKILL.md new file mode 100644 index 0000000..db550a8 --- /dev/null +++ b/.claude/skills/themed-dynamic-credentials-input/SKILL.md @@ -0,0 +1,90 @@ +--- +name: themed-dynamic-credentials-input +description: Use ThemedDynamicCredentialsInput in a layrz Flutter widget. Apply when rendering a schema-driven credentials form for Layrz API protocol entities (InboundProtocol, OutboundProtocol, etc.). +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Rendering a credentials form driven by `List` from a protocol entity +- Value type: `Map` + +This widget is NOT a single-field picker — it renders an entire `ResponsiveRow` of inputs. It has NO `labelText` / `label` parameter. + +--- + +## Minimal usage + +```dart +ThemedDynamicCredentialsInput( + value: credentials, + fields: protocol.credentialFields, + translatePrefix: 'inboundProtocols.myProtocol', + isEditing: isEditing, + errors: store.errors, + onChanged: (creds) { + credentials = creds; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Renders a `ResponsiveRow` of inputs, one per `CredentialField` in `fields`. +- Each field type maps to a specific input widget (see `references/api.md`). +- `translatePrefix` + field name compose the i18n key: `'$translatePrefix.${field.field}.title'`. +- `errors` is `Map` (not `List`) — mirrors the Layrz API error structure. +- `isEditing: false` disables all inputs (read-only view). +- Fields with `onlyField` + `onlyChoices` are conditionally shown based on current credentials values. +- `nestedField` type renders a recursive `ThemedDynamicCredentialsInput` for sub-schemas. +- No `labelText` or `label` — this widget produces its own field labels from the schema. + +--- + +## Common patterns + +```dart +// Standard credentials form (editing) +ThemedDynamicCredentialsInput( + value: entity.credentials, + fields: selectedProtocol.credentialFields, + translatePrefix: 'inboundProtocols.${selectedProtocol.identifier}', + isEditing: isEditing, + layrzGeneratedToken: entity.layrzToken, + errors: store.errors, + actionCallback: (action) async { + if (action == CredentialFieldAction.wialonOAuth) { + await _launchWialonOAuth(); + } + }, + onChanged: (creds) { + entity.credentials = creds; + if (context.mounted) onChanged.call(); + }, +) + +// Read-only / detail view +ThemedDynamicCredentialsInput( + value: entity.credentials, + fields: protocol.credentialFields, + translatePrefix: 'inboundProtocols.${protocol.identifier}', + isEditing: false, + errors: const {}, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Pass the full API error map to `errors` — `context.getErrors` is called internally per field. +- Error key paths: flat field → `credentials.{field}`, nested → `credentials.{parent}.{field}`. diff --git a/.claude/skills/themed-dynamic-credentials-input/references/api.md b/.claude/skills/themed-dynamic-credentials-input/references/api.md new file mode 100644 index 0000000..c71d814 --- /dev/null +++ b/.claude/skills/themed-dynamic-credentials-input/references/api.md @@ -0,0 +1,129 @@ +# ThemedDynamicCredentialsInput — API Reference + +Source: `lib/src/inputs/src/general/dynamic_credentials_input.dart` + +- `ThemedDynamicCredentialsInput` class + +--- + +## Examples + +```dart +// Editing mode +ThemedDynamicCredentialsInput( + value: entity.credentials, + fields: protocol.credentialFields, + translatePrefix: 'inboundProtocols.myProtocol', + isEditing: true, + errors: store.errors, + onChanged: (creds) => setState(() => entity.credentials = creds), +) + +// Read-only view +ThemedDynamicCredentialsInput( + value: entity.credentials, + fields: protocol.credentialFields, + translatePrefix: 'inboundProtocols.myProtocol', + isEditing: false, + errors: const {}, +) + +// With Wialon OAuth action +ThemedDynamicCredentialsInput( + value: entity.credentials, + fields: protocol.credentialFields, + translatePrefix: 'inboundProtocols.wialon', + isEditing: true, + errors: store.errors, + actionCallback: (action) async { + if (action == CredentialFieldAction.wialonOAuth) { + await _launchWialonOAuth(); + } + }, + onChanged: (creds) => setState(() => entity.credentials = creds), +) +``` + +--- + +## Constructor + +```dart +ThemedDynamicCredentialsInput({ + super.key, + required this.value, + required this.fields, + this.onChanged, + this.errors = const {}, + this.translatePrefix = '', + this.isEditing = true, + this.layrzGeneratedToken, + this.nested, + this.actionCallback, + this.isLoading = false, +}); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `Map` | required | Current credentials map; keys are `CredentialField.field` values | +| `fields` | `List` | required | Schema that drives which inputs are rendered | +| `onChanged` | `void Function(Map)?` | `null` | Fires with full updated credentials map on any field change | +| `errors` | `Map` | `{}` | Error map (NOT `List`) — mirrors Layrz API error structure | +| `translatePrefix` | `String` | `''` | i18n prefix; field labels resolved as `'$translatePrefix.${field.field}.title'` | +| `isEditing` | `bool` | `true` | `false` = all inputs disabled (read-only) | +| `layrzGeneratedToken` | `String?` | `null` | Shown in read-only field for `layrzApiToken` type fields; has copy button when set | +| `nested` | `String?` | `null` | Parent field name when rendered recursively for `nestedField` type | +| `actionCallback` | `void Function(CredentialFieldAction)?` | `null` | Called for special-action fields (e.g. Wialon OAuth) | +| `isLoading` | `bool` | `false` | Shows lock icon on action fields instead of refresh icon | + +--- + +## CredentialField structure (from `layrz_models`) + +```dart +class CredentialField { + final String field; // map key in credentials + final CredentialFieldType type; // drives which input is rendered + final List? choices; // required when type == choices + final List? requiredFields; // required when type == nestedField + final String? onlyField; // conditional: show when credentials[onlyField] in onlyChoices + final List? onlyChoices; // values that make this field visible +} +``` + +--- + +## CredentialFieldType — input mapping + +| Value | Rendered as | Notes | +|---|---|---| +| `string` | `ThemedTextInput` | Plain text | +| `soapUrl` | `ThemedTextInput` | URL for SOAP endpoints | +| `restUrl` | `ThemedTextInput` | URL for REST endpoints | +| `ftp` | `ThemedTextInput` | FTP address | +| `dir` | `ThemedTextInput` | Directory path | +| `integer` | `ThemedNumberInput` | Stored as `int` | +| `float` | `ThemedNumberInput` | Stored as `double` | +| `choices` | `ThemedSelectInput` | Requires `field.choices`; labels from `'$translatePrefix.${field.field}.$choice'` | +| `layrzApiToken` | Read-only `ThemedTextInput` | Shows `layrzGeneratedToken`; copy-to-clipboard suffix | +| `nestedField` | Recursive `ThemedDynamicCredentialsInput` | Requires `field.requiredFields`; passes `nested: field.field` | +| `wialonToken` | Read-only `ThemedTextInput` with action suffix | Calls `actionCallback(CredentialFieldAction.wialonOAuth)` on tap | + +--- + +## Error map wiring + +`errors` is `Map` — pass the full API error map. `context.getErrors` is called internally per field with: +- Flat field: key = `'credentials.${field.field}'` +- Nested field: key = `'credentials.${widget.nested}.${field.field}'` + +--- + +## Conditional field visibility + +Fields with `onlyField` + `onlyChoices` are shown only when `credentials[field.onlyField]` is in `field.onlyChoices`. Evaluated on every build. diff --git a/.claude/skills/themed-emoji-picker/SKILL.md b/.claude/skills/themed-emoji-picker/SKILL.md new file mode 100644 index 0000000..5c8e569 --- /dev/null +++ b/.claude/skills/themed-emoji-picker/SKILL.md @@ -0,0 +1,80 @@ +--- +name: themed-emoji-picker +description: Use ThemedEmojiPicker in a layrz Flutter widget. Apply when adding a field that lets the user pick a single Unicode emoji from all groups or a filtered subset. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Single emoji selection; value stored as a raw emoji character string (e.g. `"😀"`) +- Value type: `String?` + +For icon selection → use `ThemedIconPicker`. For a flexible avatar (icon/emoji/url/base64) → use `ThemedDynamicAvatarInput`. + +--- + +## Minimal usage + +```dart +ThemedEmojiPicker( + labelText: context.i18n.t('entity.emoji'), + value: selectedEmoji, + errors: context.getErrors(key: 'emoji'), + onChanged: (emoji) { + selectedEmoji = emoji; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Opens a 500×700 dialog with a group filter row and a scrollable emoji grid. +- Groups appear as a horizontal scrollable chip row; tapping a group filters the grid. +- Search filters by `emoji.shortName.contains(query)` — case-sensitive. +- Tapping an emoji closes the dialog immediately — no explicit Save step. +- `enabledGroups` restricts which groups are shown; empty = all groups. +- `value` is a raw emoji character, not a code point or short name. +- Use `Emoji.byChar(value)` from the `emojis` package if you need metadata. +- `customChild` wraps any widget in an `InkWell` that opens the picker. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Restrict to specific groups +ThemedEmojiPicker( + labelText: context.i18n.t('entity.emoji'), + value: selectedEmoji, + enabledGroups: [EmojiGroup.animalsAndNature, EmojiGroup.foodAndDrink], + onChanged: (emoji) { + selectedEmoji = emoji; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedEmojiPicker( + labelText: context.i18n.t('entity.emoji'), + value: selectedEmoji, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-emoji-picker/references/api.md b/.claude/skills/themed-emoji-picker/references/api.md new file mode 100644 index 0000000..67020b4 --- /dev/null +++ b/.claude/skills/themed-emoji-picker/references/api.md @@ -0,0 +1,123 @@ +# ThemedEmojiPicker — API Reference + +Source: `lib/src/inputs/src/pickers/general/emoji.dart` + +- `ThemedEmojiPicker` class — line 3 + +--- + +## Examples + +```dart +// Basic emoji picker +ThemedEmojiPicker( + labelText: 'Emoji', + value: selectedEmoji, + errors: context.getErrors(key: 'emoji'), + onChanged: (emoji) => setState(() => selectedEmoji = emoji), +) + +// Restricted groups +ThemedEmojiPicker( + labelText: 'Emoji', + value: selectedEmoji, + enabledGroups: [EmojiGroup.animalsAndNature, EmojiGroup.foodAndDrink], + onChanged: (emoji) => setState(() => selectedEmoji = emoji), +) + +// Disabled +ThemedEmojiPicker( + labelText: 'Emoji', + value: selectedEmoji, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedEmojiPicker({ + super.key, + this.labelText, + this.label, + this.value, + this.onChanged, + this.disabled = false, + this.errors = const [], + this.hideDetails = false, + this.padding, + this.dense = false, + this.isRequired = false, + this.focusNode, + this.onSubmitted, + this.readonly = false, + this.maxLines = 1, + this.buttomSize, + this.enabledGroups = const [], + this.translations = const { + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'helpers.search': 'Search an emoji or group', + }, + this.overridesLayrzTranslations = false, + this.customChild, + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `String?` | `null` | Raw emoji character (e.g. `"😀"`) | +| `onChanged` | `void Function(String)?` | `null` | Fires with emoji char when user taps one | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | +| `dense` | `bool` | `false` | Reduces vertical padding | +| `isRequired` | `bool` | `false` | Shows required indicator | +| `focusNode` | `FocusNode?` | `null` | External focus node | +| `onSubmitted` | `VoidCallback?` | `null` | Called on keyboard submit action | +| `readonly` | `bool` | `false` | Prevents opening the picker dialog | +| `maxLines` | `int` | `1` | Underlying text field line count | +| `buttomSize` | `double?` | `null` | Grid button size (width & height); `null` = auto. Note: this is the actual API parameter name — not a typo. | +| `enabledGroups` | `List` | `[]` | Group filter; empty = all groups shown | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | + +--- + +## Translations map keys + +| Key | Default | +|---|---| +| `actions.cancel` | `'Cancel'` | +| `actions.save` | `'Save'` | +| `helpers.search` | `'Search an emoji or group'` | + +--- + +## Dialog behavior + +- Max size: 500×700 logical pixels. +- Group filter row: horizontal scrollable chips, one per `EmojiGroup` (or `enabledGroups` subset). +- Search: `emoji.shortName.contains(query)` — case-sensitive. +- Tapping an emoji fires `onChanged` and closes immediately — no explicit Save. +- Use `Emoji.byChar(value)` from the `emojis` package to get emoji metadata from the stored char. diff --git a/.claude/skills/themed-file-picker/SKILL.md b/.claude/skills/themed-file-picker/SKILL.md new file mode 100644 index 0000000..8a0fa4e --- /dev/null +++ b/.claude/skills/themed-file-picker/SKILL.md @@ -0,0 +1,105 @@ +--- +name: themed-file-picker +description: Use ThemedFilePicker in a layrz Flutter widget. Apply when adding a file upload field that opens a system file picker and returns base64 + raw bytes. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- File attachment fields (PDFs, documents, spreadsheets, any file type) +- When you need both base64 string and raw byte array from the selected file + +For image-only avatar/photo upload → use `ThemedAvatarPicker`. + +--- + +## Minimal usage + +```dart +ThemedFilePicker( + labelText: context.i18n.t('entity.file'), + value: fileName, + errors: context.getErrors(key: 'file'), + onChanged: (base64, bytes) { + fileBase64 = base64; + fileBytes = bytes; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Renders as a `ThemedTextInput` showing the selected filename (read-only). +- Tapping the field opens the system file picker. +- `onChanged` fires with `(String base64, List bytes)` — base64 is a `"data:;base64,"` string. +- If a file is already selected, tapping clears it instead of opening the picker — fires `onChanged("", [])`. +- `acceptedTypes` controls which file types the picker shows (default: `FileType.any`). +- `allowedExtensions` works only when `acceptedTypes == FileType.custom`. +- `customChild` wraps any widget in an `InkWell` that opens the picker. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Images only +ThemedFilePicker( + labelText: context.i18n.t('entity.image'), + value: fileName, + acceptedTypes: FileType.image, + onChanged: (base64, bytes) { + fileBase64 = base64; + if (context.mounted) onChanged.call(); + }, +) + +// Custom extensions (e.g. CSV only) +ThemedFilePicker( + labelText: context.i18n.t('entity.csvFile'), + value: fileName, + acceptedTypes: FileType.custom, + allowedExtensions: ['csv'], + onChanged: (base64, bytes) { + csvBase64 = base64; + csvBytes = bytes; + if (context.mounted) onChanged.call(); + }, +) + +// Required field +ThemedFilePicker( + labelText: context.i18n.t('entity.document'), + value: fileName, + isRequired: true, + errors: context.getErrors(key: 'document'), + onChanged: (base64, bytes) { + documentBase64 = base64; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedFilePicker( + labelText: context.i18n.t('entity.file'), + value: fileName, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-file-picker/references/api.md b/.claude/skills/themed-file-picker/references/api.md new file mode 100644 index 0000000..f1b22c5 --- /dev/null +++ b/.claude/skills/themed-file-picker/references/api.md @@ -0,0 +1,123 @@ +# ThemedFilePicker — API Reference + +Source: `lib/src/inputs/src/pickers/general/file.dart` + +- `ThemedFilePicker` class — line 3 + +--- + +## Examples + +```dart +// Basic file picker (any type) +ThemedFilePicker( + labelText: 'Attachment', + value: fileName, + errors: context.getErrors(key: 'file'), + onChanged: (base64, bytes) => setState(() { + fileBase64 = base64; + fileBytes = bytes; + }), +) + +// Images only +ThemedFilePicker( + labelText: 'Image', + value: fileName, + acceptedTypes: FileType.image, + onChanged: (base64, bytes) => setState(() => fileBase64 = base64), +) + +// Custom extensions +ThemedFilePicker( + labelText: 'CSV file', + value: fileName, + acceptedTypes: FileType.custom, + allowedExtensions: ['csv', 'tsv'], + onChanged: (base64, bytes) => setState(() { + csvBase64 = base64; + csvBytes = bytes; + }), +) + +// Disabled +ThemedFilePicker( + labelText: 'Document', + value: fileName, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedFilePicker({ + super.key, + this.labelText, + this.label, + this.value, + this.onChanged, + this.disabled = false, + this.errors = const [], + this.hideDetails = false, + this.isRequired = false, + this.acceptedTypes = FileType.any, + this.customChild, + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.allowedExtensions, + this.padding, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `String?` | `null` | Displayed filename in the text field | +| `onChanged` | `void Function(String, List)?` | `null` | Fires with `(base64DataUri, rawBytes)`; fires `("", [])` on clear | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `isRequired` | `bool` | `false` | Marks the field as required | +| `acceptedTypes` | `FileType` | `FileType.any` | File type filter for the system picker | +| `allowedExtensions` | `List?` | `null` | Extension whitelist; only used when `acceptedTypes == FileType.custom` | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | + +--- + +## FileType values (from `file_picker` package) + +| Value | Description | +|---|---| +| `FileType.any` | All files | +| `FileType.image` | Image files | +| `FileType.video` | Video files | +| `FileType.audio` | Audio files | +| `FileType.media` | Images and videos | +| `FileType.custom` | Only extensions listed in `allowedExtensions` | + +--- + +## Behavior notes + +- Renders as a `ThemedTextInput` (read-only) showing the filename. +- Tapping when empty → opens system file picker. +- Tapping when a file is selected → clears the field, fires `onChanged("", [])`. +- Result base64 is always `"data:;base64,"`. +- Raw bytes are available as `List` in the second `onChanged` argument. diff --git a/.claude/skills/themed-icon-picker/SKILL.md b/.claude/skills/themed-icon-picker/SKILL.md new file mode 100644 index 0000000..d04fd4d --- /dev/null +++ b/.claude/skills/themed-icon-picker/SKILL.md @@ -0,0 +1,94 @@ +--- +name: themed-icon-picker +description: Use ThemedIconPicker in a layrz Flutter widget. Apply when adding a field that lets the user pick a Layrz icon from the full Solar icon set or a filtered subset. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Single icon selection from the Layrz icon set (Solar icons) +- Value type: `LayrzIcon?` + +For emoji selection → use `ThemedEmojiPicker`. For a flexible avatar (icon/emoji/url/base64) → use `ThemedDynamicAvatarInput`. + +--- + +## Minimal usage + +```dart +ThemedIconPicker( + labelText: context.i18n.t('entity.icon'), + value: selectedIcon, + errors: context.getErrors(key: 'icon'), + onChanged: (icon) { + selectedIcon = icon; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Opens a 500×700 dialog with a searchable scrolling list of icons. +- Search filters by `LayrzIcon.name` (case-insensitive). +- Auto-scrolls to the currently selected icon on open. +- Tapping an icon closes the dialog immediately — no explicit Save needed. +- `allowedIcons` restricts the picker to a specific subset when non-empty. +- `customChild` wraps any widget in an `InkWell` that opens the picker. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Restrict to a project-defined subset +ThemedIconPicker( + labelText: context.i18n.t('asset.markerIcon'), + value: selectedIcon, + allowedIcons: kAllowedMarkerIcons, + errors: context.getErrors(key: 'markerIcon'), + onChanged: (icon) { + selectedIcon = icon; + if (context.mounted) onChanged.call(); + }, +) + +// Custom trigger +ThemedIconPicker( + labelText: context.i18n.t('entity.icon'), + value: selectedIcon, + customChild: ThemedAvatar(icon: selectedIcon?.iconData, size: 48), + onChanged: (icon) { + selectedIcon = icon; + if (context.mounted) onChanged.call(); + }, +) + +// Dense / compact +ThemedIconPicker( + labelText: context.i18n.t('entity.icon'), + value: selectedIcon, + dense: true, + onChanged: (icon) { + selectedIcon = icon; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-icon-picker/references/api.md b/.claude/skills/themed-icon-picker/references/api.md new file mode 100644 index 0000000..668be72 --- /dev/null +++ b/.claude/skills/themed-icon-picker/references/api.md @@ -0,0 +1,124 @@ +# ThemedIconPicker — API Reference + +Source: `lib/src/inputs/src/pickers/general/icon.dart` + +- `ThemedIconPicker` class — line 3 + +--- + +## Examples + +```dart +// Basic icon picker +ThemedIconPicker( + labelText: 'Icon', + value: selectedIcon, + errors: context.getErrors(key: 'icon'), + onChanged: (icon) => setState(() => selectedIcon = icon), +) + +// Filtered subset +ThemedIconPicker( + labelText: 'Marker icon', + value: selectedIcon, + allowedIcons: kAllowedMarkerIcons, + onChanged: (icon) => setState(() => selectedIcon = icon), +) + +// Custom trigger +ThemedIconPicker( + labelText: 'Icon', + value: selectedIcon, + customChild: ThemedAvatar(icon: selectedIcon?.iconData, size: 48), + onChanged: (icon) => setState(() => selectedIcon = icon), +) + +// Dense +ThemedIconPicker( + labelText: 'Icon', + value: selectedIcon, + dense: true, + onChanged: (icon) => setState(() => selectedIcon = icon), +) +``` + +--- + +## Constructor + +```dart +const ThemedIconPicker({ + super.key, + this.labelText, + this.label, + this.disabled = false, + this.onChanged, + this.value, + this.errors = const [], + this.hideDetails = false, + this.padding, + this.dense = false, + this.isRequired = false, + this.focusNode, + this.translations = const { + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'helpers.search': 'Search an emoji or group', + }, + this.overridesLayrzTranslations = false, + this.allowedIcons = const [], + this.customChild, + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `LayrzIcon?` | `null` | Currently selected icon | +| `onChanged` | `void Function(LayrzIcon)?` | `null` | Called when user taps an icon (immediate close) | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | +| `dense` | `bool` | `false` | Reduces vertical padding | +| `isRequired` | `bool` | `false` | Shows required indicator | +| `focusNode` | `FocusNode?` | `null` | External focus node | +| `allowedIcons` | `List` | `[]` | Whitelist filter; empty = all icons from `iconMapping` | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | + +--- + +## Translations map keys + +| Key | Default | +|---|---| +| `actions.cancel` | `'Cancel'` | +| `actions.save` | `'Save'` | +| `helpers.search` | `'Search an emoji or group'` | + +--- + +## Dialog behavior + +- Max size: 500×700 logical pixels. +- Icon list sourced from `iconMapping` (full Layrz/Solar icon set), sorted alphabetically by `LayrzIcon.name`. +- Search: case-insensitive `name.contains(query)`. +- Auto-scrolls to selected icon on open (item height = 50 px). +- Tapping an icon fires `onChanged` and closes — no explicit Save. diff --git a/.claude/skills/themed-month-picker/SKILL.md b/.claude/skills/themed-month-picker/SKILL.md new file mode 100644 index 0000000..1119233 --- /dev/null +++ b/.claude/skills/themed-month-picker/SKILL.md @@ -0,0 +1,103 @@ +--- +name: themed-month-picker +description: Use ThemedMonthPicker in a layrz Flutter widget. Apply when adding a month + year selection field — opens a grid dialog with month buttons and year navigation. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.january`, `.february`) — never write the fully-qualified form (`Month.january`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Month + year selection (no specific day) +- Billing periods, reporting months, subscription start + +For a specific day → use `ThemedDatePicker`. For month ranges → use `ThemedMonthRangePicker`. + +--- + +## Minimal usage + +```dart +ThemedMonthPicker( + labelText: context.i18n.t('entity.month'), + value: selectedMonth, + errors: context.getErrors(key: 'month'), + onChanged: (value) { + selectedMonth = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- `value` is `ThemedMonth?` — a `{month: Month, year: int}` object. +- Displays the selected month name + year (e.g. `"April 2025"`), localized via i18n. +- Dialog shows a 12-cell grid; year navigation via arrow buttons. +- `minimum` / `maximum` bound the selectable range by `ThemedMonth`. +- `disabledMonths` blocks specific months. +- `customChild` wraps any widget in an `InkWell` that opens the picker. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// With min/max bounds +ThemedMonthPicker( + labelText: context.i18n.t('entity.billingMonth'), + value: billingMonth, + minimum: ThemedMonth(year: 2023, month: .january), + maximum: ThemedMonth(year: DateTime.now().year, month: .values[DateTime.now().month - 1]), + errors: context.getErrors(key: 'billingMonth'), + onChanged: (value) { + billingMonth = value; + if (context.mounted) onChanged.call(); + }, +) + +// Specific disabled months +ThemedMonthPicker( + labelText: context.i18n.t('entity.month'), + value: selectedMonth, + disabledMonths: [ + ThemedMonth(year: 2024, month: .february), + ThemedMonth(year: 2024, month: .march), + ], + onChanged: (value) { + selectedMonth = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedMonthPicker( + labelText: context.i18n.t('entity.month'), + value: selectedMonth, + disabled: true, +) +``` + +--- + +## ThemedMonth construction + +```dart +ThemedMonth(year: 2025, month: .april) +ThemedMonth(year: DateTime.now().year, month: Month.values[DateTime.now().month - 1]) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-month-picker/references/api.md b/.claude/skills/themed-month-picker/references/api.md new file mode 100644 index 0000000..4dcfffc --- /dev/null +++ b/.claude/skills/themed-month-picker/references/api.md @@ -0,0 +1,157 @@ +# ThemedMonthPicker — API Reference + +Source: `lib/src/inputs/src/pickers/month/single.dart` + +- `ThemedMonthPicker` class — line 3 +- `ThemedMonth` class — line ~220 +- `Month` enum — line ~200 + +--- + +## Examples + +```dart +// Basic month picker +ThemedMonthPicker( + labelText: 'Month', + value: selectedMonth, + errors: context.getErrors(key: 'month'), + onChanged: (value) => setState(() => selectedMonth = value), +) + +// With min/max bounds +ThemedMonthPicker( + labelText: 'Billing month', + value: billingMonth, + minimum: ThemedMonth(year: 2023, month: .january), + maximum: ThemedMonth(year: DateTime.now().year, month: Month.values[DateTime.now().month - 1]), + onChanged: (value) => setState(() => billingMonth = value), +) + +// With specific disabled months +ThemedMonthPicker( + labelText: 'Month', + value: selectedMonth, + disabledMonths: [ + ThemedMonth(year: 2024, month: .february), + ThemedMonth(year: 2024, month: .march), + ], + onChanged: (value) => setState(() => selectedMonth = value), +) + +// Custom child trigger +ThemedMonthPicker( + labelText: 'Month', + value: selectedMonth, + customChild: Text(selectedMonth?.toString() ?? 'Select month'), + onChanged: (value) => setState(() => selectedMonth = value), +) + +// Disabled +ThemedMonthPicker( + labelText: 'Month', + value: selectedMonth, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedMonthPicker({ + super.key, + this.value, + this.onChanged, + this.labelText, + this.label, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.customChild, + this.disabled = false, + this.translations = const { + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'layrz.monthPicker.year': 'Year {year}', + 'layrz.monthPicker.back': 'Previous year', + 'layrz.monthPicker.next': 'Next year', + }, + this.overridesLayrzTranslations = false, + this.minimum, + this.maximum, + this.disabledMonths = const [], + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.errors = const [], + this.hideDetails = false, + this.padding, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `ThemedMonth?` | `null` | Selected month+year | +| `onChanged` | `void Function(ThemedMonth)?` | `null` | Fires with the selected `ThemedMonth` | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text in the text field | +| `prefixText` | `String?` | `null` | Inline text prefix | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the prefix | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `translations` | `Map` | (see above) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map even when i18n is present | +| `minimum` | `ThemedMonth?` | `null` | All months before this are disabled | +| `maximum` | `ThemedMonth?` | `null` | All months after this are disabled | +| `disabledMonths` | `List` | `[]` | Specific months to block | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | + +--- + +## ThemedMonth class + +```dart +const ThemedMonth({ + required Month month, + required int year, // assert: year >= 0 +}) +``` + +| Member | Description | +|---|---| +| `month` | `Month` enum value | +| `year` | Integer year | +| `toString()` | Returns `'year-month'` | +| `==`, `<`, `>`, `<=`, `>=` | Comparison operators | +| `compareTo(ThemedMonth)` | For sorting | + +--- + +## Month enum + +``` +.january, .february, .march, .april, .may, .june, +.july, .august, .september, .october, .november, .december +``` + +Access current month: `Month.values[DateTime.now().month - 1]` diff --git a/.claude/skills/themed-month-range-picker/SKILL.md b/.claude/skills/themed-month-range-picker/SKILL.md new file mode 100644 index 0000000..d945eaf --- /dev/null +++ b/.claude/skills/themed-month-range-picker/SKILL.md @@ -0,0 +1,106 @@ +--- +name: themed-month-range-picker +description: Use ThemedMonthRangePicker in a layrz Flutter widget. Apply when adding a multi-month selection field — supports both arbitrary selection and consecutive range mode. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.january`) — never write the fully-qualified form (`Month.january`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Selection of multiple months (e.g. a reporting window or subscription period) +- `consecutive: false` (default) — pick any combination of months freely +- `consecutive: true` — two-tap mode: pick start month then end month; all months between are auto-filled + +For a single month → use `ThemedMonthPicker`. For day-level ranges → use `ThemedDateRangePicker`. + +--- + +## Minimal usage + +```dart +ThemedMonthRangePicker( + labelText: context.i18n.t('entity.months'), + value: selectedMonths, + errors: context.getErrors(key: 'months'), + onChanged: (value) { + selectedMonths = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- `value` is `List` — defaults to `const []`. +- Displays the selected months as a comma-separated list (sorted, de-duplicated). +- Dialog has year navigation; each of the 12 month cells toggles selection. +- `consecutive: true` → first tap sets anchor, second tap completes range and auto-fills intermediate months. +- `consecutive: true` → only first and last selected months can be deselected (middle months are locked). +- A **Reset** button in the dialog clears all selections. +- `minimum` / `maximum` bound the selectable range. +- `disabledMonths` is ignored when `consecutive: true`. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Consecutive range mode (two-tap: start → end) +ThemedMonthRangePicker( + labelText: context.i18n.t('entity.period'), + value: selectedMonths, + consecutive: true, + minimum: ThemedMonth(year: 2023, month: .january), + maximum: ThemedMonth(year: DateTime.now().year, month: Month.values[DateTime.now().month - 1]), + errors: context.getErrors(key: 'period'), + onChanged: (value) { + selectedMonths = value; + if (context.mounted) onChanged.call(); + }, +) + +// Free multi-select (any months, any years) +ThemedMonthRangePicker( + labelText: context.i18n.t('entity.activeMonths'), + value: activeMonths, + disabledMonths: [ + ThemedMonth(year: 2024, month: .august), + ], + onChanged: (value) { + activeMonths = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedMonthRangePicker( + labelText: context.i18n.t('entity.months'), + value: selectedMonths, + disabled: true, +) +``` + +--- + +## ThemedMonth construction + +```dart +ThemedMonth(year: 2025, month: .april) +ThemedMonth(year: DateTime.now().year, month: Month.values[DateTime.now().month - 1]) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-month-range-picker/references/api.md b/.claude/skills/themed-month-range-picker/references/api.md new file mode 100644 index 0000000..51fbbe0 --- /dev/null +++ b/.claude/skills/themed-month-range-picker/references/api.md @@ -0,0 +1,161 @@ +# ThemedMonthRangePicker — API Reference + +Source: `lib/src/inputs/src/pickers/month/range.dart` + +- `ThemedMonthRangePicker` class — line 3 + +--- + +## Examples + +```dart +// Basic multi-select (any months) +ThemedMonthRangePicker( + labelText: 'Active months', + value: activeMonths, + errors: context.getErrors(key: 'months'), + onChanged: (value) => setState(() => activeMonths = value), +) + +// Consecutive range (two-tap: start → end, intermediate auto-filled) +ThemedMonthRangePicker( + labelText: 'Period', + value: selectedMonths, + consecutive: true, + minimum: ThemedMonth(year: 2023, month: .january), + maximum: ThemedMonth(year: DateTime.now().year, month: Month.values[DateTime.now().month - 1]), + onChanged: (value) => setState(() => selectedMonths = value), +) + +// Free multi-select with disabled months +ThemedMonthRangePicker( + labelText: 'Months', + value: selectedMonths, + disabledMonths: [ + ThemedMonth(year: 2024, month: .august), + ThemedMonth(year: 2024, month: .september), + ], + onChanged: (value) => setState(() => selectedMonths = value), +) + +// Pre-filled range +ThemedMonthRangePicker( + labelText: 'Q1 2025', + value: [ + ThemedMonth(year: 2025, month: .january), + ThemedMonth(year: 2025, month: .february), + ThemedMonth(year: 2025, month: .march), + ], + onChanged: (value) => setState(() => selectedMonths = value), +) + +// Custom child trigger +ThemedMonthRangePicker( + labelText: 'Months', + value: selectedMonths, + customChild: Text('${selectedMonths.length} months selected'), + onChanged: (value) => setState(() => selectedMonths = value), +) + +// Disabled +ThemedMonthRangePicker( + labelText: 'Months', + value: selectedMonths, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedMonthRangePicker({ + super.key, + this.value = const [], + this.consecutive = false, + this.onChanged, + this.labelText, + this.label, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.customChild, + this.disabled = false, + this.translations = const { + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'actions.reset': 'Reset', + 'layrz.monthPicker.year': 'Year {year}', + 'layrz.monthPicker.back': 'Previous year', + 'layrz.monthPicker.next': 'Next year', + }, + this.overridesLayrzTranslations = false, + this.minimum, + this.maximum, + this.disabledMonths = const [], + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.errors = const [], + this.hideDetails = false, + this.padding, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `List` | `[]` | Selected months; returned sorted | +| `consecutive` | `bool` | `false` | Two-tap range mode — auto-fills all months between taps | +| `onChanged` | `void Function(List)?` | `null` | Fires with the updated sorted list | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text in the text field | +| `prefixText` | `String?` | `null` | Inline text prefix | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the prefix | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `translations` | `Map` | (see above) | Fallback strings when i18n unavailable; includes `actions.reset` | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map even when i18n is present | +| `minimum` | `ThemedMonth?` | `null` | All months before this are disabled | +| `maximum` | `ThemedMonth?` | `null` | All months after this are disabled | +| `disabledMonths` | `List` | `[]` | Ignored when `consecutive: true` | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | + +--- + +## Consecutive mode behavior + +- First tap → sets the anchor month. +- Second tap → completes the range; all months between anchor and second tap are auto-filled. +- Only the first and last months in the selection can be deselected (middle ones are locked). +- Reset button in the dialog clears all selections. +- `disabledMonths` has no effect in consecutive mode. + +--- + +## ThemedMonth & Month + +See `themed-month-picker/references/api.md` for full `ThemedMonth` class and `Month` enum reference. + +```dart +ThemedMonth(year: 2025, month: .april) +Month.values[DateTime.now().month - 1] // current month +``` diff --git a/.claude/skills/themed-multi-select-input/SKILL.md b/.claude/skills/themed-multi-select-input/SKILL.md new file mode 100644 index 0000000..129e33e --- /dev/null +++ b/.claude/skills/themed-multi-select-input/SKILL.md @@ -0,0 +1,86 @@ +--- +name: themed-multi-select-input +description: Use ThemedMultiSelectInput in a layrz Flutter widget. Apply when adding a multiple-value picker field that opens a searchable dialog with checkboxes. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Multiple-value selection from a list; value type: `List` +- Never use Flutter's `CheckboxListTile` or `DropdownButton` — always use this widget. + +For single-value selection → use `ThemedSelectInput`. For two-panel Available/Selected layout → use `ThemedDualListInput`. + +--- + +## Minimal usage + +```dart +ThemedMultiSelectInput( + labelText: context.i18n.t('entity.tags'), + items: tags.map((t) => ThemedSelectItem(value: t.id, label: t.name)).toList(), + value: selectedTagIds, + errors: context.getErrors(key: 'tags'), + onChanged: (items) { + selectedTagIds = items.map((e) => e.value).nonNulls.toList(); + if (context.mounted) onChanged.call(); + }, +) +``` + +`onChanged` receives `List>`. Extract values with `.map((e) => e.value).nonNulls.toList()`. + +--- + +## Key behaviors + +- Opens a searchable dialog (500×500 max) with checkboxes on each item. +- Dialog always shows: Cancel, Select All / Unselect All toggle, Save. +- Default: `onChanged` fires on every tap (real-time updates while dialog is open). +- `waitUntilClosedToSubmit: true` delays `onChanged` until Save is tapped — use when real-time updates are expensive. +- Cancel discards changes (calls `onChanged` with the previous selection). +- `autoclose: false` (default) — user must tap Save to close. +- `autoselectFirst: true` auto-selects `items[0]` on init when `value` is empty. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Batch submit (only fire onChanged on Save) +ThemedMultiSelectInput( + labelText: context.i18n.t('entity.permissions'), + items: permissions.map((p) => ThemedSelectItem(value: p.id, label: p.name)).toList(), + value: selectedPermissionIds, + waitUntilClosedToSubmit: true, + errors: context.getErrors(key: 'permissions'), + onChanged: (items) { + selectedPermissionIds = items.map((e) => e.value).nonNulls.toList(); + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedMultiSelectInput( + labelText: context.i18n.t('entity.tags'), + items: tags.map((t) => ThemedSelectItem(value: t.id, label: t.name)).toList(), + value: selectedTagIds, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Use `.nonNulls` to filter nulls from the mapped values. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-multi-select-input/references/api.md b/.claude/skills/themed-multi-select-input/references/api.md new file mode 100644 index 0000000..71b750c --- /dev/null +++ b/.claude/skills/themed-multi-select-input/references/api.md @@ -0,0 +1,159 @@ +# ThemedMultiSelectInput — API Reference + +Source: `lib/src/inputs/src/general/multiselect_input.dart` + +- `ThemedMultiSelectInput` class — line 3 + +--- + +## Examples + +```dart +// Basic multi-select +ThemedMultiSelectInput( + labelText: 'Tags', + items: tags.map((t) => ThemedSelectItem(value: t.id, label: t.name)).toList(), + value: selectedTagIds, + errors: context.getErrors(key: 'tags'), + onChanged: (items) => setState(() { + selectedTagIds = items.map((e) => e.value).nonNulls.toList(); + }), +) + +// Batch submit (fire onChanged only on Save) +ThemedMultiSelectInput( + labelText: 'Permissions', + items: permissions, + value: selectedPermissionIds, + waitUntilClosedToSubmit: true, + onChanged: (items) => setState(() { + selectedPermissionIds = items.map((e) => e.value).nonNulls.toList(); + }), +) + +// Disabled +ThemedMultiSelectInput( + labelText: 'Tags', + items: tags, + value: selectedTagIds, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedMultiSelectInput({ + super.key, + this.labelText, + this.label, + required this.items, + this.onChanged, + this.value, + this.disabled = false, + this.errors = const [], + this.isRequired = false, + this.dense = false, + this.hideDetails = false, + this.autoclose = false, + this.autoselectFirst = false, + this.enableSearch = true, + this.hideTitle = false, + this.waitUntilClosedToSubmit = false, + this.dialogConstraints = const BoxConstraints(maxWidth: 500, maxHeight: 500), + this.itemExtent = 50, + this.prefixIcon, + this.customChild, + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.translations = const { ... }, + this.overridesLayrzTranslations = false, + this.padding, + this.filter, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `items` | `List>` | required | All selectable options | +| `value` | `List?` | `null` | Currently selected values | +| `onChanged` | `void Function(List>)?` | `null` | Fires with full selected item list | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents opening the dialog | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `isRequired` | `bool` | `false` | Shows required indicator | +| `dense` | `bool` | `false` | Reduces vertical padding | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `autoclose` | `bool` | `false` | Closes dialog immediately on each item tap (not recommended) | +| `autoselectFirst` | `bool` | `false` | Auto-selects `items[0]` on init when `value` is empty | +| `enableSearch` | `bool` | `true` | Shows search field in dialog | +| `hideTitle` | `bool` | `false` | Hides dialog title (also disables search) | +| `waitUntilClosedToSubmit` | `bool` | `false` | Delays `onChanged` until Save is tapped | +| `dialogConstraints` | `BoxConstraints` | `BoxConstraints(maxWidth: 500, maxHeight: 500)` | Dialog size constraints | +| `itemExtent` | `double` | `50` | Fixed row height in the list | +| `prefixIcon` | `IconData?` | `null` | Icon before the text field | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `padding` | `EdgeInsets?` | `null` | Outer padding | +| `filter` | `bool Function(ThemedSelectItem, String)?` | `null` | Custom search filter function | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | + +--- + +## Translations map keys + +| Key | Default | +|---|---| +| `actions.cancel` | `'Cancel'` | +| `actions.save` | `'Save'` | +| `helpers.search` | `'Search'` | +| `helpers.selectAll` | `'Select all'` | +| `helpers.unselectAll` | `'Unselect all'` | + +--- + +## ThemedSelectItem + +```dart +ThemedSelectItem( + value: T, // the stored value + label: String, // displayed text + // onTap: VoidCallback? // optional tap handler inside dialog +) +``` + +--- + +## Dialog behavior + +- Always shows: Cancel, Select All / Unselect All toggle, Save. +- `waitUntilClosedToSubmit: false` (default): `onChanged` fires on every item tap. +- `waitUntilClosedToSubmit: true`: `onChanged` fires only when Save is tapped. +- Cancel calls `onChanged` with the previous (unchanged) selection. +- Select All / Unselect All does NOT call `onChanged` — only Save does. +- `autoselectFirst` fires only during `initState`. + +## Comparison with ThemedSelectInput + +| Aspect | `ThemedSelectInput` | `ThemedMultiSelectInput` | +|---|---|---| +| Selection | Single (`T?`) | Multiple (`List`) | +| Default `autoclose` | `true` | `false` | +| Deselect support | `canUnselect` | Always (uncheck any item) | +| Dialog buttons | Cancel, Save (configurable) | Cancel, Select All/Unselect All, Save (always) | +| `dialogContraints` param | `dialogContraints` (typo — no trailing 's') | `dialogConstraints` (correct spelling) | diff --git a/.claude/skills/themed-number-input/SKILL.md b/.claude/skills/themed-number-input/SKILL.md new file mode 100644 index 0000000..53d8e81 --- /dev/null +++ b/.claude/skills/themed-number-input/SKILL.md @@ -0,0 +1,130 @@ +--- +name: themed-number-input +description: Use ThemedNumberInput in a layrz Flutter widget. Apply when adding a numeric field with optional min/max bounds, step buttons, and decimal formatting. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Numeric fields: integers, decimals, quantities, prices, coordinates, percentages +- Value type: `num?` +- Never use raw `TextField` or `TextFormField` for numeric input — always use this widget. + +For time span fields → use `ThemedDurationInput`. + +--- + +## Minimal usage + +```dart +ThemedNumberInput( + labelText: context.i18n.t('entity.speed'), + value: speed, + errors: context.getErrors(key: 'speed'), + onChanged: (value) { + speed = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Renders a text field with − (prefix) and + (suffix) icon buttons for decrement/increment. +- `minimum` / `maximum` disable the ± buttons visually at the boundary — they do NOT block keyboard input. Add validation for hard limits. +- `step` controls the ± button increment amount (default: `1`). +- `onChanged` fires with `null` when field is cleared; not called for unparseable intermediate input. +- `format` requires `inputRegExp` — both must be set together (assert enforced at construction). +- `hidePrefixSuffixActions: true` hides ± buttons without disabling the field. +- `focusNode` lifecycle is the caller's responsibility — dispose it yourself. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Integer with bounds +ThemedNumberInput( + labelText: context.i18n.t('entity.quantity'), + value: quantity, + minimum: 0, + maximum: 999, + step: 1, + maximumDecimalDigits: 0, + errors: context.getErrors(key: 'quantity'), + onChanged: (value) { + quantity = value?.toInt(); + if (context.mounted) onChanged.call(); + }, +) + +// Decimal with comma separator and unit suffix +ThemedNumberInput( + labelText: context.i18n.t('entity.temperature'), + value: temperature, + decimalSeparator: .comma, + maximumDecimalDigits: 2, + suffixText: '°C', + errors: context.getErrors(key: 'temperature'), + onChanged: (value) { + temperature = value; + if (context.mounted) onChanged.call(); + }, +) + +// Price with currency prefix +ThemedNumberInput( + labelText: context.i18n.t('entity.price'), + value: price, + prefixText: '\$', + maximumDecimalDigits: 2, + minimum: 0, + errors: context.getErrors(key: 'price'), + onChanged: (value) { + price = value; + if (context.mounted) onChanged.call(); + }, +) + +// Free-form entry only (no ± buttons) +ThemedNumberInput( + labelText: context.i18n.t('entity.offset'), + value: offset, + hidePrefixSuffixActions: true, + onChanged: (value) { + offset = value; + if (context.mounted) onChanged.call(); + }, +) + +// Fine decimal steps (0.1 increments) +ThemedNumberInput( + labelText: context.i18n.t('entity.opacity'), + value: opacity, + minimum: 0, + maximum: 1, + step: 0.1, + maximumDecimalDigits: 1, + onChanged: (value) { + opacity = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-number-input/references/api.md b/.claude/skills/themed-number-input/references/api.md new file mode 100644 index 0000000..046c342 --- /dev/null +++ b/.claude/skills/themed-number-input/references/api.md @@ -0,0 +1,159 @@ +# ThemedNumberInput — API Reference + +Source: `lib/src/inputs/src/general/number_input.dart` + +- `ThemedNumberInput` class — line 3 +- `ThemedDecimalSeparator` enum — line (end of file) + +--- + +## Examples + +```dart +// Basic number field +ThemedNumberInput( + labelText: 'Speed', + value: speed, + onChanged: (value) => setState(() => speed = value), +) + +// Integer with bounds +ThemedNumberInput( + labelText: 'Quantity', + value: quantity, + minimum: 0, + maximum: 999, + step: 1, + maximumDecimalDigits: 0, + errors: context.getErrors(key: 'quantity'), + onChanged: (value) => setState(() => quantity = value?.toInt()), +) + +// Decimal with suffix +ThemedNumberInput( + labelText: 'Temperature', + value: temperature, + decimalSeparator: .comma, + maximumDecimalDigits: 2, + suffixText: '°C', + onChanged: (value) => setState(() => temperature = value), +) + +// Custom NumberFormat (inputRegExp required) +ThemedNumberInput( + labelText: 'Price', + value: price, + format: NumberFormat.currency(symbol: '\$'), + inputRegExp: RegExp(r'[\d.]'), + onChanged: (value) => setState(() => price = value), +) + +// No step buttons +ThemedNumberInput( + labelText: 'Offset', + value: offset, + hidePrefixSuffixActions: true, + onChanged: (value) => setState(() => offset = value), +) + +// Disabled +ThemedNumberInput( + labelText: 'Speed', + value: speed, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedNumberInput({ + super.key, + this.labelText, + this.label, + this.disabled = false, + this.placeholder, + this.onChanged, + this.value, + this.errors = const [], + this.hideDetails = false, + this.padding, + this.dense = false, + this.isRequired = false, + this.onSubmitted, + this.inputFormatters = const [], + this.borderRadius, + this.minimum, + this.maximum, + this.step, + this.keyboardType = TextInputType.number, + this.format, + this.decimalSeparator = ThemedDecimalSeparator.dot, + this.inputRegExp, + this.maximumDecimalDigits = 4, + this.suffixText, + this.prefixText, + this.hidePrefixSuffixActions = false, + this.focusNode, +}) : assert( + (label == null && labelText != null) || (label != null && labelText == null), + ), + assert( + (format != null && inputRegExp != null) || (format == null), + 'When format is set, inputRegExp is required.', + ); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `num?` | `null` | Current numeric value | +| `onChanged` | `void Function(num?)?` | `null` | Called with `null` on clear; not called for unparseable input | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text when field is empty | +| `disabled` | `bool` | `false` | Makes field read-only; hides ± buttons | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `dense` | `bool` | `false` | Reduces vertical padding | +| `isRequired` | `bool` | `false` | Shows required indicator | +| `padding` | `EdgeInsets?` | `null` | Outer padding | +| `minimum` | `num?` | `null` | Disables − button at boundary; does not block keyboard input | +| `maximum` | `num?` | `null` | Disables + button at boundary; does not block keyboard input | +| `step` | `num?` | `null` | Button increment/decrement amount (effective default: `1`) | +| `maximumDecimalDigits` | `int` | `4` | Max fraction digits shown; capped at 15 internally | +| `decimalSeparator` | `ThemedDecimalSeparator` | `.dot` | `.dot` → en locale; `.comma` → pt locale | +| `format` | `NumberFormat?` | `null` | Custom `intl` format; **requires** `inputRegExp` when set | +| `inputRegExp` | `RegExp?` | `null` | Input character filter; **required** when `format` is set | +| `suffixText` | `String?` | `null` | Static text after the value (e.g. `'kg'`) | +| `prefixText` | `String?` | `null` | Static text before the value (e.g. `'$'`) | +| `hidePrefixSuffixActions` | `bool` | `false` | Hides ± buttons without disabling the field | +| `keyboardType` | `TextInputType` | `TextInputType.number` | Mobile keyboard hint | +| `focusNode` | `FocusNode?` | `null` | External focus node; **caller must dispose** | +| `onSubmitted` | `VoidCallback?` | `null` | Called on keyboard submit action | +| `inputFormatters` | `List` | `[]` | Extra formatters appended after built-in regex filter | +| `borderRadius` | `double?` | `null` | Corner radius override | + +--- + +## ThemedDecimalSeparator enum + +| Value | Locale | Thousands | Decimal | Example | +|---|---|---|---|---| +| `.dot` (default) | en | `,` | `.` | `1,234.56` | +| `.comma` | pt | `.` | `,` | `1.234,56` | + +--- + +## Key gotchas + +- `minimum` / `maximum` only gate the ± buttons — keyboard input is unrestricted. Add form-layer validation for hard limits. +- `format` + `inputRegExp` must always be paired — the assert throws in debug mode if `format` is set without `inputRegExp`. +- After a ± button tap, cursor jumps to end of the formatted number (intentional). +- `onChanged` is not called for intermediate states (lone `-`, incomplete decimals, unparseable input). +- `focusNode` passed externally is NOT disposed by this widget — the caller owns its lifecycle. diff --git a/.claude/skills/themed-password-input/SKILL.md b/.claude/skills/themed-password-input/SKILL.md new file mode 100644 index 0000000..df2d3ca --- /dev/null +++ b/.claude/skills/themed-password-input/SKILL.md @@ -0,0 +1,84 @@ +--- +name: themed-password-input +description: Use ThemedPasswordInput in a layrz Flutter widget. Apply when adding a password creation or login field — includes strength indicator and show/hide toggle. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.col6`, `.start`, `.left`) — never write the fully-qualified form (`Sizes.col6`, `WrapAlignment.start`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Password creation field (with strength indicator, default behavior) +- Login password field (`showLevels: false`) +- Password confirm field (`showLevels: false`, `autofillHints: const []`) + +Never use `ThemedTextInput` with `obscureText: true` — always use this widget for passwords. + +--- + +## Minimal usage + +```dart +ThemedPasswordInput( + labelText: context.i18n.t('entity.password'), + value: password, + errors: context.getErrors(key: 'password'), + onChanged: (value) { + password = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- `showLevels: true` (default) → strength icon + tooltip checklist (lowercase, uppercase, digit, special char, length). Set `false` to hide indicator while keeping the show/hide toggle. +- `autofillHints` defaults to both `newPassword` and `password`. For login: `[AutofillHints.password]`. For confirm fields: `const []`. +- Suffix area is reserved — no `prefixIcon`, `prefixWidget`, `suffixIcon`, or `suffixWidget` exposed. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// Login — no strength indicator +ThemedPasswordInput( + labelText: context.i18n.t('auth.password'), + value: password, + showLevels: false, + autofillHints: const [AutofillHints.password], + errors: context.getErrors(key: 'password'), + onChanged: (value) { + password = value; + if (context.mounted) onChanged.call(); + }, +) + +// Confirm field — no strength, no autofill +ThemedPasswordInput( + labelText: context.i18n.t('auth.confirmPassword'), + value: passwordConfirm, + showLevels: false, + autofillHints: const [], + errors: context.getErrors(key: 'passwordConfirm'), + onChanged: (value) { + passwordConfirm = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-password-input/references/api.md b/.claude/skills/themed-password-input/references/api.md new file mode 100644 index 0000000..0d08776 --- /dev/null +++ b/.claude/skills/themed-password-input/references/api.md @@ -0,0 +1,132 @@ +# ThemedPasswordInput — API Reference + +Source: `lib/src/inputs/src/general/password_input.dart` + +- `ThemedPasswordInput` class — line 3 + +Thin wrapper over `ThemedTextInput`. Does not expose prefix/suffix — that area is reserved for the strength indicator and show/hide toggle. + +--- + +## Examples + +```dart +// Password creation (default — strength indicator shown) +String _password = 'Abc123!@#'; + +ThemedPasswordInput( + labelText: 'Password field', + value: _password, + onChanged: (value) => setState(() => _password = value), +) + +// Login — no strength indicator, correct autofill hint +ThemedPasswordInput( + labelText: context.i18n.t('auth.password'), + value: password, + showLevels: false, + autofillHints: const [AutofillHints.password], + errors: context.getErrors(key: 'password'), + onChanged: (value) { + password = value; + if (context.mounted) onChanged.call(); + }, +) + +// Confirm field — no strength, no autofill +ThemedPasswordInput( + labelText: context.i18n.t('auth.confirmPassword'), + value: passwordConfirm, + showLevels: false, + autofillHints: const [], + errors: context.getErrors(key: 'passwordConfirm'), + onChanged: (value) { + passwordConfirm = value; + if (context.mounted) onChanged.call(); + }, +) + +// Required password field in a form +ThemedPasswordInput( + labelText: context.i18n.t('entity.password'), + value: object.password, + isRequired: true, + errors: context.getErrors(key: 'password'), + onChanged: (value) { + object.password = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Constructor + +```dart +const ThemedPasswordInput({ + super.key, + this.labelText, + this.label, + this.placeholder, + this.disabled = false, + this.onChanged, + this.value, + this.errors = const [], + this.hideDetails = false, + this.padding, + this.isRequired = false, + this.onSubmitted, + this.borderRadius, + this.focusNode, + this.controller, + this.showLevels = true, + this.autofillHints = const [AutofillHints.newPassword, AutofillHints.password], +}) +``` + +Assert: `(label == null && labelText != null) || (label != null && labelText == null)` +Message: `'You must provide either a labelText or a label, but not both.'` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text | +| `disabled` | `bool` | `false` | | +| `onChanged` | `ValueChanged?` | `null` | `void Function(String)?` — fires on every keystroke | +| `value` | `String?` | `null` | Controlled value | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides errors/hints row | +| `padding` | `EdgeInsets?` | `null` | Falls through to `ThemedTextInput.outerPadding` (`EdgeInsets.all(10)`) | +| `isRequired` | `bool` | `false` | Prepends `*` to the label | +| `onSubmitted` | `VoidCallback?` | `null` | Called on keyboard submit | +| `borderRadius` | `double?` | `null` | Passed through to `ThemedTextInput` | +| `focusNode` | `FocusNode?` | `null` | NOT disposed by widget | +| `controller` | `TextEditingController?` | `null` | NOT disposed by widget | +| `showLevels` | `bool` | `true` | Shows strength icon + tooltip checklist next to toggle | +| `autofillHints` | `List` | `[AutofillHints.newPassword, AutofillHints.password]` | Adjust per context | + +--- + +## Strength calculation + +Computed from `value`. Requirements: lowercase, uppercase, digit, special character. +All 4 requirements met + valid charset → `_isValid = true`. + +Levels (only when `_isValid`): + +| Length | Level | Color | +|---|---|---| +| `< 8` | 0 | red | +| `< 12` | 1 | orange | +| `< 16` | 2 | orange | +| `< 20` | 3 | green | +| `≥ 20` | 4 | green | + +Allowed charset: `A-Za-z0-9` and `!@#$%^&*()_-+=[]{};\:'",.<>/?` `` ` `` `~|\\`. +Characters outside this set invalidate the password regardless of length. diff --git a/.claude/skills/themed-radio-input/SKILL.md b/.claude/skills/themed-radio-input/SKILL.md new file mode 100644 index 0000000..4e84466 --- /dev/null +++ b/.claude/skills/themed-radio-input/SKILL.md @@ -0,0 +1,104 @@ +--- +name: themed-radio-input +description: Use ThemedRadioInput in a layrz Flutter widget. Apply when adding a radio button group — renders options in a responsive grid with per-breakpoint column sizing. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.col6`, `.col4`) — never write the fully-qualified form (`Sizes.col6`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Single selection from a small, visible list of options (radio buttons) +- When all options should be visible at once (vs. a dropdown `ThemedSelectInput`) + +For boolean yes/no → use `ThemedCheckboxInput`. For single-select with many options → use `ThemedSelectInput`. + +--- + +## Minimal usage + +```dart +ThemedRadioInput( + labelText: context.i18n.t('entity.role'), + value: selectedRole, + items: [ + ThemedSelectItem(value: 'admin', label: 'Admin'), + ThemedSelectItem(value: 'user', label: 'User'), + ThemedSelectItem(value: 'viewer', label: 'Viewer'), + ], + errors: context.getErrors(key: 'role'), + onChanged: (value) { + selectedRole = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- `items` is required — a `List>`. +- Options are laid out via `ResponsiveRow.builder` + `ResponsiveCol` — responsive per breakpoint. +- Default column sizes: xs=`.col12`, sm=`.col6`, md=`.col4`, lg=`.col3`, xl=`.col2`. +- `value` is the currently selected `T?`. Pass `null` for no selection. +- Tapping the label text also selects the option. +- `disabled: true` → `onChanged` is silently ignored (no visual style change on individual options). +- Exactly one of `label` / `labelText` must be set — assert enforced (both null is allowed). + +--- + +## Common patterns + +```dart +// Two columns on all screens +ThemedRadioInput( + labelText: context.i18n.t('entity.status'), + value: status, + xsSize: .col6, + smSize: .col6, + mdSize: .col6, + lgSize: .col6, + xlSize: .col6, + items: [ + ThemedSelectItem(value: 'active', label: context.i18n.t('status.active')), + ThemedSelectItem(value: 'inactive', label: context.i18n.t('status.inactive')), + ], + onChanged: (value) { + status = value; + if (context.mounted) onChanged.call(); + }, +) + +// Full width (one per row) +ThemedRadioInput( + labelText: context.i18n.t('entity.priority'), + value: priority, + xsSize: .col12, + smSize: .col12, + mdSize: .col12, + lgSize: .col12, + xlSize: .col12, + items: [ + ThemedSelectItem(value: 1, label: 'Low'), + ThemedSelectItem(value: 2, label: 'Medium'), + ThemedSelectItem(value: 3, label: 'High'), + ], + onChanged: (value) { + priority = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-radio-input/references/api.md b/.claude/skills/themed-radio-input/references/api.md new file mode 100644 index 0000000..0b01af7 --- /dev/null +++ b/.claude/skills/themed-radio-input/references/api.md @@ -0,0 +1,164 @@ +# ThemedRadioInput — API Reference + +Source: `lib/src/inputs/src/general/radio_input.dart` + +- `ThemedRadioInput` class — line 3 + +--- + +## Examples + +```dart +// Basic string options +ThemedRadioInput( + labelText: 'Role', + value: selectedRole, + items: [ + ThemedSelectItem(value: 'admin', label: 'Admin'), + ThemedSelectItem(value: 'user', label: 'User'), + ThemedSelectItem(value: 'viewer', label: 'Viewer'), + ], + onChanged: (value) => setState(() => selectedRole = value), +) + +// Integer values, two columns on all screens +ThemedRadioInput( + labelText: 'Priority', + value: priority, + xsSize: .col6, + smSize: .col6, + mdSize: .col6, + lgSize: .col6, + xlSize: .col6, + items: [ + ThemedSelectItem(value: 1, label: 'Low'), + ThemedSelectItem(value: 2, label: 'Medium'), + ThemedSelectItem(value: 3, label: 'High'), + ], + onChanged: (value) => setState(() => priority = value), +) + +// Full width (one per row) +ThemedRadioInput( + labelText: 'Status', + value: status, + xsSize: .col12, + smSize: .col12, + mdSize: .col12, + lgSize: .col12, + xlSize: .col12, + items: [ + ThemedSelectItem(value: 'active', label: 'Active'), + ThemedSelectItem(value: 'inactive', label: 'Inactive'), + ], + onChanged: (value) => setState(() => status = value), +) + +// Disabled +ThemedRadioInput( + labelText: 'Type', + value: type, + disabled: true, + items: [ + ThemedSelectItem(value: 'a', label: 'Type A'), + ThemedSelectItem(value: 'b', label: 'Type B'), + ], + onChanged: (value) => setState(() => type = value), +) + +// No selection initially +ThemedRadioInput( + labelText: 'Color', + value: null, + items: [ + ThemedSelectItem(value: 'red', label: 'Red'), + ThemedSelectItem(value: 'blue', label: 'Blue'), + ThemedSelectItem(value: 'green', label: 'Green'), + ], + onChanged: (value) => setState(() => color = value), +) + +// With errors +ThemedRadioInput( + labelText: 'Plan', + value: selectedPlan, + errors: context.getErrors(key: 'plan'), + items: [ + ThemedSelectItem(value: 'basic', label: 'Basic'), + ThemedSelectItem(value: 'pro', label: 'Pro'), + ], + onChanged: (value) => setState(() => selectedPlan = value), +) +``` + +--- + +## Constructor + +```dart +const ThemedRadioInput({ + super.key, + this.labelText, + this.label, + this.onChanged, + this.disabled = false, + this.value, + required this.items, + this.errors = const [], + this.hideDetails = false, + this.padding = const EdgeInsets.all(10), + this.xsSize = .col12, + this.smSize = .col6, + this.mdSize = .col4, + this.lgSize = .col3, + this.xlSize = .col2, +}) : assert(label == null || labelText == null); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `onChanged` | `void Function(T?)?` | `null` | Fires with selected value | +| `disabled` | `bool` | `false` | Silently ignores `onChanged`; no visual change on options | +| `value` | `T?` | `null` | Currently selected value; `null` = no selection | +| `items` | `List>` | required | Options to render as radio buttons | +| `errors` | `List` | `[]` | Validation messages shown below the group | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets` | `EdgeInsets.all(10)` | Outer padding around the widget | +| `xsSize` | `Sizes` | `.col12` | Column width on extra-small screens (< 600px) | +| `smSize` | `Sizes?` | `.col6` | Column width on small screens (600–959px) | +| `mdSize` | `Sizes?` | `.col4` | Column width on medium screens (960–1263px) | +| `lgSize` | `Sizes?` | `.col3` | Column width on large screens (1264–1903px) | +| `xlSize` | `Sizes?` | `.col2` | Column width on extra-large screens (≥ 1904px) | + +--- + +## ThemedSelectItem + +Used in `items`. Defined in the select inputs module. + +```dart +ThemedSelectItem({ + required T value, + required String label, +}) +``` + +--- + +## Breakpoint constants + +| Constant | Value | Applies when width is | +|---|---|---| +| `kExtraSmallGrid` | 600 | < 600 → xs | +| `kSmallGrid` | 960 | 600–959 → sm | +| `kMediumGrid` | 1264 | 960–1263 → md | +| `kLargeGrid` | 1904 | 1264–1903 → lg | +| — | — | ≥ 1904 → xl | + +Fallback chain: `xl ?? lg ?? md ?? sm ?? xs` diff --git a/.claude/skills/themed-search-input/SKILL.md b/.claude/skills/themed-search-input/SKILL.md new file mode 100644 index 0000000..dd35323 --- /dev/null +++ b/.claude/skills/themed-search-input/SKILL.md @@ -0,0 +1,108 @@ +--- +name: themed-search-input +description: Use ThemedSearchInput in a layrz Flutter widget. Apply when adding a search bar — either a compact icon button that expands into an overlay, or a full-width inline field. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.col6`, `.start`, `.left`) — never write the fully-qualified form (`Sizes.col6`, `WrapAlignment.start`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Global or list search with minimal toolbar footprint → button mode (default) +- Prominent always-visible search bar → `asField: true` + +Not a form widget — no `errors`, `isRequired`, or validation. Do not pass `context.getErrors()` to it. + +--- + +## Minimal usage — button mode + +```dart +ThemedSearchInput( + value: searchQuery, + labelText: context.i18n.t('general.search'), + onSearch: (value) { + searchQuery = value; + if (context.mounted) setState(() {}); + }, +) +``` + +## Minimal usage — field mode + +```dart +ThemedSearchInput( + value: searchQuery, + labelText: context.i18n.t('general.search'), + asField: true, + maxWidth: 300, + onSearch: (value) { + searchQuery = value; + if (context.mounted) setState(() {}); + }, +) +``` + +--- + +## Key behaviors + +- Button mode: 40×40 rounded icon button. Tap opens `OverlayEntry` with scale animation. Escape or tap-outside closes it. +- Field mode: always-visible `SizedBox(height: 40, width: maxWidth)` — no overlay. +- `position: .left` → overlay expands leftward (button on the right edge of toolbar). `.right` → expands rightward. +- `debounce` fires `onSearch` after delay. Enter fires immediately and closes the overlay. +- `value` is non-nullable `String` — always init to `''`. + +--- + +## Common patterns + +```dart +// Toolbar search on right edge — expands leftward +Row( + children: [ + const Spacer(), + ThemedSearchInput( + value: query, + labelText: context.i18n.t('general.search'), + position: .left, + onSearch: (value) { + query = value; + if (context.mounted) setState(() {}); + }, + ), + ], +) + +// Synchronous search (no debounce) +ThemedSearchInput( + value: query, + labelText: context.i18n.t('general.search'), + asField: true, + maxWidth: 400, + debounce: null, + onSearch: (value) { + query = value; + if (context.mounted) setState(() {}); + }, +) + +// Custom trigger widget +ThemedSearchInput( + value: query, + labelText: context.i18n.t('general.search'), + customChild: ThemedButton( + label: context.i18n.t('general.search'), + icon: LayrzIcons.solarOutlineMagnifier, + style: ThemedButtonStyle.outlined, + onTap: () {}, + ), + onSearch: (value) { + query = value; + if (context.mounted) setState(() {}); + }, +) +``` diff --git a/.claude/skills/themed-search-input/references/api.md b/.claude/skills/themed-search-input/references/api.md new file mode 100644 index 0000000..a8eaa3f --- /dev/null +++ b/.claude/skills/themed-search-input/references/api.md @@ -0,0 +1,123 @@ +# ThemedSearchInput — API Reference + +Source: `lib/src/inputs/src/general/search_input.dart` + +- `ThemedSearchInput` class — line 5 +- `ThemedSearchPosition` enum — line 318 + +--- + +## Examples + +```dart +// Button mode — toolbar right edge, expands leftward +String _query = ''; + +Row( + children: [ + const Spacer(), + ThemedSearchInput( + value: _query, + labelText: 'Search', + position: .left, + onSearch: (value) => setState(() => _query = value), + ), + ], +) + +// Field mode — always visible inline search bar +ThemedSearchInput( + value: _query, + labelText: 'Search', + asField: true, + maxWidth: 300, + onSearch: (value) => setState(() => _query = value), +) + +// Wider field mode, no debounce (synchronous) +ThemedSearchInput( + value: _query, + labelText: 'Search', + asField: true, + maxWidth: 400, + debounce: null, + onSearch: (value) => setState(() => _query = value), +) + +// Button mode expanding rightward (button on the left edge) +ThemedSearchInput( + value: _query, + labelText: 'Search', + position: .right, + onSearch: (value) => setState(() => _query = value), +) + +// Custom trigger widget replacing the default icon button +ThemedSearchInput( + value: _query, + labelText: 'Search', + customChild: ThemedButton( + label: 'Search', + icon: LayrzIcons.solarOutlineMagnifier, + style: ThemedButtonStyle.outlined, + onTap: () {}, + ), + onSearch: (value) => setState(() => _query = value), +) + +// Disabled search button +ThemedSearchInput( + value: _query, + labelText: 'Search', + disabled: true, + onSearch: (value) => setState(() => _query = value), +) +``` + +--- + +## Constructor + +```dart +const ThemedSearchInput({ + super.key, + required this.value, + required this.onSearch, + this.maxWidth = 300, + this.labelText = 'Search', + this.customChild, + this.disabled = false, + this.position = .left, + this.asField = false, + this.inputPadding = EdgeInsets.zero, + this.debounce = const Duration(milliseconds: 300), +}) +``` + +Not a form widget — no `label`, `errors`, `hideDetails`, or `isRequired`. + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `String` | required | Non-nullable — init state to `''`, never `null` | +| `onSearch` | `OnSearch` (`void Function(String)`) | required | Fires after debounce or immediately on Enter | +| `maxWidth` | `double` | `300` | Max width of overlay (button mode) or field (field mode) | +| `labelText` | `String` | `'Search'` | Hint text inside the field — not a form label | +| `customChild` | `Widget?` | `null` | Replaces the default icon button; tap still opens overlay | +| `disabled` | `bool` | `false` | Disables tapping in button mode; no effect in field mode | +| `position` | `ThemedSearchPosition` | `.left` | Overlay expansion direction | +| `asField` | `bool` | `false` | Renders full-width inline field instead of compact button | +| `inputPadding` | `EdgeInsets` | `EdgeInsets.zero` | Inner padding — only applies when `asField: true` | +| `debounce` | `Duration?` | `Duration(milliseconds: 300)` | `null` = fire synchronously on every keystroke | + +--- + +## ThemedSearchPosition enum + +| Value | Description | +|---|---| +| `.left` | Overlay expands leftward (use when button is on the right edge) | +| `.right` | Overlay expands rightward (use when button is on the left edge) | diff --git a/.claude/skills/themed-select-input/SKILL.md b/.claude/skills/themed-select-input/SKILL.md new file mode 100644 index 0000000..c1174af --- /dev/null +++ b/.claude/skills/themed-select-input/SKILL.md @@ -0,0 +1,98 @@ +--- +name: themed-select-input +description: Use ThemedSelectInput in a layrz Flutter widget. Apply when adding a single-value picker field that opens a searchable dialog list. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Single-value selection from a list; value type: `T?` +- Never use Flutter's `DropdownButton` — always use this widget. + +For multiple-value selection → use `ThemedMultiSelectInput`. For two-panel Available/Selected layout → use `ThemedDualListInput`. + +--- + +## Minimal usage + +```dart +ThemedSelectInput( + labelText: context.i18n.t('entity.status'), + items: statuses.map((s) => ThemedSelectItem(value: s.id, label: s.name)).toList(), + value: selectedStatus, + errors: context.getErrors(key: 'status'), + onChanged: (item) { + selectedStatus = item?.value; + if (context.mounted) onChanged.call(); + }, +) +``` + +`onChanged` receives `ThemedSelectItem?`. Always use `.value` to extract the raw `T`. + +--- + +## Key behaviors + +- Opens a searchable dialog (500×500 max) with a scrollable item list. +- Default `autoclose: true` — dialog closes immediately on item tap (no Save button needed). +- `canUnselect: true` lets the user tap the current selection to deselect it; pair with `autoclose: false`. +- `returnNullOnClose: true` calls `onChanged(null)` when dialog is dismissed without picking. +- `autoSelectFirst: true` auto-selects `items[0]` on init when `value` is null. +- `customChild` wraps any widget in an `InkWell` that opens the dialog. +- Exactly one of `label` / `labelText` must be set — assert enforced. +- **API typo note**: the parameter is `dialogContraints` (missing 's') — not `dialogConstraints`. + +--- + +## Common patterns + +```dart +// Allow deselection +ThemedSelectInput( + labelText: context.i18n.t('entity.category'), + items: categories.map((c) => ThemedSelectItem(value: c.id, label: c.name)).toList(), + value: selectedCategory, + canUnselect: true, + autoclose: false, + errors: context.getErrors(key: 'category'), + onChanged: (item) { + selectedCategory = item?.value; + if (context.mounted) onChanged.call(); + }, +) + +// Clear on dialog dismiss +ThemedSelectInput( + labelText: context.i18n.t('entity.filter'), + items: filters, + value: selectedFilter, + returnNullOnClose: true, + onChanged: (item) { + selectedFilter = item?.value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedSelectInput( + labelText: context.i18n.t('entity.status'), + items: statuses, + value: selectedStatus, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-select-input/references/api.md b/.claude/skills/themed-select-input/references/api.md new file mode 100644 index 0000000..a4667ef --- /dev/null +++ b/.claude/skills/themed-select-input/references/api.md @@ -0,0 +1,160 @@ +# ThemedSelectInput — API Reference + +Source: `lib/src/inputs/src/general/select_input.dart` + +- `ThemedSelectInput` class — line 3 +- `DialogSelectInput` class (internal dialog widget) +- `SelectInputResult` class (result wrapper) + +--- + +## Examples + +```dart +// Basic single select +ThemedSelectInput( + labelText: 'Status', + items: statuses.map((s) => ThemedSelectItem(value: s.id, label: s.name)).toList(), + value: selectedStatus, + errors: context.getErrors(key: 'status'), + onChanged: (item) => setState(() => selectedStatus = item?.value), +) + +// Allow deselection +ThemedSelectInput( + labelText: 'Category', + items: categories, + value: selectedCategory, + canUnselect: true, + autoclose: false, + onChanged: (item) => setState(() => selectedCategory = item?.value), +) + +// Clear on dismiss +ThemedSelectInput( + labelText: 'Filter', + items: filters, + value: selectedFilter, + returnNullOnClose: true, + onChanged: (item) => setState(() => selectedFilter = item?.value), +) + +// Disabled +ThemedSelectInput( + labelText: 'Status', + items: statuses, + value: selectedStatus, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedSelectInput({ + super.key, + this.labelText, + this.label, + required this.items, + this.onChanged, + this.value, + this.disabled = false, + this.errors = const [], + this.isRequired = false, + this.dense = false, + this.hideDetails = false, + this.autoclose = true, + this.canUnselect = false, + this.returnNullOnClose = false, + this.autoSelectFirst = false, + this.enableSearch = true, + this.hideTitle = false, + this.hideButtons = false, + this.dialogContraints = const BoxConstraints(maxWidth: 500, maxHeight: 500), + this.overrideHeightDialog, + this.itemExtent = 50, + this.prefixIcon, + this.customChild, + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.translations = const { ... }, + this.overridesLayrzTranslations = false, + this.padding, + this.filter, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `items` | `List>` | required | Selectable options | +| `value` | `T?` | `null` | Currently selected value | +| `onChanged` | `void Function(ThemedSelectItem?)?` | `null` | Fires with item on select, `null` on deselect/dismiss | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Prevents opening the dialog | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `isRequired` | `bool` | `false` | Shows required indicator | +| `dense` | `bool` | `false` | Reduces vertical padding | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `autoclose` | `bool` | `true` | Closes dialog immediately on item tap | +| `canUnselect` | `bool` | `false` | Allows tapping current selection to deselect it | +| `returnNullOnClose` | `bool` | `false` | Calls `onChanged(null)` when dialog is dismissed without picking | +| `autoSelectFirst` | `bool` | `false` | Auto-selects `items[0]` on init when `value` is null | +| `enableSearch` | `bool` | `true` | Shows search field in dialog | +| `hideTitle` | `bool` | `false` | Hides dialog title (also disables search) | +| `hideButtons` | `bool` | `false` | Hides Cancel / Save buttons | +| `dialogContraints` | `BoxConstraints` | `BoxConstraints(maxWidth: 500, maxHeight: 500)` | **Note: parameter is `dialogContraints` (typo in API — missing 's')** | +| `overrideHeightDialog` | `double?` | `null` | Forces dialog height | +| `itemExtent` | `double` | `50` | Fixed row height in the list | +| `prefixIcon` | `IconData?` | `null` | Icon before the text field | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `padding` | `EdgeInsets?` | `null` | Outer padding | +| `filter` | `bool Function(ThemedSelectItem, String)?` | `null` | Custom search filter function | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | + +--- + +## Translations map keys + +| Key | Default | +|---|---| +| `actions.cancel` | `'Cancel'` | +| `actions.save` | `'Save'` | +| `helpers.search` | `'Search'` | + +--- + +## ThemedSelectItem + +```dart +ThemedSelectItem( + value: T, // the stored value + label: String, // displayed text + // onTap: VoidCallback? // optional tap handler inside dialog +) +``` + +--- + +## Dialog behavior + +- Max size: 500×500 (controlled by `dialogContraints`). +- `autoclose: true`: dialog closes immediately on tap — no Save button shown. +- `autoclose: false`: user must tap Save to confirm selection. +- `canUnselect: true` + `autoclose: false`: user can tap the selected item to deselect, then Save to confirm `null`. +- `autoSelectFirst` fires only during `initState` — does not re-fire if `value` later becomes null. diff --git a/.claude/skills/themed-snackbar/SKILL.md b/.claude/skills/themed-snackbar/SKILL.md new file mode 100644 index 0000000..0fe6aa3 --- /dev/null +++ b/.claude/skills/themed-snackbar/SKILL.md @@ -0,0 +1,121 @@ +--- +name: themed-snackbar +description: Use ThemedSnackbar in a layrz Flutter app. Apply when showing transient, dismissible feedback — drop-in replacement for Flutter's ScaffoldMessenger.showSnackBar. Requires a ThemedSnackbarMessenger ancestor. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for enum values where applicable — never write fully-qualified forms. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Any transient feedback after an action: save confirmation, error surface, background-task result, copy-to-clipboard acknowledgement. +- **Always use this instead of Flutter's `ScaffoldMessenger.showSnackBar(...)` or raw `SnackBar` in layrz code.** The custom messenger does not depend on a `Scaffold`. +- Not for persistent status callouts — use `ThemedAlert` (inline widget) for those. +- Not for blocking confirmations — use an `AlertDialog` with a `ThemedAlert` body instead. + +--- + +## Setup + +`ThemedSnackbarMessenger` must wrap the app **once** before any call to `showSnackbar`. The canonical place is the `builder:` of `MaterialApp` or `MaterialApp.router`: + +```dart +MaterialApp.router( + // ... + builder: (context, child) { + return ThemedSnackbarMessenger( + child: child ?? const SizedBox(), + ); + }, +) +``` + +If the ancestor is missing, `ThemedSnackbarMessenger.of(context)` throws an assert at runtime. + +--- + +## Minimal usage + +```dart +ThemedSnackbarMessenger.of(context).showSnackbar( + ThemedSnackbar( + message: context.i18n.t('actions.saved'), + color: Colors.green, + icon: LayrzIcons.solarOutlineCheckSquare, + ), +); +``` + +--- + +## Key behaviors + +- `message` is **required** and must be non-empty; `duration` must be > 0 s. +- Defaults: 5 s duration, `Colors.blue` background, `solarOutlineInfoCircle` icon. Foreground text is auto-contrasted via `validateColor(...)`. +- **Desktop/tablet:** top-right stack, snackbars queue downward. +- **Mobile portrait:** bottom, respects the virtual keyboard via `MediaQuery.viewInsetsOf(context).bottom` (controlled by `useViewInsetsBottom` on the messenger, default `true`). +- Hover pauses the progress timer on desktop. +- The close icon is always rendered — `isDismissible` on the data class does not suppress it in the current implementation. +- Multiple queued snackbars show a red `+N` badge (capped at `+9`); tapping it clears the entire queue. +- `ThemedSnackbarMessenger.of(context)` throws if no ancestor exists — use `.maybeOf(context)` in tests or partial widget trees where the ancestor may be absent. +- **Do not use** the deprecated `width` or `maxLines` parameters (removed in 8.0.0). Configure width on the messenger via `maxWidth:` instead. + +--- + +## Common patterns + +```dart +// 1. Success after a save +await save(); +if (context.mounted) { + ThemedSnackbarMessenger.of(context).showSnackbar( + ThemedSnackbar( + message: context.i18n.t('entity.saved'), + color: Colors.green, + icon: LayrzIcons.solarOutlineCheckSquare, + ), + ); +} + +// 2. Error after a failed request +if (context.mounted) { + ThemedSnackbarMessenger.of(context).showSnackbar( + ThemedSnackbar( + title: context.i18n.t('errors.generic.title'), + message: context.i18n.t('errors.generic.message'), + color: Colors.red, + icon: LayrzIcons.solarOutlineCloseSquare, + ), + ); +} + +// 3. Info with a longer display duration +ThemedSnackbarMessenger.of(context).showSnackbar( + ThemedSnackbar( + message: context.i18n.t('export.queued'), + duration: const Duration(seconds: 10), + ), +); + +// 4. maybeOf guard — safe outside a full widget tree (e.g. tests, storybook) +ThemedSnackbarMessenger.maybeOf(context)?.showSnackbar( + ThemedSnackbar(message: context.i18n.t('actions.copied')), +); +``` + +--- + +## Usage conventions + +- Localize `title` and `message` via `context.i18n.t('...')` — never hardcode strings. +- Always guard async callsites with `if (context.mounted)` before calling `of(context).showSnackbar(...)`. +- Pick colors by severity — no enum is needed, just the `Color` value: + - `Colors.blue` — info / neutral (default) + - `Colors.green` — success + - `Colors.orange` — warning + - `Colors.red` — danger / error +- Keep `message` to one line. Use `title` for a bold lead when the message needs a header; skip `title` for single-line content. +- To widen snackbars on a dashboard layout, set `maxWidth:` on `ThemedSnackbarMessenger` — do **not** pass the deprecated `width:` on `ThemedSnackbar`. diff --git a/.claude/skills/themed-snackbar/references/api.md b/.claude/skills/themed-snackbar/references/api.md new file mode 100644 index 0000000..ca7e514 --- /dev/null +++ b/.claude/skills/themed-snackbar/references/api.md @@ -0,0 +1,170 @@ +# ThemedSnackbar — API Reference + +Sources: + +- `ThemedSnackbar` data class — `lib/src/snackbar/src/snackbar.dart` L3 +- `ThemedSnackbarMessenger` widget — `lib/src/snackbar/src/messenger.dart` L5 +- `ThemedSnackbarMessengerState` — `lib/src/snackbar/src/messenger.dart` L52 +- Constants & helpers — `lib/src/snackbar/snackbar.dart` + +--- + +## Examples + +```dart +// App setup — wrap MaterialApp.router once +MaterialApp.router( + builder: (context, child) { + return ThemedSnackbarMessenger( + child: child ?? const SizedBox(), + ); + }, +) + +// Minimal call — message only +ThemedSnackbarMessenger.of(context).showSnackbar( + ThemedSnackbar(message: 'File saved'), +); + +// Success with icon and color +ThemedSnackbarMessenger.of(context).showSnackbar( + ThemedSnackbar( + message: 'Record created successfully', + color: Colors.green, + icon: LayrzIcons.solarOutlineCheckSquare, + ), +); + +// Error with title + extended duration +ThemedSnackbarMessenger.of(context).showSnackbar( + ThemedSnackbar( + title: 'Upload failed', + message: 'The server returned a 503 error. Please try again.', + color: Colors.red, + icon: LayrzIcons.solarOutlineCloseSquare, + duration: const Duration(seconds: 10), + ), +); + +// Title + message (two-line layout) +ThemedSnackbarMessenger.of(context).showSnackbar( + ThemedSnackbar( + title: 'Export queued', + message: 'Your CSV will be ready in a few seconds.', + icon: LayrzIcons.solarOutlineExport, + ), +); + +// maybeOf — safe when ancestor may be absent +ThemedSnackbarMessenger.maybeOf(context)?.showSnackbar( + ThemedSnackbar(message: 'Copied to clipboard'), +); + +// Custom messenger width (dashboard layouts) +ThemedSnackbarMessenger( + maxWidth: 600, + child: child, +) +``` + +--- + +## `ThemedSnackbar` constructor + +```dart +ThemedSnackbar({ + String? title, + required String message, + IconData? icon, + Color? color, + Duration duration = const Duration(seconds: 5), + @Deprecated('Will be removed in 8.0.0. Use maxWidth on ThemedSnackbarMessenger instead.') + double? width, + @Deprecated('Will be removed in 8.0.0.') + int maxLines = 2, + bool isDismissible = true, +}); +``` + +--- + +## `ThemedSnackbar` properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `title` | `String?` | `null` | Optional bold heading line. Skip for single-line snackbars. | +| `message` | `String` | — | **Required.** Must be non-empty (runtime assert). | +| `icon` | `IconData?` | `null` | Icon displayed on the left. Falls back to `solarOutlineInfoCircle` when null. | +| `color` | `Color?` | `null` | Background color. Falls back to `Colors.blue` when null. Foreground is auto-contrasted. | +| `duration` | `Duration` | `Duration(seconds: 5)` | How long the snackbar stays visible. Must be > 0 s (runtime assert). | +| `width` | `double?` | `null` | **Deprecated — do not use.** Removed in 8.0.0. Set `maxWidth` on `ThemedSnackbarMessenger` instead. | +| `maxLines` | `int` | `2` | **Deprecated — do not use.** Removed in 8.0.0. | +| `isDismissible` | `bool` | `true` | Stored but does not suppress the close icon in the current implementation. | + +--- + +## `ThemedSnackbarMessenger` constructor + +```dart +const ThemedSnackbarMessenger({ + Key? key, + required Widget child, + double maxWidth = 400, + double mobileBreakpoint = kSmallGrid, + bool useViewInsetsBottom = true, +}); +``` + +--- + +## `ThemedSnackbarMessenger` properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `child` | `Widget` | — | **Required.** The subtree that can call `of(context).showSnackbar(...)`. | +| `maxWidth` | `double` | `400` | Maximum snackbar width in logical pixels on desktop/tablet. | +| `mobileBreakpoint` | `double` | `kSmallGrid` (960) | Width threshold below which mobile layout (bottom position) is used. | +| `useViewInsetsBottom` | `bool` | `true` | When true, adds `MediaQuery.viewInsetsOf(context).bottom` to the bottom offset so the snackbar clears the virtual keyboard. | + +--- + +## Static accessors + +| Method | Return type | Notes | +|---|---|---| +| `ThemedSnackbarMessenger.of(context)` | `ThemedSnackbarMessengerState` | Asserts a `ThemedSnackbarMessenger` ancestor exists. Throws in debug mode if absent. | +| `ThemedSnackbarMessenger.maybeOf(context)` | `ThemedSnackbarMessengerState?` | Nullable variant — returns `null` when no ancestor is found. Use in tests or partial widget trees. | + +--- + +## `ThemedSnackbarMessengerState` public members + +| Member | Signature | Notes | +|---|---|---| +| `showSnackbar` | `void showSnackbar(ThemedSnackbar snackbar)` | Enqueues and displays the snackbar. | +| `show` | `void show(ThemedSnackbar snackbar)` | Alias for `showSnackbar`. | +| `snackbars` | `final List snackbars` | Current display/queue list. Read-only in practice. | + +--- + +## Constants & helpers + +| Identifier | Type / Signature | Value / Notes | +|---|---|---| +| `kSnackbarAnimationDuration` | `const Duration` | `Duration(milliseconds: 300)` — shared fade-in/out animation duration. | +| `ThemedSnackbarCallback` | `typedef bool Function()` | Callback type used internally by the messenger state. | +| `debugCheckHasThemedSnackbarMessenger` | `bool Function(BuildContext context)` | Debug assertion helper — throws a descriptive `FlutterError` if no `ThemedSnackbarMessenger` ancestor exists. Called internally by `of(context)`. | + +--- + +## Behavior notes + +- **Positioning:** desktop/tablet → top-right, snackbars stack downward. Mobile portrait (width < `mobileBreakpoint` or Android/iOS portrait) → bottom, offset by `MediaQuery.viewInsetsOf(context).bottom` when `useViewInsetsBottom` is true. +- **Width:** `min(screenWidth × 0.7, maxWidth)` on desktop/tablet; full screen width minus view padding on mobile portrait. +- **Stacking and queue:** snackbars are queued. While one is visible a red badge in its top-right corner shows the remaining count. Badge shows `+9` when the queue exceeds 9. Tapping the badge dismisses the entire queue. +- **Timer pause:** a `MouseRegion` wraps each snackbar. `onEnter` pauses the linear progress indicator; `onExit` resumes it. Only effective on pointer devices (desktop/web). +- **Icon fallback:** `LayrzIcons.solarOutlineInfoCircle` when `icon == null`. +- **Color fallback:** `Colors.blue` when `color == null`. The foreground (text + icon tint) is computed via `validateColor(color: resolvedColor)` for legibility contrast. +- **No `ScaffoldMessenger` dependency:** `ThemedSnackbarMessenger` renders its overlay via a custom `Stack` + `Positioned` at the top of its own subtree. A `Scaffold` is not required. +- **`isDismissible` caveat:** the `isDismissible` field is stored on `ThemedSnackbar` but the widget always renders a close `InkWell` (`solarOutlineCloseCircle`). The field has no observable effect on the close affordance in the current implementation. +- **Animation timing:** 150 ms fade-in, linear progress bar consuming `duration`, 100 ms delay before widget removal, `kSnackbarAnimationDuration` (300 ms) governs the overall animation controller. diff --git a/.claude/skills/themed-tab-view/SKILL.md b/.claude/skills/themed-tab-view/SKILL.md new file mode 100644 index 0000000..e7c1e4d --- /dev/null +++ b/.claude/skills/themed-tab-view/SKILL.md @@ -0,0 +1,110 @@ +--- +name: themed-tab-view +description: Use ThemedTabView and ThemedTab in a layrz Flutter widget. Apply when adding horizontal tab navigation with Material 3 styling. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Horizontal tab navigation: settings panels, data views, dialogs with sections +- All tabs are built upfront (not lazy) — keep count reasonable (< 10) +- For lazy content within a tab, use `FutureBuilder`/`StreamBuilder` inside the tab's `child` + +--- + +## Minimal usage + +```dart +ThemedTabView( + tabs: [ + ThemedTab( + labelText: context.i18n.t('tabs.general'), + child: GeneralPanel(), + ), + ThemedTab( + labelText: context.i18n.t('tabs.advanced'), + child: AdvancedPanel(), + ), + ], +) +``` + +`ThemedTabView` must be in a bounded-height context — wrap in `Expanded` or give it a fixed height. + +--- + +## Key behaviors + +- Tab bar is always scrollable (`TabBar(isScrollable: true)`). +- Two styles: `.filledTonal` (default, filled background on active) and `.underline` (bottom border on active). +- `initialPosition` is clamped to valid range — out-of-range values don't crash. +- `onTabIndex` fires only when the user **changes** the tab, not on initial mount. +- `persistTabPosition` only resets when `tabs.length` changes (not on every rebuild). +- `showArrows` adds left/right `ThemedButton` navigation; combine with `wrapArrowNavigation` for circular nav. +- `additionalWidgets` appear to the right of the tab bar (e.g., filters, search). +- Exactly one of `labelText` / `label` must be set on `ThemedTab` — assert enforced. + +--- + +## Common patterns + +```dart +// With underline style and arrows +ThemedTabView( + style: .underline, + showArrows: true, + tabs: [ + ThemedTab(labelText: 'Tab A', child: ContentA()), + ThemedTab(labelText: 'Tab B', child: ContentB()), + ], +) + +// Circular arrow navigation +ThemedTabView( + showArrows: true, + wrapArrowNavigation: true, + tabs: [ /* ... */ ], +) + +// Tab with leading icon +ThemedTab( + labelText: context.i18n.t('tabs.dashboard'), + leadingIcon: LayrzIcons.solarOutlineDashboard, + child: DashboardView(), +) + +// Tab with custom label (e.g., badge) +ThemedTab( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Alerts'), + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(10)), + child: Text('$count', style: const TextStyle(color: Colors.white, fontSize: 10)), + ), + ], + ), + child: AlertsView(), +) + +// With additionalWidgets and tab change callback +ThemedTabView( + onTabIndex: (index) => setState(() => _activeTab = index), + additionalWidgets: [FilterButton()], + tabs: [ /* ... */ ], +) + +// Dynamic tab list — reset to tab 0 when list changes +ThemedTabView( + persistTabPosition: false, + tabs: categories.map((c) => ThemedTab(labelText: c.name, child: CategoryView(c))).toList(), +) +``` diff --git a/.claude/skills/themed-tab-view/references/api.md b/.claude/skills/themed-tab-view/references/api.md new file mode 100644 index 0000000..6706e3a --- /dev/null +++ b/.claude/skills/themed-tab-view/references/api.md @@ -0,0 +1,171 @@ +# ThemedTabView — API Reference + +Sources: +- `lib/src/tabs/src/view.dart` — `ThemedTabView` +- `lib/src/tabs/src/tab.dart` — `ThemedTab` +- `lib/src/tabs/src/style.dart` — `ThemedTabStyle` + +--- + +## Examples + +```dart +// Minimal +ThemedTabView( + tabs: [ + ThemedTab(labelText: 'Home', child: HomePage()), + ThemedTab(labelText: 'Settings', child: SettingsPage()), + ], +) + +// With underline style and arrows +ThemedTabView( + style: .underline, + showArrows: true, + tabs: [ + ThemedTab(labelText: 'Tab A', child: ContentA()), + ThemedTab(labelText: 'Tab B', child: ContentB()), + ], +) + +// Tab change callback +ThemedTabView( + onTabIndex: (index) => setState(() => _tab = index), + tabs: [ /* ... */ ], +) + +// Tab with leading icon +ThemedTab( + labelText: 'Dashboard', + leadingIcon: LayrzIcons.solarOutlineDashboard, + child: DashboardView(), +) + +// Tab with custom label widget +ThemedTab( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Alerts'), + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(10)), + child: Text('$count', style: const TextStyle(color: Colors.white, fontSize: 10)), + ), + ], + ), + child: AlertsView(), +) +``` + +--- + +## ThemedTabView Constructor + +```dart +const ThemedTabView({ + super.key, + required this.tabs, + this.padding = const EdgeInsets.all(10), + this.crossAxisAlignment = CrossAxisAlignment.start, + this.mainAxisAlignment = MainAxisAlignment.start, + this.animationDuration = const Duration(milliseconds: 250), + this.physics, + this.separatorPadding = const EdgeInsets.only(top: 10), + this.showArrows = false, + this.persistTabPosition = true, + this.initialPosition = 0, + this.onTabIndex, + this.additionalWidgets = const [], + this.style = .filledTonal, + this.wrapArrowNavigation = false, +}) +``` + +--- + +## ThemedTabView Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `tabs` | `List` | required | All tabs built upfront (not lazy) | +| `style` | `ThemedTabStyle` | `.filledTonal` | Visual style for all tabs | +| `initialPosition` | `int` | `0` | Starting tab index; clamped to valid range | +| `onTabIndex` | `Function(int)?` | `null` | Fires on user tab change; NOT on initial mount | +| `persistTabPosition` | `bool` | `true` | Preserves selected tab when `tabs.length` changes | +| `animationDuration` | `Duration` | `250ms` | Tab switch animation duration | +| `showArrows` | `bool` | `false` | Adds left/right navigation buttons | +| `wrapArrowNavigation` | `bool` | `false` | Arrows wrap at boundaries (first↔last). Only applies when `showArrows: true` | +| `additionalWidgets` | `List` | `[]` | Extra widgets placed right of the tab bar | +| `padding` | `EdgeInsetsGeometry` | `all(10)` | Outer padding (bar + content) | +| `separatorPadding` | `EdgeInsetsGeometry` | `only(top: 10)` | Padding between tab bar and content | +| `crossAxisAlignment` | `CrossAxisAlignment` | `.start` | Vertical alignment of content column | +| `mainAxisAlignment` | `MainAxisAlignment` | `.start` | Horizontal alignment of content (rarely used) | +| `physics` | `ScrollPhysics?` | `null` | Scroll physics for `TabBarView` | + +--- + +## ThemedTab Constructor + +```dart +const ThemedTab({ + super.key, + this.labelText, + this.label, + this.iconSize = 30, + this.leading, + this.leadingIcon, + this.trailing, + this.trailingIcon, + this.padding = const EdgeInsets.all(10), + this.color, + this.child = const SizedBox(), + this.style = .filledTonal, +}) : assert(labelText != null || label != null); +``` + +--- + +## ThemedTab Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `labelText` | `String?` | — | Mutually exclusive with `label`; at least one required | +| `label` | `Widget?` | — | Custom label widget; mutually exclusive with `labelText` | +| `child` | `Widget` | `SizedBox()` | Content shown when this tab is active | +| `leadingIcon` | `IconData?` | `null` | Icon before the label | +| `leading` | `Widget?` | `null` | Custom widget before the label; prefer `leadingIcon` | +| `trailingIcon` | `IconData?` | `null` | Icon after the label | +| `trailing` | `Widget?` | `null` | Custom widget after the label; prefer `trailingIcon` | +| `iconSize` | `double` | `30` | Size for leading/trailing icons | +| `padding` | `EdgeInsets` | `all(10)` | Padding around the tab label area | +| `color` | `Color?` | `null` | Override tab color (used for active state detection) | +| `style` | `ThemedTabStyle` | `.filledTonal` | Overridden by parent `ThemedTabView.style` | + +--- + +## ThemedTabStyle enum + +```dart +enum ThemedTabStyle { + filledTonal, // Material 3 filled tonal button (default) + underline, // Text with bottom underline when active +} +``` + +| Style | Active state | Best for | +|---|---|---| +| `.filledTonal` | Filled background (20% opacity) | Dashboard-style multi-tab | +| `.underline` | Bottom border via TabBar indicator | Document-style, minimal design | + +--- + +## Behavior notes + +- Tab bar is always scrollable (`TabBar(isScrollable: true)`). +- `onTabIndex` fires only when the user changes the tab (`indexIsChanging` guard) — not on initial mount. +- `persistTabPosition` only resets to 0 when `tabs.length` changes. Same-length rebuilds always preserve index. +- `initialPosition` out of range is clamped — no exception thrown. +- Tab labels are rendered as `RichText` — widget tests must use custom `RichText`-based finders, not `find.text()`. +- Active tab color detection is RGB+Alpha equality against `colorScheme.primary`. Tabs without explicit `color` use `DefaultTextStyle`. diff --git a/.claude/skills/themed-table-2/SKILL.md b/.claude/skills/themed-table-2/SKILL.md new file mode 100644 index 0000000..d1a854c --- /dev/null +++ b/.claude/skills/themed-table-2/SKILL.md @@ -0,0 +1,150 @@ +--- +name: themed-table-2 +description: Use ThemedTable2 in a layrz Flutter widget. Apply when displaying a list of domain objects in a table with sort, search, multiselect, and row actions. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Displaying lists of domain objects in tabular form (assets, users, reports, etc.) +- Handles small and large datasets — tested up to 55,000+ rows via virtualized rendering +- Use `ThemedColumn2` to define columns alongside `ThemedTable2` + +--- + +## Minimal usage + +```dart +ThemedTable2( + items: store.assets, + actionsCount: 0, + hasMultiselect: false, + columns: [ + ThemedColumn2( + headerText: 'Name', + valueBuilder: (item) => item.name, + ), + ThemedColumn2( + headerText: 'Plate', + valueBuilder: (item) => item.plate ?? 'N/A', + ), + ], +) +``` + +Always wrap in `Expanded` or give a fixed height — `ThemedTable2` requires bounded height. + +--- + +## Key behaviors + +- Virtualized via `ListView.builder` + fixed `itemExtent` — only visible rows are rendered. +- Sort runs in a background isolate via `compute()` — **valueBuilder and customSort must be isolate-safe** (no BuildContext, no i18n, no Flutter objects). +- Search is debounced 600ms and searches all column values. +- `actionsCount` must equal the number of buttons `actionsBuilder` actually returns — assert enforced. +- `hasMultiselect: true` requires at least one entry in `multiselectActions` — assert enforced. +- `onTapDefaultBehavior` defaults to `.copyToClipboard`; set to `.none` to disable. + +--- + +## Isolate safety — CRITICAL + +`valueBuilder` and `customSort` run inside a background isolate. They **cannot capture Flutter objects** (BuildContext, i18n, State, Streams, widgets). Doing so causes a runtime crash: + +``` +Invalid argument(s): Illegal argument in isolate message: object is unsendable +``` + +**Wrong:** +```dart +// ❌ CRASH — i18n captures BuildContext +valueBuilder: (item) => i18n.t('status.${item.status}'), +``` + +**Right:** +```dart +// ✅ SAFE — precompute a plain Map before the ThemedTable2 call +final Map statusLabels = { + 'active': i18n.t('status.active'), + 'inactive': i18n.t('status.inactive'), +}; + +valueBuilder: (item) => statusLabels[item.status] ?? item.status ?? 'N/A', +``` + +--- + +## Common patterns + +```dart +// With search + actions +ThemedTable2( + items: _items, + canSearch: true, + actionsCount: 2, + hasMultiselect: false, + columns: [ + ThemedColumn2( + headerText: i18n.t('asset.name'), + valueBuilder: (item) => item.name, + ), + ThemedColumn2( + headerText: i18n.t('asset.plate'), + width: 150, + valueBuilder: (item) => item.plate ?? 'N/A', + ), + ], + actionsBuilder: (item) => [ + ThemedActionButton.edit( + labelText: i18n.t('actions.edit'), + onTap: () => _onEdit(item), + ), + ThemedActionButton.delete( + labelText: i18n.t('actions.delete'), + onTap: () => _onDelete(item), + ), + ], +) + +// With multiselect +final ValueNotifier> _selected = ValueNotifier([]); + +ThemedTable2( + items: _items, + hasMultiselect: true, + actionsCount: 0, + multiselectValue: _selected, + multiselectActions: [ + ThemedActionButton( + icon: LayrzIcons.solarOutlineTrashBin, + labelText: i18n.t('actions.deleteSelected'), + color: Colors.red, + onTap: () => _onDeleteSelected(_selected.value), + ), + ], + columns: [ /* ... */ ], +) + +// With programmatic controller +final _controller = ThemedTable2Controller(); + +// In dispose(): +_controller.dispose(); + +ThemedTable2( + items: _items, + controller: _controller, + columns: [ /* ... */ ], + actionsCount: 0, + hasMultiselect: false, +) + +// Trigger sort programmatically +_controller.sort(columnIndex: 0, ascending: true); +_controller.refresh(); +``` diff --git a/.claude/skills/themed-table-2/references/api.md b/.claude/skills/themed-table-2/references/api.md new file mode 100644 index 0000000..72f95c1 --- /dev/null +++ b/.claude/skills/themed-table-2/references/api.md @@ -0,0 +1,215 @@ +# ThemedTable2 — API Reference + +Sources: +- `lib/src/table2/src/table.dart` — `ThemedTable2` +- `lib/src/table2/src/column.dart` — `ThemedColumn2` +- `lib/src/table2/src/controller.dart` — `ThemedTable2Controller` +- `lib/src/table2/src/on_tap_behavior.dart` — `ThemedTable2OnTapBehavior` + +--- + +## Examples + +```dart +// Minimal — no actions, no multiselect +ThemedTable2( + items: store.assets, + actionsCount: 0, + hasMultiselect: false, + columns: [ + ThemedColumn2( + headerText: 'Name', + valueBuilder: (item) => item.name, + ), + ], +) + +// With actions +ThemedTable2( + items: _items, + actionsCount: 2, + hasMultiselect: false, + columns: [ /* ... */ ], + actionsBuilder: (item) => [ + ThemedActionButton.edit(labelText: 'Edit', onTap: () => _edit(item)), + ThemedActionButton.delete(labelText: 'Delete', onTap: () => _delete(item)), + ], +) + +// With multiselect +ThemedTable2( + items: _items, + hasMultiselect: true, + actionsCount: 0, + multiselectValue: _selected, + multiselectActions: [ + ThemedActionButton( + icon: LayrzIcons.solarOutlineTrashBin, + labelText: 'Delete selected', + color: Colors.red, + onTap: () => _deleteSelected(_selected.value), + ), + ], + columns: [ /* ... */ ], +) + +// With programmatic controller +final _controller = ThemedTable2Controller(); +// dispose in State.dispose() + +ThemedTable2( + items: _items, + controller: _controller, + actionsCount: 0, + hasMultiselect: false, + columns: [ /* ... */ ], +) +_controller.sort(columnIndex: 0, ascending: true); +_controller.refresh(); +``` + +--- + +## ThemedTable2 Constructor + +```dart +const ThemedTable2({ + required this.items, + required this.columns, + super.key, + this.actionsBuilder, + this.actionsMobileBreakpoint = kSmallGrid, + this.headerHeight = 40, + this.actionsLabelText = 'Actions', + this.hasMultiselect = true, + this.actionsCount = 0, + this.loadingLabelText = 'Computing data, please wait...', + this.canSearch = true, + this.minColumnWidth = 250, + this.multiselectActions = const [], + this.multiSelectionTitleText = 'Multiple items selected', + this.multiSelectionContentText = 'You have selected multiple items. What do you want to do?', + this.multiSelectionCancelLabelText = 'Clear', + this.multiselectValue, + this.populateDelay = const Duration(milliseconds: 150), + this.reloadOnDidUpdate = false, + this.onTapDefaultBehavior = .copyToClipboard, + this.copyToClipboardText, + this.controller, +}) +// Asserts: +// - columns.length > 0 +// - actionsCount >= 0 +// - minColumnWidth > 0 +// - actionsCount == 0 || actionsBuilder != null +// - hasMultiselect → multiselectActions.length > 0 +``` + +--- + +## ThemedTable2 Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `items` | `List` | required | Full dataset | +| `columns` | `List>` | required | At least one required | +| `actionsCount` | `int` | `0` | Expected number of actions per row; `0` hides actions column | +| `actionsBuilder` | `List Function(T)?` | `null` | Required when `actionsCount > 0` | +| `hasMultiselect` | `bool` | `true` | Shows checkbox column; requires `multiselectActions` | +| `multiselectActions` | `List` | `[]` | Required when `hasMultiselect: true` | +| `multiselectValue` | `ValueNotifier>?` | `null` | External notifier to read/control selection | +| `canSearch` | `bool` | `true` | Shows search input above the table | +| `onTapDefaultBehavior` | `ThemedTable2OnTapBehavior` | `.copyToClipboard` | Cell tap behavior when no `onTap` on column | +| `controller` | `ThemedTable2Controller?` | `null` | Programmatic sort/refresh | +| `populateDelay` | `Duration` | `150ms` | Delay before rendering data | +| `minColumnWidth` | `double` | `250` | Minimum width for flex columns | +| `headerHeight` | `double` | `40` | Header row height | +| `actionsMobileBreakpoint` | `double` | `kSmallGrid` | Width threshold for mobile action layout | +| `actionsLabelText` | `String` | `'Actions'` | Header label for actions column | +| `loadingLabelText` | `String` | `'Computing data...'` | Text shown during sort/filter | +| `reloadOnDidUpdate` | `bool` | `false` | Forces reload on hot reload (debug only) | +| `copyToClipboardText` | `String?` | `null` | Toast text override for copy-to-clipboard | +| `multiSelectionTitleText` | `String` | `'Multiple items selected'` | Fallback when i18n unavailable | +| `multiSelectionContentText` | `String` | `'You have selected...'` | Fallback when i18n unavailable | +| `multiSelectionCancelLabelText` | `String` | `'Clear'` | Fallback when i18n unavailable | + +--- + +## ThemedColumn2 Constructor + +```dart +ThemedColumn2({ + required this.headerText, + required this.valueBuilder, + this.richTextBuilder, + this.alignment = .centerLeft, + this.isSortable = true, + this.width, + this.onTap, + this.customSort, +}) +``` + +--- + +## ThemedColumn2 Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `headerText` | `String` | required | Column header label | +| `valueBuilder` | `String Function(T)` | required | Extracts plain string for sort, search, and display. **Must be isolate-safe** | +| `richTextBuilder` | `List Function(T)?` | `null` | Rich cell rendering; does NOT affect sort | +| `alignment` | `Alignment` | `.centerLeft` | Cell content alignment | +| `isSortable` | `bool` | `true` | Header click triggers sort | +| `width` | `double?` | `null` | Fixed pixel width; `null` = flex | +| `onTap` | `CellTap?` | `null` | Per-cell tap; overrides `onTapDefaultBehavior` | +| `customSort` | `int Function(T a, T b, bool ascending)?` | `null` | Custom comparator. **Must be isolate-safe** | + +--- + +## ThemedTable2OnTapBehavior + +```dart +enum ThemedTable2OnTapBehavior { + none, // No action on cell tap + copyToClipboard, // Copies valueBuilder result to clipboard (default) +} +``` + +--- + +## ThemedTable2Controller + +```dart +final controller = ThemedTable2Controller(); + +controller.sort(columnIndex: 0, ascending: true); // Trigger sort +controller.refresh(); // Re-filter + re-sort +controller.dispose(); // Always call in State.dispose() +``` + +Wire via `controller` parameter on `ThemedTable2`. Controller must be disposed by the caller. + +--- + +## Isolate safety + +`valueBuilder` and `customSort` execute inside `compute()` (background isolate). They **cannot capture**: +- `BuildContext` (directly or indirectly) +- `LayrzAppLocalizations` / `i18n` (holds a `BuildContext`) +- `State`, streams, `AnimationController`, `ValueNotifier` +- Any Flutter widget or element + +**Safe pattern:** precompute a `Map` from `i18n` before the `ThemedTable2(...)` call, reference the map inside `valueBuilder`. + +--- + +## Anti-patterns + +| Anti-pattern | Fix | +|---|---| +| `valueBuilder: (item) => i18n.t(item.key)` | Precompute `Map` from i18n outside the column | +| `customSort` capturing `context` or `store` | Use only item fields inside `customSort` | +| `actionsCount: 2` without `actionsBuilder` | Assert failure — always pair them | +| `hasMultiselect: true` with empty `multiselectActions` | Assert failure — provide at least one action | +| `ThemedTable2` without `Expanded` or fixed height | Layout overflow/crash — always bound the height | diff --git a/.claude/skills/themed-text-input/SKILL.md b/.claude/skills/themed-text-input/SKILL.md new file mode 100644 index 0000000..d50a515 --- /dev/null +++ b/.claude/skills/themed-text-input/SKILL.md @@ -0,0 +1,101 @@ +--- +name: themed-text-input +description: Use ThemedTextInput in a layrz Flutter widget. Apply when adding any free-text field, multiline textarea, or combobox autocomplete to a form or view. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.col6`, `.start`, `.left`) — never write the fully-qualified form (`Sizes.col6`, `WrapAlignment.start`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Any free-text form field (name, email, URL, notes, phone) +- Multiline textarea (`maxLines > 1`) +- Autocomplete / typeahead with a string list (`enableCombobox: true`) + +For passwords → use `ThemedPasswordInput`. For search bars → use `ThemedSearchInput`. +Never use raw `TextField` or `TextFormField`. + +--- + +## Minimal usage + +```dart +ThemedTextInput( + labelText: context.i18n.t('entity.name'), + value: name, + errors: context.getErrors(key: 'name'), + onChanged: (value) { + name = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Exactly one of `label` / `labelText` must be set — assert enforced. +- `disabled: true` → appends `LayrzIcons.solarOutlineLockKeyhole` as suffix automatically. Do not add your own. +- `value` syncs to the internal `TextEditingController` on `didUpdateWidget` (only when no external `controller` provided). +- `validator` gates `onChanged` — if provided, `onChanged` fires only when `validator` returns `true`. +- `enableCombobox: true` → opens `OverlayEntry` on tap, overrides `onTap`. Reacts to live `choices` updates. +- `maxLines > 1` forces `floatingLabelBehavior: always`. +- `prefixIcon`/`prefixWidget` are mutually exclusive. Same for `suffixIcon`/`suffixWidget`. + +--- + +## Common patterns + +```dart +// Multiline textarea +ThemedTextInput( + labelText: context.i18n.t('entity.description'), + value: description, + maxLines: 5, + errors: context.getErrors(key: 'description'), + onChanged: (value) { + description = value; + if (context.mounted) onChanged.call(); + }, +) + +// Combobox autocomplete +ThemedTextInput( + labelText: context.i18n.t('entity.city'), + value: city, + enableCombobox: true, + choices: citySuggestions, + errors: context.getErrors(key: 'city'), + onChanged: (value) { + city = value; + if (context.mounted) onChanged.call(); + }, +) + +// Prefix + suffix icons +ThemedTextInput( + labelText: context.i18n.t('entity.url'), + value: url, + prefixIcon: LayrzIcons.solarOutlineLink, + suffixIcon: LayrzIcons.solarOutlineCopy, + onSuffixTap: () => Clipboard.setData(ClipboardData(text: url)), + errors: context.getErrors(key: 'url'), + onChanged: (value) { + url = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-text-input/references/api.md b/.claude/skills/themed-text-input/references/api.md new file mode 100644 index 0000000..60d7d1d --- /dev/null +++ b/.claude/skills/themed-text-input/references/api.md @@ -0,0 +1,235 @@ +# ThemedTextInput — API Reference + +Source: `lib/src/inputs/src/general/text_input.dart` + +- `ThemedTextInput` class — line 3 +- `ThemedTextInput.outerPadding` static getter — line 184 → `EdgeInsets.all(10)` +- `ThemedComboboxPosition` enum — line 595 + +--- + +## Examples + +```dart +// Basic form field +ThemedTextInput( + labelText: context.i18n.t('entity.name'), + value: name, + errors: context.getErrors(key: 'name'), + onChanged: (value) { + name = value; + if (context.mounted) onChanged.call(); + }, +) + +// Shared state — two inputs bound to the same variable +ThemedTextInput( + labelText: 'Primary label', + value: _text, + onChanged: (value) => setState(() => _text = value), +) +ThemedTextInput( + labelText: 'Secondary label (same state)', + value: _text, + onChanged: (value) => setState(() => _text = value), +) + +// Prefix + suffix icons +ThemedTextInput( + labelText: 'URL', + prefixIcon: LayrzIcons.mdiAccessPoint, + suffixIcon: LayrzIcons.mdiAccessPoint, +) + +// Placeholder with custom text style and input formatter +ThemedTextInput( + labelText: 'Formatted field', + placeholder: 'Numbers only', + textStyle: TextStyle(color: Colors.purple), + inputFormatters: [ + TextInputFormatter.withFunction((oldValue, newValue) { + final regex = RegExp(r'^\d+\,?\d*$'); + if (newValue.text.isEmpty) return newValue; + if (regex.hasMatch(newValue.text)) return newValue; + return oldValue; + }), + ], +) + +// Validation errors +const ThemedTextInput( + labelText: 'Field with errors', + errors: ['Error 1', 'Error 2'], +) + +// Combobox autocomplete +const ThemedTextInput( + labelText: 'City', + choices: ['Choice 1', 'Choice 2', 'Choice 3'], + enableCombobox: true, +) + +// Multiline textarea +ThemedTextInput( + labelText: context.i18n.t('entity.description'), + value: description, + maxLines: 5, + errors: context.getErrors(key: 'description'), + onChanged: (value) { + description = value; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled (lock icon added automatically) +ThemedTextInput( + labelText: context.i18n.t('entity.name'), + value: name, + disabled: true, +) + +// Required field marker +ThemedTextInput( + labelText: context.i18n.t('entity.name'), + value: name, + isRequired: true, + onChanged: (value) { + name = value; + if (context.mounted) onChanged.call(); + }, +) + +// Copy-to-clipboard suffix +ThemedTextInput( + labelText: context.i18n.t('entity.url'), + value: url, + prefixIcon: LayrzIcons.solarOutlineLink, + suffixIcon: LayrzIcons.solarOutlineCopy, + onSuffixTap: () => Clipboard.setData(ClipboardData(text: url)), + errors: context.getErrors(key: 'url'), + onChanged: (value) { + url = value; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Constructor + +```dart +const ThemedTextInput({ + super.key, + this.keyboardType = .text, + this.labelText, + this.label, + this.disabled = false, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.prefixIconDisabled = false, + this.suffixIconDisabled = false, + this.suffixIcon, + this.suffixText, + this.suffixWidget, + this.onSuffixTap, + this.onTap, + this.obscureText = false, + this.controller, + this.onChanged, + this.value, + this.errors = const [], + this.hideDetails = false, + this.padding, + this.dense = false, + this.isRequired = false, + this.focusNode, + this.validator, + this.onSubmitted, + this.readonly = false, + this.inputFormatters = const [], + this.autofillHints = const [], + this.borderRadius, + this.maxLines = 1, + this.autocorrect = true, + this.enableSuggestions = true, + this.autofocus = false, + this.choices = const [], + this.maxChoicesToDisplay = 5, + this.enableCombobox = false, + this.emptyChoicesText = "No choices", + this.position = .below, + this.textStyle, +}) +``` + +Assert: `(label == null && labelText != null) || (label != null && labelText == null)` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `keyboardType` | `TextInputType` | `.text` | Software keyboard layout | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `disabled` | `bool` | `false` | Sets readOnly + appends lock icon suffix automatically | +| `placeholder` | `String?` | `null` | Hint text shown when empty | +| `prefixText` | `String?` | `null` | Inline text prefix inside the field | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | | +| `prefixIconDisabled` | `bool` | `false` | 0.4 opacity + ignores taps | +| `suffixIconDisabled` | `bool` | `false` | 0.4 opacity + ignores taps | +| `suffixIcon` | `IconData?` | `null` | Mutually exclusive with `suffixWidget` | +| `suffixText` | `String?` | `null` | Inline text suffix inside the field | +| `suffixWidget` | `Widget?` | `null` | Mutually exclusive with `suffixIcon` | +| `onSuffixTap` | `VoidCallback?` | `null` | | +| `onTap` | `VoidCallback?` | `null` | Overridden when `enableCombobox: true` | +| `obscureText` | `bool` | `false` | Use `ThemedPasswordInput` instead of setting this directly | +| `controller` | `TextEditingController?` | `null` | NOT disposed by widget | +| `onChanged` | `void Function(String)?` | `null` | Fires only when `validator` passes | +| `value` | `String?` | `null` | Synced to internal controller on `didUpdateWidget` | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Defaults to `EdgeInsets.all(10)`; read via `ThemedTextInput.outerPadding` | +| `dense` | `bool` | `false` | Reduces vertical padding | +| `isRequired` | `bool` | `false` | Prepends `*` to the label | +| `focusNode` | `FocusNode?` | `null` | NOT disposed by widget | +| `validator` | `bool Function(String)?` | `null` | Gates `onChanged`; if provided, fires only when `true` | +| `onSubmitted` | `VoidCallback?` | `null` | Called on keyboard submit action | +| `readonly` | `bool` | `false` | Readonly without disabled styling | +| `inputFormatters` | `List` | `[]` | Applied to the underlying `TextField` | +| `autofillHints` | `List` | `[]` | Browser/OS autofill hints | +| `borderRadius` | `double?` | `null` | Switches to `OutlineInputBorder` when set | +| `maxLines` | `int` | `1` | Values > 1 force `floatingLabelBehavior: always` | +| `autocorrect` | `bool` | `true` | | +| `enableSuggestions` | `bool` | `true` | | +| `autofocus` | `bool` | `false` | | +| `choices` | `List` | `[]` | Combobox options; reactive via `StreamController.broadcast` | +| `maxChoicesToDisplay` | `int` | `5` | Max rows before scrolling in combobox | +| `enableCombobox` | `bool` | `false` | Opens `OverlayEntry` on tap instead of calling `onTap` | +| `emptyChoicesText` | `String` | `"No choices"` | Shown when `choices` is empty | +| `position` | `ThemedComboboxPosition` | `.below` | Combobox opens above or below the field | +| `textStyle` | `TextStyle?` | `null` | Overrides text style inside the field | + +--- + +## ThemedComboboxPosition enum + +| Value | Description | +|---|---| +| `.above` | Combobox opens above the field | +| `.below` | Combobox opens below the field (default) | + +--- + +## Static API + +| Member | Type | Value | +|---|---|---| +| `ThemedTextInput.outerPadding` | `EdgeInsets` | `EdgeInsets.all(10)` | diff --git a/.claude/skills/themed-time-picker/SKILL.md b/.claude/skills/themed-time-picker/SKILL.md new file mode 100644 index 0000000..9b474e7 --- /dev/null +++ b/.claude/skills/themed-time-picker/SKILL.md @@ -0,0 +1,91 @@ +--- +name: themed-time-picker +description: Use ThemedTimePicker in a layrz Flutter widget. Apply when adding a single time-of-day selection field. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Single time-of-day selection; value type: `TimeOfDay?` +- Never use Flutter's built-in `showTimePicker` — always use this widget. + +For start + end time pair → use `ThemedTimeRangePicker`. + +--- + +## Minimal usage + +```dart +ThemedTimePicker( + labelText: context.i18n.t('entity.time'), + value: selectedTime, + errors: context.getErrors(key: 'time'), + onChanged: (time) { + selectedTime = time; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- Opens a dialog with hour/minute spinners (+/− buttons on desktop, keyboard input on mobile). +- `onChanged` fires only when the user taps **Save** — not on each spinner change. +- Default format: 12-hour (`%I:%M %p`). Set `use24HourFormat: true` for 24-hour (`%H:%M`). +- `pattern` overrides the display format without changing spinner behavior. +- `disableBlink: true` disables the 700 ms blink animation on the time display. +- `customChild` wraps any widget in an `InkWell` that opens the dialog. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// 24-hour format +ThemedTimePicker( + labelText: context.i18n.t('schedule.startTime'), + value: startTime, + use24HourFormat: true, + errors: context.getErrors(key: 'startTime'), + onChanged: (time) { + startTime = time; + if (context.mounted) onChanged.call(); + }, +) + +// Custom display pattern (hours only) +ThemedTimePicker( + labelText: context.i18n.t('shift.hour'), + value: shiftHour, + use24HourFormat: true, + pattern: '%Hh%M', + onChanged: (time) { + shiftHour = TimeOfDay(hour: time.hour, minute: 0); + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedTimePicker( + labelText: context.i18n.t('entity.time'), + value: selectedTime, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-time-picker/references/api.md b/.claude/skills/themed-time-picker/references/api.md new file mode 100644 index 0000000..164e274 --- /dev/null +++ b/.claude/skills/themed-time-picker/references/api.md @@ -0,0 +1,153 @@ +# ThemedTimePicker — API Reference + +Source: `lib/src/inputs/src/pickers/time/single.dart` + +- `ThemedTimePicker` class — line 3 + +--- + +## Examples + +```dart +// Basic 12-hour picker +ThemedTimePicker( + labelText: 'Time', + value: selectedTime, + errors: context.getErrors(key: 'time'), + onChanged: (time) => setState(() => selectedTime = time), +) + +// 24-hour format +ThemedTimePicker( + labelText: 'Time', + value: selectedTime, + use24HourFormat: true, + onChanged: (time) => setState(() => selectedTime = time), +) + +// Custom display pattern +ThemedTimePicker( + labelText: 'Hour', + value: selectedTime, + use24HourFormat: true, + pattern: '%Hh%M', + onChanged: (time) => setState(() => selectedTime = time), +) + +// No blink animation +ThemedTimePicker( + labelText: 'Time', + value: selectedTime, + disableBlink: true, + onChanged: (time) => setState(() => selectedTime = time), +) + +// Disabled +ThemedTimePicker( + labelText: 'Time', + value: selectedTime, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedTimePicker({ + super.key, + this.value, + this.onChanged, + this.labelText, + this.label, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.customChild, + this.disabled = false, + this.translations = const { + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'layrz.timePicker.hours': 'Hours', + 'layrz.timePicker.minutes': 'Minutes', + }, + this.overridesLayrzTranslations = false, + this.pattern, + this.use24HourFormat = false, + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.errors = const [], + this.hideDetails = false, + this.disableBlink = false, + this.padding, + this.dense = false, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `TimeOfDay?` | `null` | Currently selected time | +| `onChanged` | `void Function(TimeOfDay)?` | `null` | Fires only on Save tap | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text when value is null | +| `prefixText` | `String?` | `null` | Inline text prefix | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the prefix | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `use24HourFormat` | `bool` | `false` | `true` → 24 h spinners + `%H:%M`; `false` → 12 h + AM/PM toggle + `%I:%M %p` | +| `pattern` | `String?` | `null` | Display format override; uses `DateTime.format()` extension — not `DateFormat` | +| `disableBlink` | `bool` | `false` | Disables the 700 ms blink animation on hour/minute display | +| `dense` | `bool` | `false` | Reduces vertical padding | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | + +--- + +## Translations map keys + +| Key | Default | +|---|---| +| `actions.cancel` | `'Cancel'` | +| `actions.save` | `'Save'` | +| `layrz.timePicker.hours` | `'Hours'` | +| `layrz.timePicker.minutes` | `'Minutes'` | + +--- + +## Dialog behavior + +- Dialog max size: 400×400 logical pixels. +- Initializes to `value` if set, or `TimeOfDay.now()` if null. +- Desktop: +/− buttons flank each spinner. Mobile (width < `kSmallGrid`): buttons hidden, user types digits. +- `onChanged` called only on Save tap — not on intermediate spinner changes. +- Suffix icon (`LayrzIcons.solarOutlineClockSquare`) is always shown; not configurable. + +## Format tokens (DateTime.format extension) + +| Token | Meaning | +|---|---| +| `%H` | 24 h hour (00–23) | +| `%I` | 12 h hour (01–12) | +| `%M` | Minutes (00–59) | +| `%p` | AM/PM | diff --git a/.claude/skills/themed-time-range-picker/SKILL.md b/.claude/skills/themed-time-range-picker/SKILL.md new file mode 100644 index 0000000..5d84d9e --- /dev/null +++ b/.claude/skills/themed-time-range-picker/SKILL.md @@ -0,0 +1,92 @@ +--- +name: themed-time-range-picker +description: Use ThemedTimeRangePicker in a layrz Flutter widget. Apply when adding a start + end time selection field. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values — never write the fully-qualified form. + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Start + end time-of-day pair; value type: `List` (empty or exactly 2 elements) +- Never use Flutter's built-in `showTimePicker` — always use this widget. + +For a single time → use `ThemedTimePicker`. + +--- + +## Minimal usage + +```dart +ThemedTimeRangePicker( + labelText: context.i18n.t('entity.timeRange'), + value: timeRange, + errors: context.getErrors(key: 'timeRange'), + onChanged: (range) { + timeRange = range; + if (context.mounted) onChanged.call(); + }, +) +``` + +--- + +## Key behaviors + +- `value` must be empty or exactly 2 elements — assert enforced. +- Opens a dialog with two independent hour/minute spinners (Start / End) stacked vertically. +- Result is always sorted `[earliest, latest]` — no manual sorting needed. +- `onChanged` fires only when the user taps **Save** and both start and end are set. +- Default format: 12-hour (`%I:%M %p`). Set `use24HourFormat: true` for 24-hour (`%H:%M`). +- Display: `"HH:MM - HH:MM"` formatted with `pattern`. +- `disableBlink: true` disables the blink animation in both spinners. +- `customChild` wraps any widget in an `InkWell` that opens the dialog. +- Exactly one of `label` / `labelText` must be set — assert enforced. + +--- + +## Common patterns + +```dart +// 24-hour format +ThemedTimeRangePicker( + labelText: context.i18n.t('shift.operatingHours'), + value: operatingHours, + use24HourFormat: true, + errors: context.getErrors(key: 'operatingHours'), + onChanged: (range) { + operatingHours = range; + if (context.mounted) onChanged.call(); + }, +) + +// Pre-populated range +ThemedTimeRangePicker( + labelText: context.i18n.t('schedule.window'), + value: const [TimeOfDay(hour: 8, minute: 0), TimeOfDay(hour: 17, minute: 0)], + onChanged: (range) { + selectedRange = range; + if (context.mounted) onChanged.call(); + }, +) + +// Disabled +ThemedTimeRangePicker( + labelText: context.i18n.t('entity.timeRange'), + value: timeRange, + disabled: true, +) +``` + +--- + +## Form conventions + +- Guard `onChanged` with `if (context.mounted)` before calling the parent callback. +- Use `context.i18n.t('entity.fieldName')` for `labelText` — never hardcode strings. +- Pass `errors: context.getErrors(key: 'fieldName')`. +- Never pass a single-element list to `value` — the assert will throw. +- Separate stacked inputs with `const SizedBox(height: 10)`. diff --git a/.claude/skills/themed-time-range-picker/references/api.md b/.claude/skills/themed-time-range-picker/references/api.md new file mode 100644 index 0000000..757f2db --- /dev/null +++ b/.claude/skills/themed-time-range-picker/references/api.md @@ -0,0 +1,137 @@ +# ThemedTimeRangePicker — API Reference + +Source: `lib/src/inputs/src/pickers/time/range.dart` + +- `ThemedTimeRangePicker` class — line 3 + +--- + +## Examples + +```dart +// Basic time range (empty initial) +ThemedTimeRangePicker( + labelText: 'Time range', + value: const [], + errors: context.getErrors(key: 'timeRange'), + onChanged: (range) => setState(() => timeRange = range), +) + +// Pre-populated range +ThemedTimeRangePicker( + labelText: 'Operating hours', + value: const [TimeOfDay(hour: 8, minute: 0), TimeOfDay(hour: 17, minute: 0)], + onChanged: (range) => setState(() => operatingHours = range), +) + +// 24-hour format +ThemedTimeRangePicker( + labelText: 'Shift window', + value: timeRange, + use24HourFormat: true, + onChanged: (range) => setState(() => timeRange = range), +) + +// Disabled +ThemedTimeRangePicker( + labelText: 'Time range', + value: timeRange, + disabled: true, +) +``` + +--- + +## Constructor + +```dart +const ThemedTimeRangePicker({ + super.key, + this.value = const [], + this.onChanged, + this.labelText, + this.label, + this.placeholder, + this.prefixText, + this.prefixIcon, + this.prefixWidget, + this.onPrefixTap, + this.customChild, + this.disabled = false, + this.translations = const { + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'layrz.timePicker.hours': 'Hours', + 'layrz.timePicker.minutes': 'Minutes', + 'layrz.timePicker.start': 'Start time', + 'layrz.timePicker.end': 'End time', + }, + this.overridesLayrzTranslations = false, + this.pattern, + this.use24HourFormat = false, + this.hoverColor = Colors.transparent, + this.focusColor = Colors.transparent, + this.splashColor = Colors.transparent, + this.highlightColor = Colors.transparent, + this.borderRadius = const BorderRadius.all(Radius.circular(10)), + this.errors = const [], + this.hideDetails = false, + this.disableBlink = false, + this.padding, +}) : assert((label == null && labelText != null) || (label != null && labelText == null)), + assert(value.length == 0 || value.length == 2); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `value` | `List` | `[]` | Empty or exactly 2 elements (assert enforced) | +| `onChanged` | `void Function(List)?` | `null` | Fires with sorted `[start, end]` only when both are set on Save | +| `labelText` | `String?` | — | Mutually exclusive with `label` | +| `label` | `Widget?` | — | Mutually exclusive with `labelText` | +| `placeholder` | `String?` | `null` | Hint text when value is empty | +| `prefixText` | `String?` | `null` | Inline text prefix | +| `prefixIcon` | `IconData?` | `null` | Mutually exclusive with `prefixWidget` | +| `prefixWidget` | `Widget?` | `null` | Mutually exclusive with `prefixIcon` | +| `onPrefixTap` | `VoidCallback?` | `null` | Tap handler for the prefix | +| `customChild` | `Widget?` | `null` | Custom trigger widget; wraps in `InkWell` | +| `disabled` | `bool` | `false` | Prevents opening the picker | +| `use24HourFormat` | `bool` | `false` | `true` → 24 h; `false` → 12 h + AM/PM | +| `pattern` | `String?` | `null` | Display format override | +| `disableBlink` | `bool` | `false` | Disables blink animation in both spinners | +| `errors` | `List` | `[]` | Validation messages shown below the field | +| `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | +| `padding` | `EdgeInsets?` | `null` | Outer padding; defaults to `ThemedTextInput.outerPadding` | +| `hoverColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` hover color | +| `focusColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` focus color | +| `splashColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` splash color | +| `highlightColor` | `Color` | `Colors.transparent` | `customChild` `InkWell` highlight color | +| `borderRadius` | `BorderRadius` | `BorderRadius.all(Radius.circular(10))` | `customChild` `InkWell` border radius | +| `translations` | `Map` | (see below) | Fallback strings when i18n unavailable | +| `overridesLayrzTranslations` | `bool` | `false` | Force use of `translations` map | + +--- + +## Translations map keys + +| Key | Default | Used by | +|---|---|---| +| `actions.cancel` | `'Cancel'` | Both | +| `actions.save` | `'Save'` | Both | +| `layrz.timePicker.hours` | `'Hours'` | Both | +| `layrz.timePicker.minutes` | `'Minutes'` | Both | +| `layrz.timePicker.start` | `'Start time'` | Range only | +| `layrz.timePicker.end` | `'End time'` | Range only | + +--- + +## Dialog behavior + +- Two `_ThemedTimeUtility` spinners stacked vertically (Start / End label above each). +- Dialog max size: 400×430 (24 h) or 400×550 (12 h — extra height for AM/PM toggles). +- Result sorted by hour then minute before calling `onChanged`. +- `onChanged` not called if either spinner is still null when Save is tapped. +- Display: `"HH:MM - HH:MM"` joined with ` - `. diff --git a/.claude/skills/themed-tooltip/SKILL.md b/.claude/skills/themed-tooltip/SKILL.md new file mode 100644 index 0000000..cd9006e --- /dev/null +++ b/.claude/skills/themed-tooltip/SKILL.md @@ -0,0 +1,93 @@ +--- +name: themed-tooltip +description: Use ThemedTooltip in a layrz Flutter widget. Apply when wrapping any child with a hover/long-press hint — drop-in replacement for Flutter's Tooltip that preserves child tap gestures. +--- + +> **Dart syntax:** This library requires Dart ≥ 3.10. Use dot shorthand for all enum values (e.g. `.top`, `.bottom`, `.left`, `.right`) — never write the fully-qualified form (`ThemedTooltipPosition.bottom`). + +> **Full constructor and property reference:** read `references/api.md` in this skill's directory. + +--- + +## When to use + +- Anywhere you would reach for Flutter's `Tooltip` — `ThemedTooltip` is the Layrz drop-in replacement. +- Use it specifically when the wrapped child must remain tappable: Flutter's `Tooltip` intercepts taps on mobile, `ThemedTooltip` does not. +- Hover on desktop/web shows the tip automatically; on touch devices a long-press shows it. +- Any icon button, action chip, or compact control that needs a short explanatory hint. +- Never wrap in a raw `Tooltip(...)` inside layrz code — always use `ThemedTooltip`. + +--- + +## Minimal usage + +```dart +ThemedTooltip( + message: context.i18n.t('actions.delete'), + child: IconButton( + icon: const Icon(LayrzIcons.solarOutlineTrashBinTrash), + onPressed: () => onDelete(), + ), +) +``` + +--- + +## Key behaviors + +- `child` and `message` are **required**. +- `position` defaults to `.bottom`. Other options: `.top`, `.left`, `.right`. +- Auto-clamps to the screen edge: if the tooltip would overflow horizontally, it shifts to `left: 0` / `right: 0`; if `.left` / `.right` don't fit, it flips to the opposite side. +- `color` defaults to `Theme.of(context).tooltipTheme.decoration` (primary color on light theme, `kDarkBackgroundColor` on dark theme). Pass a custom `Color` to override. +- Text color is auto-derived from the background via `validateColor(...)` for contrast — do not set it manually. +- Mouse detected → shows on hover via `MouseRegion`. No mouse → shows on `onLongPress`. +- Tooltip hides on pointer-down anywhere, pointer-up, pointer-cancel, or when the app is paused/inactive. +- Preserves the child's own gesture detector — taps on the child fire normally. This is the key difference from Flutter's `Tooltip`. + +--- + +## Common patterns + +```dart +// Default — hint below the child +ThemedTooltip( + message: context.i18n.t('entity.info'), + child: const Icon(LayrzIcons.solarOutlineInfoCircle), +) + +// Position above +ThemedTooltip( + message: context.i18n.t('actions.edit'), + position: .top, + child: IconButton( + icon: const Icon(LayrzIcons.solarOutlinePen), + onPressed: onEdit, + ), +) + +// Custom background color (text color is auto-contrasted) +ThemedTooltip( + message: context.i18n.t('status.warning'), + color: Colors.orange, + child: const Icon(LayrzIcons.solarOutlineDangerTriangle), +) + +// Wrapping a tappable control — tap still fires +ThemedTooltip( + message: context.i18n.t('actions.save'), + position: .left, + child: ElevatedButton( + onPressed: onSave, + child: Text(context.i18n.t('actions.save')), + ), +) +``` + +--- + +## Usage conventions + +- Use `context.i18n.t('...')` for `message` — never hardcode strings. +- Prefer the default `.bottom` position; only override when layout constraints demand it (e.g. row of icons near the screen bottom → use `.top`). +- Don't pass `color` just to match the theme — the default already picks the correct themed color. +- Keep messages short (one line); long strings wrap to 80% of screen width but hurt scannability. diff --git a/.claude/skills/themed-tooltip/references/api.md b/.claude/skills/themed-tooltip/references/api.md new file mode 100644 index 0000000..5fb17d6 --- /dev/null +++ b/.claude/skills/themed-tooltip/references/api.md @@ -0,0 +1,101 @@ +# ThemedTooltip — API Reference + +Source: `lib/src/tooltips/src/custom_tooltip.dart` + +- `ThemedTooltip` class — line 3 +- `ThemedTooltipPosition` enum — line 296 + +--- + +## Examples + +```dart +// Default — tooltip below the child +ThemedTooltip( + message: 'Delete', + child: IconButton( + icon: const Icon(LayrzIcons.solarOutlineTrashBinTrash), + onPressed: onDelete, + ), +) + +// Position above +ThemedTooltip( + message: 'Edit', + position: .top, + child: IconButton( + icon: const Icon(LayrzIcons.solarOutlinePen), + onPressed: onEdit, + ), +) + +// Position left +ThemedTooltip( + message: 'Save', + position: .left, + child: ElevatedButton( + onPressed: onSave, + child: const Text('Save'), + ), +) + +// Position right +ThemedTooltip( + message: 'More info', + position: .right, + child: const Icon(LayrzIcons.solarOutlineInfoCircle), +) + +// Custom background color (text color auto-contrasted) +ThemedTooltip( + message: 'Warning', + color: Colors.orange, + child: const Icon(LayrzIcons.solarOutlineDangerTriangle), +) +``` + +--- + +## Constructor + +```dart +const ThemedTooltip({ + super.key, + required Widget child, + required String message, + ThemedTooltipPosition position = .bottom, + Color? color, +}); +``` + +--- + +## Properties + +| Property | Type | Default | Notes | +|---|---|---|---| +| `child` | `Widget` | — | **Required.** The widget wrapped by the tooltip. Its own gestures are preserved. | +| `message` | `String` | — | **Required.** Text displayed in the tooltip bubble. Wraps at 80% of screen width. | +| `position` | `ThemedTooltipPosition` | `.bottom` | Placement relative to the child. Auto-flips / clamps to stay on-screen. | +| `color` | `Color?` | `null` | Background color. When null, uses `Theme.of(context).tooltipTheme.decoration` color. Text color is always derived via `validateColor(...)` for contrast. | + +--- + +## ThemedTooltipPosition enum + +| Value | Description | +|---|---| +| `.top` | Tooltip rendered above the child, 10px gap; horizontally centered and clamped to screen edges. | +| `.bottom` | Tooltip rendered below the child, 10px gap; horizontally centered and clamped to screen edges. | +| `.left` | Tooltip rendered to the left; if it would overflow, flips to the right; if still no space, left-aligns with clamped width. | +| `.right` | Tooltip rendered to the right; if it would overflow, flips to the left and can narrow to fit. | + +--- + +## Behavior notes + +- **Input mode detection**: uses `RendererBinding.instance.mouseTracker.mouseIsConnected`. If a mouse is present, hover triggers the tooltip (`MouseRegion`); otherwise a `GestureDetector.onLongPress` triggers it. +- **Auto-dismiss**: any `PointerDownEvent` dismisses immediately; `PointerUpEvent` / `PointerCancelEvent` animate out. The tooltip also hides on `AppLifecycleState.paused` / `.inactive`. +- **Animation**: fades in/out using `kHoverDuration` via an internal `AnimationController`. +- **Preserves child taps**: unlike Flutter's `Tooltip` which can intercept taps on touch devices, `ThemedTooltip` wraps its child so the child's own `onTap` / `onPressed` always fire. +- **Semantics**: exposes `message` via `Semantics(tooltip: message, child: child)` for accessibility. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8999da0..487b0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 7.5.27 + +- Fixed `ThemedColorPicker` double `#` prefix bug: `.hex` extension already includes `#`, so the controller text was displaying `##RRGGBB` instead of `#RRGGBB`. +- Fixed `ThemedColorPicker` memory leak: `TextEditingController` is now properly disposed in `dispose()`. +- Fixed `ThemedColorPicker` external value not reflected: added `didUpdateWidget` to sync `_value` and controller text when the parent passes a new `value` prop. +- Fixed `ThemedColorPicker` duplicate dialog buttons: `ColorPickerActionButtons` had `dialogActionButtons: true` while a manual `Row` with `ThemedButton.cancel`/`ThemedButton.save` was also rendered below; set `dialogActionButtons: false` to use only the Layrz-themed buttons. +- Fixed `ThemedColorPicker` assertion: replaced `(label == null && labelText != null) || (label != null && labelText == null)` with XOR logic `(label == null) != (labelText == null)` and added a descriptive error message. +- Fixed `ThemedColorPicker` doc comments misalignment: `onChanged`, `value`, and `disabled` fields had their comments shifted by two fields. +- Removed `ThemedColorPicker` unused `prefixIcon` parameter: the prefix is always a color swatch and cannot be replaced with an icon. +- Improved `ThemedColorPicker` `placeholder` forwarding: the parameter was declared but never passed to `ThemedTextInput`. +- Improved `ThemedColorPicker` `dense` and `padding` forwarding: `ThemedTextInput` now receives values through the `isDense` and `widgetPadding` getters. +- Improved `ThemedColorPicker` method signature: `_showPicker` is now `Future` instead of `void async`. +- Added comprehensive widget tests for `ThemedColorPicker` covering: rendering (label, hex display, kPrimaryColor fallback, color swatch), disabled state (dialog does not open), and lifecycle (dispose without leak, repeated mount/unmount, `didUpdateWidget` updates field on external value change, resets to `kPrimaryColor` on null, handles same-value rebuild without crash). +- Added `ThemedColorPicker` skill documentation with parameter reference, `ColorPickerType` values, usage examples, form integration patterns, and Color extension helpers. + ## 7.5.26 - Fixed `ThemedNumberInput` memory leak: `TextEditingController` is now properly disposed in `dispose()`. diff --git a/LICENSE b/LICENSE index 656b796..bde1b1e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2023 Golden M, Inc. +Copyright 2023-2026 Golden M, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 6cacba3..43d34cd 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,30 @@ It's a set of tools, widgets and generators to help you to create an application easily, fast and with a good quality using the Layrz design standard. Works in the platforms that Flutter supports, also mostly of `layrz_theme` works in Embedded devices using [Flutter eLinux](https://github.com/sony/flutter-elinux) (Disclaimer, not fully tested). +## Claude Code skill + +This repository includes a *Claude Code plugin* as part of our initiative to provide AI-assisted development tools for Flutter. The plugin contains a collection of skills that guide developers on how to use the various components of the `layrz_theme` library effectively in their Flutter projects. Each skill includes detailed descriptions, usage examples, and best practices for implementing specific widgets or features from the `layrz_theme` package. By leveraging these skills, developers can quickly integrate `layrz_theme` into their applications and adhere to the Layrz design standard with ease. + +### Installation + +Add this repository as a Claude Code plugin marketplace, then install the plugin: + +```bash +/plugin marketplace add goldenm-software/layrz-theme +``` + +Once the marketplace is added, install the plugin from the **Discover** tab in `/plugin`, or run: + +```bash +/plugin install layrz-theme@goldenm-software-layrz-theme +``` + +Then reload your plugins: + +```bash +/reload-plugins +``` + ## Live demo You can see a live demo on [https://theme.layrz.com](https://theme.layrz.com) (Disclaimer, not all of the components of Layrz Theme are available in the demo) @@ -16,10 +40,6 @@ You can see a live demo on [https://theme.layrz.com](https://theme.layrz.com) (D All packages developed by [Layrz](https://layrz.com) are prefixed with `layrz_`, check out our other packages on [pub.dev](https://pub.dev/publishers/goldenm.com/packages). -### This library is a framework? - -Technically, no because Flutter is the Framework, but `layrz_theme` is framework-like library, with a set of tools, widgets and generators. - ### Why this library exists? We are using Flutter to create most of our applications, based on that, we create initially `layrz_theme` to standarize the design of our applications, but we think that it could be useful for other developers, so we decided to share it with the community. @@ -44,20 +64,6 @@ If you need more assistance, you open an issue on the [Repository](https://githu This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -This project is maintained by [Golden M](https://goldenm.com) with authorization of [Layrz LTD](https://layrz.com). - ## Who are you? / Want to work with us? **Golden M** is a software and hardware development company what is working on a new, innovative and disruptive technologies. For more information, contact us at [sales@goldenm.com](mailto:sales@goldenm.com) or via WhatsApp at [+(507)-6979-3073](https://wa.me/50769793073?text="From%20layrz_theme%20flutter%20library.%20Hello"). - - - -test: - - Add widget tests for ResponsiveRow and ResponsiveCol - - Add comprehensive tests covering rendering, spacing, alignment, builder functionality, and integration between ResponsiveRow and ResponsiveCol. - -docs: - - Add ResponsiveRow and ResponsiveCol skill documentation - - Document parameter reference, breakpoint logic, responsive behavior examples, gotchas, best practices, and common layout patterns. - - Co-authored-by: Claude AI \ No newline at end of file diff --git a/example/pubspec.lock b/example/pubspec.lock index 1adc9f3..e8c5be6 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -390,7 +390,7 @@ packages: path: ".." relative: true source: path - version: "7.5.26" + version: "7.5.27" leak_tracker: dependency: transitive description: diff --git a/lib/src/inputs/src/pickers/general/color.dart b/lib/src/inputs/src/pickers/general/color.dart index 8df759a..c5d2a2d 100644 --- a/lib/src/inputs/src/pickers/general/color.dart +++ b/lib/src/inputs/src/pickers/general/color.dart @@ -9,13 +9,13 @@ class ThemedColorPicker extends StatefulWidget { /// Avoid submit [label] and [labelText] at the same time. We priorize [label] over [labelText]. final Widget? label; - /// [disabled] is the state of the input being disabled. + /// [onChanged] is the callback function when the input is changed. final void Function(Color)? onChanged; - /// [onChanged] is the callback function when the input is changed. + /// [value] is the value of the input. final Color? value; - /// [value] is the value of the input. + /// [disabled] is the state of the input being disabled. final bool disabled; /// [errors] is the list of errors of the input. @@ -30,9 +30,6 @@ class ThemedColorPicker extends StatefulWidget { /// [dense] is the state of the input being dense. final bool dense; - /// [prefixIcon] is the prefix icon of the input. - final IconData? prefixIcon; - /// [onPrefixTap] is the callback function when the prefix is tapped. final VoidCallback? onPrefixTap; @@ -87,7 +84,6 @@ class ThemedColorPicker extends StatefulWidget { this.hideDetails = false, this.padding, this.dense = false, - this.prefixIcon, this.onPrefixTap, this.placeholder, this.saveText = "OK", @@ -100,7 +96,7 @@ class ThemedColorPicker extends StatefulWidget { this.highlightColor = Colors.transparent, this.borderRadius = const .all(.circular(10)), this.maxWidth = 400, - }) : assert((label == null && labelText != null) || (label != null && labelText == null)); + }) : assert((label == null) != (labelText == null), 'Provide exactly one of label or labelText'); @override State createState() => _ThemedColorPickerState(); @@ -115,7 +111,22 @@ class _ThemedColorPickerState extends State { super.initState(); _value = widget.value ?? kPrimaryColor; - _controller.text = "#${_value.hex}"; + _controller.text = _value.hex; + } + + @override + void didUpdateWidget(ThemedColorPicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _value = widget.value ?? kPrimaryColor; + _controller.text = _value.hex; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); } EdgeInsets get widgetPadding => widget.padding ?? ThemedTextInput.outerPadding; @@ -138,6 +149,7 @@ class _ThemedColorPickerState extends State { return ThemedTextInput( label: widget.label, labelText: widget.labelText, + placeholder: widget.placeholder, prefixWidget: Padding( padding: const .symmetric(vertical: 10, horizontal: 15), child: SizedBox( @@ -156,16 +168,16 @@ class _ThemedColorPickerState extends State { suffixIcon: LayrzIcons.solarOutlinePalette2, disabled: widget.disabled, onTap: widget.disabled ? null : _showPicker, - dense: widget.dense, + dense: isDense, errors: widget.errors, hideDetails: widget.hideDetails, controller: _controller, readonly: true, - padding: widget.padding, + padding: widgetPadding, ); } - void _showPicker() async { + Future _showPicker() async { Color? value = await showDialog( context: context, builder: (context) { @@ -188,7 +200,7 @@ class _ThemedColorPickerState extends State { pasteButton: true, ), actionButtons: ColorPickerActionButtons( - dialogActionButtons: true, + dialogActionButtons: false, dialogActionIcons: false, dialogOkButtonType: .outlined, dialogCancelButtonType: .text, @@ -241,6 +253,6 @@ class _ThemedColorPickerState extends State { if (value == null) return; setState(() => _value = value); widget.onChanged?.call(value); - _controller.text = "#${_value.hex}"; + _controller.text = _value.hex; } } diff --git a/pubspec.yaml b/pubspec.yaml index de96384..300bd45 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: layrz_theme description: Layrz standard styling library for Flutter. Widget library following the Material Design 3 guidelines, with a focus on reliavility and functionality. -version: "7.5.26" +version: "7.5.27" homepage: https://theme.layrz.com repository: https://github.com/goldenm-software/layrz_theme diff --git a/test/widgets/color_picker_test.dart b/test/widgets/color_picker_test.dart new file mode 100644 index 0000000..e941235 --- /dev/null +++ b/test/widgets/color_picker_test.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:layrz_theme/layrz_theme.dart'; + +Widget _buildPicker({ + Color? value, + void Function(Color)? onChanged, + bool disabled = false, + String labelText = 'Color', +}) { + return MaterialApp( + home: Scaffold( + body: ThemedColorPicker( + labelText: labelText, + value: value, + disabled: disabled, + onChanged: onChanged, + ), + ), + ); +} + +void main() { + group('ThemedColorPicker', () { + // ───────────────────────────────────────────────────────────── + // RENDERING + // ───────────────────────────────────────────────────────────── + group('rendering', () { + testWidgets('renders without crashing', (tester) async { + await tester.pumpWidget(_buildPicker(value: const Color(0xFFFF5252))); + await tester.pumpAndSettle(); + expect(find.byType(ThemedColorPicker), findsOneWidget); + }); + + testWidgets('shows label text', (tester) async { + await tester.pumpWidget(_buildPicker(labelText: 'Background')); + await tester.pumpAndSettle(); + expect(find.text('Background'), findsOneWidget); + }); + + testWidgets('shows hex value in text field', (tester) async { + await tester.pumpWidget(_buildPicker(value: const Color(0xFFFF5252))); + await tester.pumpAndSettle(); + expect(find.text('#FF5252'), findsOneWidget); + }); + + testWidgets('shows kPrimaryColor hex when value is null', (tester) async { + await tester.pumpWidget(_buildPicker(value: null)); + await tester.pumpAndSettle(); + expect(find.text(kPrimaryColor.hex), findsOneWidget); + }); + + testWidgets('shows color swatch in prefix', (tester) async { + await tester.pumpWidget(_buildPicker(value: const Color(0xFF001E60))); + await tester.pumpAndSettle(); + final container = tester.widget( + find + .descendant( + of: find.byType(ThemedColorPicker), + matching: find.byType(Container), + ) + .first, + ); + final decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, equals(const Color(0xFF001E60))); + }); + }); + + // ───────────────────────────────────────────────────────────── + // DISABLED + // ───────────────────────────────────────────────────────────── + group('disabled', () { + testWidgets('disabled picker does not open dialog on tap', (tester) async { + await tester.pumpWidget(_buildPicker(value: const Color(0xFFFF5252), disabled: true)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ThemedColorPicker)); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('enabled picker opens dialog on tap', (tester) async { + await tester.pumpWidget(_buildPicker(value: const Color(0xFFFF5252))); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsOneWidget); + }); + }); + + // ───────────────────────────────────────────────────────────── + // LIFECYCLE — didUpdateWidget & dispose + // ───────────────────────────────────────────────────────────── + group('lifecycle', () { + testWidgets('mounts and unmounts without errors', (tester) async { + await tester.pumpWidget(_buildPicker(value: const Color(0xFFFF5252))); + await tester.pumpAndSettle(); + + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('repeated mount/unmount cycles do not throw', (tester) async { + for (int i = 0; i < 5; i++) { + await tester.pumpWidget(_buildPicker(value: const Color(0xFFFF5252))); + await tester.pumpAndSettle(); + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('didUpdateWidget updates text field when value changes externally', (tester) async { + // Regression: before adding didUpdateWidget, the controller was never updated + // when the parent passed a new value prop. + Color current = const Color(0xFFFF5252); + late StateSetter externalSetState; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + externalSetState = setState; + return ThemedColorPicker( + labelText: 'Color', + value: current, + onChanged: (v) => setState(() => current = v), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('#FF5252'), findsOneWidget); + + externalSetState(() => current = const Color(0xFF001E60)); + await tester.pumpAndSettle(); + + expect(find.text(const Color(0xFF001E60).hex), findsOneWidget); + expect(find.text('#FF5252'), findsNothing); + }); + + testWidgets('didUpdateWidget resets to kPrimaryColor when value changes to null', (tester) async { + Color? current = const Color(0xFFFF5252); + late StateSetter externalSetState; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + externalSetState = setState; + return ThemedColorPicker( + labelText: 'Color', + value: current, + onChanged: (v) => setState(() => current = v), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + externalSetState(() => current = null); + await tester.pumpAndSettle(); + + expect(find.text(kPrimaryColor.hex), findsOneWidget); + }); + + testWidgets('didUpdateWidget does not rebuild when same value is passed', (tester) async { + const color = Color(0xFFFF5252); + Color current = color; + late StateSetter externalSetState; + int buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + externalSetState = setState; + buildCount++; + return ThemedColorPicker( + labelText: 'Color', + value: current, + onChanged: (_) {}, + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final countAfterMount = buildCount; + + // Pass the exact same color — no change expected in controller + externalSetState(() => current = color); + await tester.pumpAndSettle(); + + // Text field should still show the same hex, no crash + expect(find.text('#FF5252'), findsOneWidget); + // buildCount increased (StatefulBuilder rebuilt) but no exception + expect(buildCount, greaterThan(countAfterMount)); + expect(tester.takeException(), isNull); + }); + }); + }); +}