feat(studio): editorial Today hero on the store (v0.30.0)
App-store research shows that the most loved discovery surfaces — Apple's Today tab being the canonical example — work because editors tell stories instead of stacking algorithmic recommendations. F∆I has no surveillance budget and no monetization pressure, so we lean fully into editorial curation. This commit adds `_StoreTodayHero`, the first surface a browsing operator sees: - Single curated story per release, kept const in [_kCurrentTodayStory] so the narrative is reviewable in code review and ships in the audit log via the binary hash. No CMS, no network, no surveillance. - Bilingual content shipped inline (`titleEn`/`titleDe`, `bodyEn`/`bodyDe`) so KRITIS deployments don't need a translation backend. - Gradient backdrop with hero icon, deck, narrative paragraph, optional CTA. Visual treatment matches Apple Today's hierarchy: badge → headline → body → action. - Auto-hides whenever a filter is active so a purposeful search isn't pushed below the fold. - Per-session dismiss button — no permanent suppression, the next Studio launch shows it again so a release-bumped story has a chance to be seen. Current story (Studio v0.30.x) directs operators to the new recommended-sources strip, closing the loop between editorial context and one-click action. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
581ebbeaf7
commit
fe933a713f
8 changed files with 273 additions and 2 deletions
|
|
@ -118,6 +118,9 @@
|
|||
"placeholders": { "n": { "type": "int" } }
|
||||
},
|
||||
"storeReloadTooltip": "Aktualisieren",
|
||||
"storeTodayBadge": "HEUTE",
|
||||
"storeTodayDeck": "Von der F∆I-Redaktion",
|
||||
"storeTodayDismissTooltip": "Für diese Sitzung ausblenden",
|
||||
"storePillInstalled": "installiert",
|
||||
"storePillUpdate": "Update",
|
||||
"storeUpdateButton": "Update auf {version}",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"placeholders": { "n": { "type": "int" } }
|
||||
},
|
||||
"storeReloadTooltip": "Reload",
|
||||
"storeTodayBadge": "TODAY",
|
||||
"storeTodayDeck": "From the F∆I editor",
|
||||
"storeTodayDismissTooltip": "Hide for this session",
|
||||
"storePillInstalled": "installed",
|
||||
"storePillUpdate": "update",
|
||||
"storeUpdateButton": "Update to {version}",
|
||||
|
|
|
|||
|
|
@ -692,6 +692,24 @@ abstract class AppLocalizations {
|
|||
/// **'Reload'**
|
||||
String get storeReloadTooltip;
|
||||
|
||||
/// No description provided for @storeTodayBadge.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TODAY'**
|
||||
String get storeTodayBadge;
|
||||
|
||||
/// No description provided for @storeTodayDeck.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From the F∆I editor'**
|
||||
String get storeTodayDeck;
|
||||
|
||||
/// No description provided for @storeTodayDismissTooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide for this session'**
|
||||
String get storeTodayDismissTooltip;
|
||||
|
||||
/// No description provided for @storePillInstalled.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -345,6 +345,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get storeReloadTooltip => 'Aktualisieren';
|
||||
|
||||
@override
|
||||
String get storeTodayBadge => 'HEUTE';
|
||||
|
||||
@override
|
||||
String get storeTodayDeck => 'Von der F∆I-Redaktion';
|
||||
|
||||
@override
|
||||
String get storeTodayDismissTooltip => 'Für diese Sitzung ausblenden';
|
||||
|
||||
@override
|
||||
String get storePillInstalled => 'installiert';
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get storeReloadTooltip => 'Reload';
|
||||
|
||||
@override
|
||||
String get storeTodayBadge => 'TODAY';
|
||||
|
||||
@override
|
||||
String get storeTodayDeck => 'From the F∆I editor';
|
||||
|
||||
@override
|
||||
String get storeTodayDismissTooltip => 'Hide for this session';
|
||||
|
||||
@override
|
||||
String get storePillInstalled => 'installed';
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
|
|||
/// Studio's own build version. Bump on every UI commit so the
|
||||
/// running app self-identifies — visible in the sidebar header
|
||||
/// and quick-glance proof that you're seeing the current build.
|
||||
const String kStudioVersion = '0.29.0';
|
||||
const String kStudioVersion = '0.30.0';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ class _StorePageState extends State<StorePage> {
|
|||
/// Tracks per-recommended-source add buttons to disable them
|
||||
/// while the request is in flight.
|
||||
final Set<String> _addingSources = <String>{};
|
||||
/// Whether the operator dismissed the editorial hero this
|
||||
/// session. Not persisted — the next Studio launch shows it
|
||||
/// again so a release-bumped story has a chance to be seen.
|
||||
bool _todayDismissed = false;
|
||||
late Future<List<StoreItem>> _future;
|
||||
|
||||
/// Active UI locale, read from the app-wide MaterialApp
|
||||
|
|
@ -247,9 +251,30 @@ class _StorePageState extends State<StorePage> {
|
|||
_source.isEmpty &&
|
||||
!_installedOnly &&
|
||||
!raw.any((e) => e.isFederated);
|
||||
// Editorial hero is browsing-only chrome —
|
||||
// hides as soon as any filter is set so the
|
||||
// operator's actual query stays in focus.
|
||||
final showToday = !_todayDismissed &&
|
||||
_queryCtrl.text.trim().isEmpty &&
|
||||
_category.isEmpty &&
|
||||
_status.isEmpty &&
|
||||
_source.isEmpty &&
|
||||
!_installedOnly;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showToday) ...[
|
||||
_StoreTodayHero(
|
||||
story: _kCurrentTodayStory,
|
||||
locale: _locale,
|
||||
onDismiss: () =>
|
||||
setState(() => _todayDismissed = true),
|
||||
onCta: _kCurrentTodayStory.cta == _TodayCta.openSettings
|
||||
? () => FaiSettingsDialog.show(context)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
],
|
||||
if (showRecommendedStrip) ...[
|
||||
_RecommendedSourcesStrip(
|
||||
adding: _addingSources,
|
||||
|
|
@ -1909,6 +1934,210 @@ class _ScreenshotPlaceholder extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Editorial action attached to a `_TodayStory`. Kept as an
|
||||
/// enum (not a closure) so the story list stays `const` and
|
||||
/// thus shippable as a frozen, audit-friendly artefact.
|
||||
enum _TodayCta { openSettings }
|
||||
|
||||
/// Editorial story shown in the Today-hero strip. One story
|
||||
/// is curated per release in [_kCurrentTodayStory] — operators
|
||||
/// who launch Studio see the same narrative until the next
|
||||
/// version bump. Bilingual content is shipped inline so KRITIS
|
||||
/// deployments don't need to pull translations from a CMS.
|
||||
class _TodayStory {
|
||||
final String badgeEn;
|
||||
final String badgeDe;
|
||||
final String titleEn;
|
||||
final String titleDe;
|
||||
final String bodyEn;
|
||||
final String bodyDe;
|
||||
final String ctaLabelEn;
|
||||
final String ctaLabelDe;
|
||||
final IconData icon;
|
||||
final _TodayCta cta;
|
||||
|
||||
const _TodayStory({
|
||||
required this.badgeEn,
|
||||
required this.badgeDe,
|
||||
required this.titleEn,
|
||||
required this.titleDe,
|
||||
required this.bodyEn,
|
||||
required this.bodyDe,
|
||||
required this.ctaLabelEn,
|
||||
required this.ctaLabelDe,
|
||||
required this.icon,
|
||||
required this.cta,
|
||||
});
|
||||
}
|
||||
|
||||
/// The story shown by Studio v0.30.x. Update when the
|
||||
/// release-narrative changes — this is the editorial role of
|
||||
/// the App Store Today tab, kept lightweight: one story, one
|
||||
/// CTA, refreshed per release.
|
||||
const _TodayStory _kCurrentTodayStory = _TodayStory(
|
||||
badgeEn: 'CAPABILITY FEDERATION',
|
||||
badgeDe: 'CAPABILITY-FEDERATION',
|
||||
titleEn: 'Two MCP servers away from a richer hub',
|
||||
titleDe: 'Zwei MCP-Server bis zu einem reicheren Hub',
|
||||
bodyEn:
|
||||
'The store ships with bundled native modules and curated planned bridges. Click a recommended public source below — DeepWiki for repository docs, Semgrep for security scans — and synthetic `mcp.<server>.<tool>` capabilities appear here within seconds. No Node, no API key, no subprocess.',
|
||||
bodyDe:
|
||||
'Der Store bringt native Module und kuratierte Bridge-Vorschläge mit. Klicke unten auf eine öffentliche Quelle — DeepWiki für Repo-Doku, Semgrep für Security-Scans — und synthetische `mcp.<server>.<tool>`-Capabilities erscheinen binnen Sekunden hier. Kein Node, kein API-Key, kein Subprozess.',
|
||||
ctaLabelEn: 'Open MCP settings',
|
||||
ctaLabelDe: 'MCP-Einstellungen öffnen',
|
||||
icon: Icons.hub_outlined,
|
||||
cta: _TodayCta.openSettings,
|
||||
);
|
||||
|
||||
/// Editorial hero strip — the first surface a browsing operator
|
||||
/// sees. Plays the role of Apple's Today tab: one curated
|
||||
/// story, gradient backdrop, narrative copy over feature
|
||||
/// bullets. Auto-hides whenever a filter is active so a
|
||||
/// purposeful search isn't pushed below the fold.
|
||||
class _StoreTodayHero extends StatelessWidget {
|
||||
final _TodayStory story;
|
||||
final String locale;
|
||||
final VoidCallback onDismiss;
|
||||
/// CTA action — null hides the button. Wired by the parent
|
||||
/// because navigation targets live in the store-page state,
|
||||
/// not in const story data.
|
||||
final VoidCallback? onCta;
|
||||
|
||||
const _StoreTodayHero({
|
||||
required this.story,
|
||||
required this.locale,
|
||||
required this.onDismiss,
|
||||
required this.onCta,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final isDe = locale == 'de';
|
||||
final badge = isDe ? story.badgeDe : story.badgeEn;
|
||||
final title = isDe ? story.titleDe : story.titleEn;
|
||||
final body = isDe ? story.bodyDe : story.bodyEn;
|
||||
final ctaLabel = isDe ? story.ctaLabelDe : story.ctaLabelEn;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
FaiSpace.xl,
|
||||
FaiSpace.lg,
|
||||
FaiSpace.md,
|
||||
FaiSpace.lg,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
theme.colorScheme.primary.withValues(alpha: 0.16),
|
||||
theme.colorScheme.primary.withValues(alpha: 0.04),
|
||||
theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
|
||||
],
|
||||
stops: const [0.0, 0.55, 1.0],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.14),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
story.icon,
|
||||
size: 24,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.lg),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FaiPill(
|
||||
label: l.storeTodayBadge,
|
||||
tone: FaiPillTone.accent,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
badge,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
'·',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
l.storeTodayDeck,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
body,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
if (onCta != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCta,
|
||||
icon: const Icon(Icons.arrow_forward, size: 14),
|
||||
label: Text(ctaLabel),
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: l.storeTodayDismissTooltip,
|
||||
onPressed: onDismiss,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Curated public MCP server that the operator can register
|
||||
/// with one click — no Node, no API key, no subprocess. Fields
|
||||
/// are kept const so the strip can be a `const` widget tree.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: fai_studio
|
||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||
publish_to: 'none'
|
||||
version: 0.29.0
|
||||
version: 0.30.0
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0-200.1.beta
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue