diff --git a/lib/pages/store.dart b/lib/pages/store.dart index e017406..05a85ab 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -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)), ), diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart index 845cedc..dbbc84b 100644 --- a/lib/theme/tokens.dart +++ b/lib/theme/tokens.dart @@ -61,6 +61,50 @@ class FaiRadius { 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 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 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. class FaiMotion { FaiMotion._(); diff --git a/lib/widgets/fai_empty_state.dart b/lib/widgets/fai_empty_state.dart index 10816da..cf393b9 100644 --- a/lib/widgets/fai_empty_state.dart +++ b/lib/widgets/fai_empty_state.dart @@ -1,9 +1,17 @@ // FaiEmptyState — gracious empty / loading / error placeholder. // 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 F∆I 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 '../theme/tokens.dart'; +import 'fai_delta_mark.dart'; class FaiEmptyState extends StatelessWidget { final IconData icon; @@ -30,40 +38,67 @@ class FaiEmptyState extends StatelessWidget { final color = iconColor ?? theme.colorScheme.onSurfaceVariant; return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), + constraints: const BoxConstraints(maxWidth: 440), child: Padding( padding: const EdgeInsets.all(FaiSpace.xxl), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - shape: BoxShape.circle, + // Brand hero: an oversized, low-opacity ∆ watermark + // anchors the composition while the contextual icon + // badge sits crisply on top — so every empty surface + // still carries the F∆I signature, never a bare icon. + SizedBox( + 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( title, textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), if (hint != null) ...[ const SizedBox(height: FaiSpace.sm), Text( hint!, textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( + style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, + height: 1.5, ), ), ], if (action != null) ...[ - const SizedBox(height: FaiSpace.lg), + const SizedBox(height: FaiSpace.xl), action!, ], ], diff --git a/lib/widgets/fai_module_sheet.dart b/lib/widgets/fai_module_sheet.dart index 65a7974..5ae7d36 100644 --- a/lib/widgets/fai_module_sheet.dart +++ b/lib/widgets/fai_module_sheet.dart @@ -26,6 +26,7 @@ class FaiModuleSheet extends StatefulWidget { context: context, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, isScrollControlled: true, + elevation: 8, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), ),