diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..6965136 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "layrz-theme", + "owner": { + "name": "goldenm-software" + }, + "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" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..dd0d731 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "layrz_theme", + "description": "Claude Code skills for layrz_theme Flutter widget library", + "version": "7.5.21" +} diff --git a/.claude-plugin/skills/add-dual-list/SKILL.md b/.claude-plugin/skills/add-dual-list/SKILL.md new file mode 100644 index 0000000..ee83822 --- /dev/null +++ b/.claude-plugin/skills/add-dual-list/SKILL.md @@ -0,0 +1,89 @@ +--- +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 new file mode 100644 index 0000000..91fbddc --- /dev/null +++ b/.claude-plugin/skills/boolean-radio-inputs/SKILL.md @@ -0,0 +1,289 @@ +--- +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/date-pickers/SKILL.md b/.claude-plugin/skills/date-pickers/SKILL.md new file mode 100644 index 0000000..88d5fcf --- /dev/null +++ b/.claude-plugin/skills/date-pickers/SKILL.md @@ -0,0 +1,445 @@ +--- +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 new file mode 100644 index 0000000..a134ca2 --- /dev/null +++ b/.claude-plugin/skills/datetime-pickers/SKILL.md @@ -0,0 +1,389 @@ +--- +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 new file mode 100644 index 0000000..e700e00 --- /dev/null +++ b/.claude-plugin/skills/file-media-pickers/SKILL.md @@ -0,0 +1,377 @@ +--- +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 new file mode 100644 index 0000000..9287e9d --- /dev/null +++ b/.claude-plugin/skills/icon-emoji-avatar-inputs/SKILL.md @@ -0,0 +1,463 @@ +--- +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 new file mode 100644 index 0000000..17a953c --- /dev/null +++ b/.claude-plugin/skills/number-duration-inputs/SKILL.md @@ -0,0 +1,286 @@ +--- +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. `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` | Clamps the decrement button; does not block typed input | +| `maximum` | `num?` | `null` | Clamps the increment button; does not block typed input | +| `step` | `num?` | `1` | Amount added/subtracted by prefix/suffix buttons | +| `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` `NumberFormat`. **Requires** `inputRegExp` when set. | +| `inputRegExp` | `RegExp?` | `null` | Required when `format` is provided; filters raw character input | +| `disabled` | `bool` | `false` | Disables typing and action buttons | +| `hidePrefixSuffixActions` | `bool` | `false` | Hides increment/decrement 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 the widget | +| `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 runtime. +- When `format` is set you **must** also set `inputRegExp`. The widget asserts this at construction time. The default regex `[-0-9\,.]` only applies when no custom format is used. +- `minimum`/`maximum` only gate the **buttons** — they do not restrict keyboard input. Add a validator in your form layer if you need hard limits. +- Cursor position is preserved across reformats: the widget counts thousand-separator characters before and after the edit and adjusts the offset to avoid jumps. +- `onChanged` is called with `null` when the field is cleared, and is **not** called when the user types a bare `-` sign (intermediate state). +- `decimalSeparator: .comma` uses the `pt` locale `NumberFormat` internally, producing `1.234,56` style formatting. +- `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(); + }, +) + +// Custom NumberFormat (requires inputRegExp) +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(); + }, +) + +// Hidden action buttons (display-mode field, still editable via keyboard) +ThemedNumberInput( + labelText: context.i18n.t('entity.offset'), + value: offset, + hidePrefixSuffixActions: true, + onChanged: (value) { + offset = 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; the 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` is called **only when the user taps Save**. Tapping Cancel or dismissing the dialog discards changes. +- The dialog has three action buttons: **Cancel** (discard), **Reset** (zeroes all unit fields, does not close), and **Save** (commits the value and closes). +- The display text is humanized using `Duration.humanize(...)` with the active `LayrzAppLocalizations` locale and the units in `visibleValues`. +- `visibleValues` must be a subset of `kThemedDurationSupported`. Passing an unsupported unit (`year`, `month`, `week`, `millisecond`) throws an assertion at construction. +- When only one unit is visible it occupies full width; when two or more are visible they are laid out in a two-column `ResponsiveRow`. +- If the odd-last unit rule applies (last item at an odd index), it stretches to full width automatically. +- `label` and `labelText` are **mutually exclusive**. + +### Common patterns + +```dart +// Hours and minutes only — useful 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 — useful 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 a custom 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(); + }, +) +``` + +--- + +## Integrating with layrz forms (onChanged + errors pattern) + +### ThemedNumberInput + +```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(); + }, +) +``` + +### ThemedDurationInput + +```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(); + }, +) +``` + +### Stacking inputs + +```dart +ThemedNumberInput( + labelText: context.i18n.t('entity.retries'), + value: object.retries, + errors: context.getErrors(key: 'retries'), + onChanged: (value) { + object.retries = value?.toInt(); + if (context.mounted) onChanged.call(); + }, +), +const SizedBox(height: 10), +ThemedDurationInput( + labelText: context.i18n.t('entity.retryDelay'), + value: object.retryDelay, + errors: context.getErrors(key: 'retryDelay'), + onChanged: (value) { + object.retryDelay = value; + if (context.mounted) onChanged.call(); + }, +), +``` + +--- + +## 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** that is stored as a `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 (e.g. seconds) — it gives the user explicit unit controls and produces a human-readable display automatically. +- When you need to capture only a subset of time units (e.g. hours + minutes for a schedule), pass a filtered `visibleValues` list to `ThemedDurationInput` rather than building a manual multi-field layout with `ThemedNumberInput`. diff --git a/.claude-plugin/skills/select-inputs/SKILL.md b/.claude-plugin/skills/select-inputs/SKILL.md new file mode 100644 index 0000000..6203517 --- /dev/null +++ b/.claude-plugin/skills/select-inputs/SKILL.md @@ -0,0 +1,213 @@ +--- +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 new file mode 100644 index 0000000..62225f4 --- /dev/null +++ b/.claude-plugin/skills/text-inputs/SKILL.md @@ -0,0 +1,395 @@ +--- +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/time-pickers/SKILL.md b/.claude-plugin/skills/time-pickers/SKILL.md new file mode 100644 index 0000000..cf08233 --- /dev/null +++ b/.claude-plugin/skills/time-pickers/SKILL.md @@ -0,0 +1,283 @@ +--- +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/CHANGELOG.md b/CHANGELOG.md index b76aae8..4655d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 7.5.21 +- Add claudio `Skills` + ## 7.5.20 - Fixed `ThemedTable2` header overflow detection by including the sort icon width in the available text width, ensuring tooltips appear correctly. diff --git a/pubspec.yaml b/pubspec.yaml index ec33cda..f48d6c1 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.20" +version: "7.5.21" homepage: https://theme.layrz.com repository: https://github.com/goldenm-software/layrz_theme