feat(studio): award-tier visual polish

Brightness-aware elevation tokens (FaiElevation low/medium) applied
to cards, sheets and the sidebar active item; gentle hover-lift on
Store + Doc cards; a longer, eased sidebar expand using the
under-used FaiMotion.slow; and a branded empty state carrying the
F∆I delta mark with warmer spacing. Subtle, Linear/Raycast-style —
both light and dark themes tuned.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-11 23:52:31 +02:00
parent 5313266cc4
commit f0a944dce7
4 changed files with 261 additions and 146 deletions

View file

@ -1792,7 +1792,7 @@ class _FeaturedTile extends StatelessWidget {
}
}
class _StoreCard extends StatelessWidget {
class _StoreCard extends StatefulWidget {
final StoreItem item;
final String locale;
@ -1810,6 +1810,19 @@ class _StoreCard extends StatelessWidget {
required this.onInstall,
});
@override
State<_StoreCard> createState() => _StoreCardState();
}
class _StoreCardState extends State<_StoreCard> {
bool _hovered = false;
StoreItem get item => widget.item;
String get locale => widget.locale;
String? get installedVersion => widget.installedVersion;
VoidCallback get onTap => widget.onTap;
VoidCallback get onInstall => widget.onInstall;
/// True iff the operator has this module installed AND the
/// store-index offers a strictly-newer best_version. Uses a
/// dotted-numeric semver-light compare so 0.10.5 > 0.9.99.
@ -1836,146 +1849,167 @@ class _StoreCard extends StatelessWidget {
final notInstallable = !installable && !item.installed;
return Opacity(
opacity: notInstallable ? 0.6 : 1.0,
child: Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
// Hover-lift: a 2px rise plus shadow bump signals the card
// is clickable without a heavy hover fill. Resting state
// carries the low elevation so cards read as physical.
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: FaiMotion.base,
curve: FaiMotion.easing,
transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
boxShadow: _hovered
? FaiElevation.medium(theme.brightness)
: FaiElevation.low(theme.brightness),
),
child: Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
child: Icon(
_iconForCategory(item.category),
size: 18,
color: theme.colorScheme.primary,
),
Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
child: Icon(
_iconForCategory(item.category),
size: 18,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
item.category.isEmpty
? ''
: _categoryDisplayName(
context,
item.category,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (_hasUpdate)
Tooltip(
message: l.storeUpdateTooltip(
installedVersion ?? '?',
item.bestVersion,
),
child: FaiPill(
label: l.storePillUpdate,
tone: FaiPillTone.warning,
icon: Icons.system_update_alt,
),
)
else if (item.installed)
FaiPill(
label: l.storePillInstalled,
tone: FaiPillTone.success,
icon: Icons.check,
)
else if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(context, item.status),
tone: _toneForStatus(item.status),
),
],
),
const SizedBox(width: FaiSpace.sm),
const SizedBox(height: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
item.category.isEmpty
? ''
: _categoryDisplayName(context, item.category),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
child: Text(
tagline.isEmpty ? l.storeNoTagline : tagline,
style: theme.textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (_hasUpdate)
Tooltip(
message: l.storeUpdateTooltip(
installedVersion ?? '?',
item.bestVersion,
),
child: FaiPill(
label: l.storePillUpdate,
tone: FaiPillTone.warning,
icon: Icons.system_update_alt,
),
)
else if (item.installed)
FaiPill(
label: l.storePillInstalled,
tone: FaiPillTone.success,
icon: Icons.check,
)
else if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(context, item.status),
tone: _toneForStatus(item.status),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
if (item.bestVersion.isNotEmpty) ...[
Tooltip(
message: item.isFederated
? l.storeAdvisoryVersionTooltip
: '',
child: FaiPill(
label: item.isFederated
? '~v${item.bestVersion}'
: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
),
const SizedBox(width: FaiSpace.xs),
],
_ProvenancePill(item: item),
const Spacer(),
if (_hasUpdate)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.system_update_alt, size: 14),
label: Text(l.storeUpdateButton(item.bestVersion)),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else if (item.installed)
TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
)
else if (installable)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.download, size: 14),
label: Text(l.buttonInstall),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else
OutlinedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
],
),
const SizedBox(height: FaiSpace.sm),
Expanded(
child: Text(
tagline.isEmpty ? l.storeNoTagline : tagline,
style: theme.textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
if (item.bestVersion.isNotEmpty) ...[
Tooltip(
message: item.isFederated
? l.storeAdvisoryVersionTooltip
: '',
child: FaiPill(
label: item.isFederated
? '~v${item.bestVersion}'
: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
),
const SizedBox(width: FaiSpace.xs),
],
_ProvenancePill(item: item),
const Spacer(),
if (_hasUpdate)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.system_update_alt, size: 14),
label: Text(l.storeUpdateButton(item.bestVersion)),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else if (item.installed)
TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
)
else if (installable)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.download, size: 14),
label: Text(l.buttonInstall),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else
OutlinedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
],
),
),
),
),
@ -2003,6 +2037,7 @@ class _StoreDetailSheet extends StatefulWidget {
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
),