feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
Some checks failed
Security / Security check (push) Failing after 1s

Settings dialog's Theme Plugin section is now a grid of
swatched tiles:

- Built-in (none) — falls back to FaiTheme.light/.dark
- One tile per installed studio.theme.* plugin, each
  showing the plugin's primary/secondary/tertiary as
  live colour dots. Tile loads its preview lazily so a
  dozen installed themes don't block the picker.
- Custom — opens a colour-picker dialog with 12 curated
  Material presets + a hex input + live preview. Selecting
  applies ColorScheme.fromSeed for both brightnesses.

main.dart's _pluginThemes parses a 'custom:#RRGGBB' sigil
in the same notifier slot as plugin capability ids, so the
existing persistence + restoration paths cover the custom
case with no new state.

Bumps editor to 0.11.0 (type-checked port connections +
dynamic card width fix + card-height border allowance) and
Studio to 0.58.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-02 01:29:34 +02:00
parent 69864da934
commit c1c60d5434
30 changed files with 1280 additions and 846 deletions

View file

@ -21,12 +21,15 @@ class FaiSearchHit {
final String label;
final String hint;
final IconData icon;
/// Free-form group used for the section header in the
/// results list ("Pages", "Modules", "Store", "Flows").
final String group;
/// Lowercase haystack name + hint + group. Filtered against
/// the lowercased query.
final String haystack;
/// Action to run when this hit is selected.
final VoidCallback onSelect;
@ -103,60 +106,67 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
final hits = <FaiSearchHit>[];
for (final m in modules) {
hits.add(FaiSearchHit(
label: m.name,
hint: l.searchInstalledModuleHint(m.version),
icon: Icons.extension,
group: l.searchGroupModules,
onSelect: () {
Navigator.pop(context);
FaiModuleSheet.show(context, m.name);
},
));
hits.add(
FaiSearchHit(
label: m.name,
hint: l.searchInstalledModuleHint(m.version),
icon: Icons.extension,
group: l.searchGroupModules,
onSelect: () {
Navigator.pop(context);
FaiModuleSheet.show(context, m.name);
},
),
);
}
final storeGroup = l.searchGroupStore;
final pagesGroup = l.searchGroupPages;
for (final s in store) {
hits.add(FaiSearchHit(
label: s.name,
hint: s.taglineEn.isEmpty
? l.searchStoreHintWithCategory(
s.category.isEmpty ? l.searchStoreUncategorized : s.category)
: l.searchStoreHintWithTagline(s.taglineEn),
icon: Icons.storefront_outlined,
group: storeGroup,
// Selecting a store hit just closes the palette and
// focuses the Store tab; deep-linking to the detail
// sheet would require thread-through plumbing we don't
// have yet.
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == storeGroup && h.group == pagesGroup) {
h.onSelect();
return;
hits.add(
FaiSearchHit(
label: s.name,
hint: s.taglineEn.isEmpty
? l.searchStoreHintWithCategory(
s.category.isEmpty ? l.searchStoreUncategorized : s.category,
)
: l.searchStoreHintWithTagline(s.taglineEn),
icon: Icons.storefront_outlined,
group: storeGroup,
// Selecting a store hit just closes the palette and
// focuses the Store tab; deep-linking to the detail
// sheet would require thread-through plumbing we don't
// have yet.
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == storeGroup && h.group == pagesGroup) {
h.onSelect();
return;
}
}
}
},
));
},
),
);
}
final flowsGroup = l.searchGroupFlows;
for (final f in flows) {
hits.add(FaiSearchHit(
label: f.name,
hint: l.searchSavedFlowHint,
icon: Icons.account_tree_outlined,
group: flowsGroup,
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == flowsGroup && h.group == pagesGroup) {
h.onSelect();
return;
hits.add(
FaiSearchHit(
label: f.name,
hint: l.searchSavedFlowHint,
icon: Icons.account_tree_outlined,
group: flowsGroup,
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == flowsGroup && h.group == pagesGroup) {
h.onSelect();
return;
}
}
}
},
));
},
),
);
}
return hits;
}
@ -211,9 +221,7 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
return Dialog(
alignment: Alignment.topCenter,
insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 540),
child: FutureBuilder<List<FaiSearchHit>>(
@ -240,17 +248,17 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
controller: _query,
autofocus: true,
decoration: InputDecoration(
prefixIcon:
const Icon(Icons.search, size: 20),
prefixIcon: const Icon(Icons.search, size: 20),
hintText: l.searchHint,
border: InputBorder.none,
suffixIcon: snap.connectionState ==
ConnectionState.waiting
suffixIcon:
snap.connectionState == ConnectionState.waiting
? const SizedBox(
width: 14,
height: 14,
child:
CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: null,
),
@ -283,11 +291,10 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
itemBuilder: (context, i) {
final h = hits[i];
final highlighted = i == _highlight;
final showHeader = i == 0 ||
hits[i - 1].group != h.group;
final showHeader =
i == 0 || hits[i - 1].group != h.group;
return Column(
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showHeader)
Padding(
@ -301,11 +308,12 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
h.group.toUpperCase(),
style: theme.textTheme.labelSmall
?.copyWith(
color: theme
.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
color: theme
.colorScheme
.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
),
InkWell(
@ -317,8 +325,9 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
},
child: Container(
color: highlighted
? theme.colorScheme
.surfaceContainerHigh
? theme
.colorScheme
.surfaceContainerHigh
: null,
padding: const EdgeInsets.symmetric(
horizontal: 16,
@ -340,21 +349,24 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
Text(
h.label,
style: theme
.textTheme.bodyMedium
.textTheme
.bodyMedium
?.copyWith(
fontWeight:
FontWeight.w500,
),
fontWeight:
FontWeight.w500,
),
),
if (h.hint.isNotEmpty)
Text(
h.hint,
style: theme.textTheme
style: theme
.textTheme
.bodySmall
?.copyWith(
color: theme.colorScheme
.onSurfaceVariant,
),
color: theme
.colorScheme
.onSurfaceVariant,
),
maxLines: 1,
overflow:
TextOverflow.ellipsis,
@ -367,7 +379,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
Icons.keyboard_return,
size: 14,
color: theme
.colorScheme.onSurfaceVariant,
.colorScheme
.onSurfaceVariant,
),
],
),