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

View file

@ -61,6 +61,50 @@ class FaiRadius {
static const md = 10.0; static const md = 10.0;
} }
/// Elevation tokens. Subtle, Linear/Raycast-style depth never
/// heavy drop-shadows. Two resting levels plus a hover bump.
/// Brightness-aware: dark themes lean on deeper, lower-alpha
/// shadows (they read as ambient occlusion against near-black
/// surfaces); light themes use softer, slightly larger spreads.
class FaiElevation {
FaiElevation._();
/// Resting elevation for cards and sheets. Just enough to lift
/// a surface off the canvas without announcing itself.
static List<BoxShadow> low(Brightness b) => b == Brightness.dark
? const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 6,
offset: Offset(0, 2),
),
]
: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 8,
offset: Offset(0, 2),
),
];
/// Raised elevation for hover-lifted cards and floating sheets.
static List<BoxShadow> medium(Brightness b) => b == Brightness.dark
? const [
BoxShadow(
color: Color(0x4D000000),
blurRadius: 16,
offset: Offset(0, 6),
),
]
: const [
BoxShadow(
color: Color(0x1F000000),
blurRadius: 20,
offset: Offset(0, 8),
),
];
}
/// Animation durations. Keep them under 300ms for power-user feel. /// Animation durations. Keep them under 300ms for power-user feel.
class FaiMotion { class FaiMotion {
FaiMotion._(); FaiMotion._();

View file

@ -1,9 +1,17 @@
// FaiEmptyState gracious empty / loading / error placeholder. // FaiEmptyState gracious empty / loading / error placeholder.
// Replaces "(no data)" strings with one tone, one icon, one tip. // Replaces "(no data)" strings with one tone, one icon, one tip.
//
// The blank surface is where an app most easily feels unfinished,
// so this state is deliberately *designed*: the FI delta ()
// sits as a soft watermark behind the icon badge, the badge
// carries a whisper of elevation, and the copy breathes. The
// result reads "nothing here yet, on purpose" rather than
// "something is broken".
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import 'fai_delta_mark.dart';
class FaiEmptyState extends StatelessWidget { class FaiEmptyState extends StatelessWidget {
final IconData icon; final IconData icon;
@ -30,40 +38,67 @@ class FaiEmptyState extends StatelessWidget {
final color = iconColor ?? theme.colorScheme.onSurfaceVariant; final color = iconColor ?? theme.colorScheme.onSurfaceVariant;
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420), constraints: const BoxConstraints(maxWidth: 440),
child: Padding( child: Padding(
padding: const EdgeInsets.all(FaiSpace.xxl), padding: const EdgeInsets.all(FaiSpace.xxl),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( // Brand hero: an oversized, low-opacity watermark
width: 56, // anchors the composition while the contextual icon
height: 56, // badge sits crisply on top so every empty surface
decoration: BoxDecoration( // still carries the FI signature, never a bare icon.
color: color.withValues(alpha: 0.1), SizedBox(
shape: BoxShape.circle, width: 112,
height: 112,
child: Stack(
alignment: Alignment.center,
children: [
Opacity(
opacity: 0.10,
child: FaiDeltaMark(
color: theme.colorScheme.primary,
size: 112,
),
),
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
shape: BoxShape.circle,
border: Border.all(
color: color.withValues(alpha: 0.18),
),
boxShadow: FaiElevation.low(theme.brightness),
),
child: Icon(icon, size: 26, color: color),
),
],
), ),
child: Icon(icon, size: 24, color: color),
), ),
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.xl),
Text( Text(
title, title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
), ),
if (hint != null) ...[ if (hint != null) ...[
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Text( Text(
hint!, hint!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
), ),
), ),
], ],
if (action != null) ...[ if (action != null) ...[
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.xl),
action!, action!,
], ],
], ],

View file

@ -26,6 +26,7 @@ class FaiModuleSheet extends StatefulWidget {
context: context, context: context,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer, backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
isScrollControlled: true, isScrollControlled: true,
elevation: 8,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
), ),