From 76942511b965f722b1f281043ba66dbe56b4826a Mon Sep 17 00:00:00 2001 From: Sebastian Heit Date: Thu, 8 Oct 2026 02:38:15 +0200 Subject: [PATCH 01/32] feat(profile): show supporter status as an animated halo and wings Prototype for #9914, gated by --dart-define=SUPPORTER_HALO_PREVIEW=true on the own profile: a halo and two wings positioned from the Figma frame, driven by one repeating controller, paused for reduced motion and when not visible. Co-Authored-By: Claude --- mobile/assets/supporter/halo.png | Bin 0 -> 18953 bytes mobile/assets/supporter/wing_left.png | Bin 0 -> 33428 bytes mobile/assets/supporter/wing_right.png | Bin 0 -> 33902 bytes .../profile/profile_header_identity.dart | 3 +- .../widgets/profile/profile_header_media.dart | 9 +- .../profile/profile_header_widget.dart | 3 + .../widgets/profile/supporter_halo_wings.dart | 306 ++++++++++++++++++ mobile/pubspec.yaml | 1 + 8 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 mobile/assets/supporter/halo.png create mode 100644 mobile/assets/supporter/wing_left.png create mode 100644 mobile/assets/supporter/wing_right.png create mode 100644 mobile/lib/widgets/profile/supporter_halo_wings.dart diff --git a/mobile/assets/supporter/halo.png b/mobile/assets/supporter/halo.png new file mode 100644 index 0000000000000000000000000000000000000000..e0a069db96d74f100c804ce5127421b718e56320 GIT binary patch literal 18953 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

wwCfwR^e#Dv>FjfEx06c%s3qPK$9pnKS}PF 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 SczAmN0000 _showAvatarLightbox( context, @@ -496,6 +500,9 @@ class _ProfileAvatarWithColor extends StatelessWidget { ), ) : avatarWidget; + avatar = showSupporterTreatment + ? SupporterHaloWings(avatarSize: avatarSize, child: tappableAvatar) + : tappableAvatar; } if (pendingActions.isEmpty) return avatar; diff --git a/mobile/lib/widgets/profile/profile_header_widget.dart b/mobile/lib/widgets/profile/profile_header_widget.dart index 8f16cf0d52..d55e098e18 100644 --- a/mobile/lib/widgets/profile/profile_header_widget.dart +++ b/mobile/lib/widgets/profile/profile_header_widget.dart @@ -54,6 +54,7 @@ import 'package:openvine/widgets/profile/profile_following_stat.dart'; import 'package:openvine/widgets/profile/profile_stats_row_widget.dart'; import 'package:openvine/widgets/profile/profile_support_sheet.dart'; import 'package:openvine/widgets/profile/profile_website_row.dart'; +import 'package:openvine/widgets/profile/supporter_halo_wings.dart'; import 'package:openvine/widgets/profile/verified_accounts_row.dart'; import 'package:openvine/widgets/profile_badge_explanation_sheet.dart'; import 'package:openvine/widgets/special_profile_checkmark.dart'; @@ -415,6 +416,8 @@ class _ProfileHeaderWidgetState extends ConsumerState { showSkeleton: showIdentitySkeleton, profileColor: profileColor, pendingActions: pendingActions, + showSupporterTreatment: + supporterHaloPreview && widget.isOwnProfile, onActionTap: pendingActions.isEmpty ? null : pendingActions.first == diff --git a/mobile/lib/widgets/profile/supporter_halo_wings.dart b/mobile/lib/widgets/profile/supporter_halo_wings.dart new file mode 100644 index 0000000000..ec6e00b061 --- /dev/null +++ b/mobile/lib/widgets/profile/supporter_halo_wings.dart @@ -0,0 +1,306 @@ +// ABOUTME: Divine Supporter treatment around the profile avatar: a floating +// ABOUTME: halo and two wings, gently animated by one repeating controller. + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:openvine/extensions/media_query_extensions.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +// TODO(#9914): Prototype switch. Replace with real supporter status before +// this ships; for now `--dart-define=SUPPORTER_HALO_PREVIEW=true` forces the +// treatment on the signed-in user's own profile. +const bool supporterHaloPreview = bool.fromEnvironment( + 'SUPPORTER_HALO_PREVIEW', +); + +/// Wraps [child] (the avatar) with a halo above it and a wing on each side. +/// +/// Geometry comes from the Figma profile frame, where the avatar is 144pt. +/// Every offset is stored as a fraction of that size so the treatment scales +/// with whatever [avatarSize] it is given. +class SupporterHaloWings extends StatefulWidget { + const SupporterHaloWings({ + required this.avatarSize, + required this.child, + super.key, + }); + + final double avatarSize; + final Widget child; + + @override + State createState() => _SupporterHaloWingsState(); +} + +/// Figma geometry, as fractions of the 144pt avatar. +abstract class _Geometry { + static const double _figmaAvatar = 144; + + static const double wingWidth = 46 / _figmaAvatar; + static const double wingHeight = 97 / _figmaAvatar; + static const double wingTop = 14 / _figmaAvatar; + + /// How far each wing reaches past the avatar edge (it tucks 3pt behind it). + static const double wingOutset = 43 / _figmaAvatar; + + static const double haloWidth = 75.76 / _figmaAvatar; + static const double haloHeight = 28 / _figmaAvatar; + static const double haloTop = -37 / _figmaAvatar; +} + +/// Motion amplitudes. Kept small: the avatar should breathe, not flap. +abstract class _Motion { + static const Duration period = Duration(milliseconds: 3000); + static const double haloBobFraction = 3 / 144; + static const double haloTiltRadians = 2.5 * math.pi / 180; + + /// The whole group floats like the halo, but smaller and trailing it, so + /// the halo still reads as hovering over the head rather than fixed to it. + static const double groupBobFraction = 2 / 144; + static const double groupTiltRadians = 0.5 * math.pi / 180; + static const double groupPhaseLag = 0.15; + + /// Wings swing between these angles, always tipping down from the Figma + /// pose, so the inner edge below the pivot stays tucked behind the avatar. + static const double wingLowRadians = 10 * math.pi / 180; + static const double wingHighRadians = 1 * math.pi / 180; + + /// The right wing trails the left by this share of a cycle. + static const double wingPhaseLag = 0.08; +} + +class _SupporterHaloWingsState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: _Motion.period, + ); + late final AppLifecycleListener _lifecycle; + final Key _visibilityKey = UniqueKey(); + bool _visible = true; + + @override + void initState() { + super.initState(); + _lifecycle = AppLifecycleListener(onStateChange: (_) => _sync()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _sync(); + } + + /// Runs the loop only while it can be seen and motion is allowed. + void _sync() { + final appVisible = + WidgetsBinding.instance.lifecycleState != AppLifecycleState.paused && + WidgetsBinding.instance.lifecycleState != AppLifecycleState.hidden; + if (context.reduceMotion) { + if (_controller.isAnimating) _controller.stop(); + _controller.value = 0; + } else if (_visible && appVisible) { + if (!_controller.isAnimating) _controller.repeat(); + } else if (_controller.isAnimating) { + _controller.stop(); + } + } + + void _onVisibilityChanged(VisibilityInfo info) { + if (!mounted) return; + _visible = info.visibleFraction > 0; + _sync(); + } + + @override + void dispose() { + _lifecycle.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = widget.avatarSize; + final outset = size * _Geometry.wingOutset; + final wingWidth = size * _Geometry.wingWidth; + final wingHeight = size * _Geometry.wingHeight; + final wingTop = size * _Geometry.wingTop; + final haloWidth = size * _Geometry.haloWidth; + final haloHeight = size * _Geometry.haloHeight; + + // The box claims the wings' width so nothing beside it can crowd them. + // The halo is left to overflow upward into the empty middle of the nav + // row, matching Figma where it sits above the avatar's own frame. + return VisibilityDetector( + key: _visibilityKey, + onVisibilityChanged: _onVisibilityChanged, + child: _AnimatedPart( + animation: _controller, + builder: (t) => _Group(t: t, avatarSize: size), + // Its own boundary keeps the static avatar from repainting as the + // group moves. + child: RepaintBoundary( + child: SizedBox( + width: size + outset * 2, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + top: wingTop, + width: wingWidth, + height: wingHeight, + child: _AnimatedPart( + animation: _controller, + builder: (t) => _Wing(t: t, isLeft: true), + child: const _PartImage('assets/supporter/wing_left.png'), + ), + ), + Positioned( + right: 0, + top: wingTop, + width: wingWidth, + height: wingHeight, + child: _AnimatedPart( + animation: _controller, + builder: (t) => _Wing(t: t, isLeft: false), + child: const _PartImage('assets/supporter/wing_right.png'), + ), + ), + Positioned( + left: outset, + top: 0, + width: size, + height: size, + child: widget.child, + ), + Positioned( + left: outset + (size - haloWidth) / 2, + top: size * _Geometry.haloTop, + width: haloWidth, + height: haloHeight, + child: _AnimatedPart( + animation: _controller, + builder: (t) => _Halo(t: t, avatarSize: size), + child: const _PartImage('assets/supporter/halo.png'), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// A transform computed from the loop position `t` in [0, 1). +abstract class _PartTransform { + Matrix4 get matrix; + Alignment get origin; +} + +class _Wing implements _PartTransform { + _Wing({required this.t, required this.isLeft}); + + final double t; + final bool isLeft; + + @override + Matrix4 get matrix { + final phase = isLeft ? t : t - _Motion.wingPhaseLag; + const mid = (_Motion.wingLowRadians + _Motion.wingHighRadians) / 2; + const amplitude = (_Motion.wingLowRadians - _Motion.wingHighRadians) / 2; + final drop = mid + math.sin(phase * 2 * math.pi) * amplitude; + // Tipping down swings the inner edge below the pivot into the avatar. + return Matrix4.rotationZ(isLeft ? -drop : drop); + } + + // Pivot on the upper inner edge, like a shoulder joint. + @override + Alignment get origin => + isLeft ? const Alignment(1, -0.2) : const Alignment(-1, -0.2); +} + +class _Halo implements _PartTransform { + _Halo({required this.t, required this.avatarSize}); + + final double t; + final double avatarSize; + + @override + Matrix4 get matrix { + final angle = t * 2 * math.pi; + final bob = -math.sin(angle) * avatarSize * _Motion.haloBobFraction; + final tilt = math.sin(angle + math.pi / 2) * _Motion.haloTiltRadians; + return Matrix4.translationValues(0, bob, 0)..rotateZ(tilt); + } + + @override + Alignment get origin => Alignment.center; +} + +class _Group implements _PartTransform { + _Group({required this.t, required this.avatarSize}); + + final double t; + final double avatarSize; + + @override + Matrix4 get matrix { + final angle = (t - _Motion.groupPhaseLag) * 2 * math.pi; + final bob = -math.sin(angle) * avatarSize * _Motion.groupBobFraction; + final tilt = math.sin(angle + math.pi / 2) * _Motion.groupTiltRadians; + return Matrix4.translationValues(0, bob, 0)..rotateZ(tilt); + } + + @override + Alignment get origin => Alignment.center; +} + +/// Applies a per-frame transform to an image without rebuilding it. +class _AnimatedPart extends StatelessWidget { + const _AnimatedPart({ + required this.animation, + required this.builder, + required this.child, + }); + + final Animation animation; + final _PartTransform Function(double t) builder; + final Widget child; + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: AnimatedBuilder( + animation: animation, + builder: (context, child) { + final part = builder(animation.value); + return Transform( + transform: part.matrix, + alignment: part.origin, + child: child, + ); + }, + child: child, + ), + ); + } +} + +class _PartImage extends StatelessWidget { + const _PartImage(this.asset); + + final String asset; + + @override + Widget build(BuildContext context) { + return ExcludeSemantics( + child: Image.asset(asset, fit: BoxFit.contain), + ); + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 162ca42c2f..b5182a6e47 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -530,6 +530,7 @@ flutter: - assets/seed_media/classic_viner_avatars/jerome-jarre.png - assets/stickers/ - assets/stickers/i18n/ + - assets/supporter/ - shorebird.yaml # Fragment shaders. `chroma_key.frag` powers the video editor's live From a2f6e207685879ef20c26b3c5ea91cc00eac40bd Mon Sep 17 00:00:00 2001 From: Sebastian Heit Date: Thu, 8 Oct 2026 02:46:24 +0200 Subject: [PATCH 02/32] feat(profile): hide supporter halo and wings while the avatar lightbox is open They used to stay behind on the profile while only the avatar flew to the lightbox. They now fade and draw in toward the avatar as it takes off and return as it lands, following the lightbox route animation so an interrupted or swiped-back close stays in step. Refs #9914. Co-Authored-By: Claude --- .../widgets/profile/profile_header_media.dart | 77 ++++++----- .../widgets/profile/supporter_halo_wings.dart | 130 +++++++++++++++--- 2 files changed, 160 insertions(+), 47 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_media.dart b/mobile/lib/widgets/profile/profile_header_media.dart index 01d0efa2d4..3d14a9d853 100644 --- a/mobile/lib/widgets/profile/profile_header_media.dart +++ b/mobile/lib/widgets/profile/profile_header_media.dart @@ -482,27 +482,35 @@ class _ProfileAvatarWithColor extends StatelessWidget { size: avatarSize, ); final hasAvatar = imageUrl != null && imageUrl!.isNotEmpty; - final tappableAvatar = hasAvatar - ? GestureDetector( - onTap: () => _showAvatarLightbox( - context, - imageUrl: imageUrl, - userIdHex: userIdHex, - ), - child: Hero( - tag: _avatarHeroTag(userIdHex), - flightShuttleBuilder: (_, _, _, _, _) => - _AvatarHeroFlightShuttle( - imageUrl: imageUrl, - userIdHex: userIdHex, - ), - child: avatarWidget, - ), - ) - : avatarWidget; + Widget tappableAvatar({void Function(Animation)? onOpened}) { + if (!hasAvatar) return avatarWidget; + return GestureDetector( + onTap: () { + final transition = _showAvatarLightbox( + context, + imageUrl: imageUrl, + userIdHex: userIdHex, + ); + onOpened?.call(transition); + }, + child: Hero( + tag: _avatarHeroTag(userIdHex), + flightShuttleBuilder: (_, _, _, _, _) => _AvatarHeroFlightShuttle( + imageUrl: imageUrl, + userIdHex: userIdHex, + ), + child: avatarWidget, + ), + ); + } + avatar = showSupporterTreatment - ? SupporterHaloWings(avatarSize: avatarSize, child: tappableAvatar) - : tappableAvatar; + ? SupporterHaloWings( + avatarSize: avatarSize, + builder: (context, trackCover) => + tappableAvatar(onOpened: trackCover), + ) + : tappableAvatar(); } if (pendingActions.isEmpty) return avatar; @@ -703,7 +711,9 @@ class _AvatarHeroFlightShuttle extends StatelessWidget { } } -void _showAvatarLightbox( +/// Opens the lightbox and returns its route animation, so decorations around +/// the header avatar can follow the transition both ways. +Animation _showAvatarLightbox( BuildContext context, { required String userIdHex, String? imageUrl, @@ -716,18 +726,23 @@ void _showAvatarLightbox( // from the current PageRoute of nested navigators, so the header avatar // (branch) and the lightbox (root) are matched. A PageRoute — not a // PopupRoute like showGeneralDialog — is required for the HeroController. - Navigator.of(context, rootNavigator: true).push( - PageRouteBuilder( - opaque: false, - barrierColor: VineTheme.transparent, - barrierDismissible: true, - barrierLabel: context.l10n.profileAvatarLightboxBarrierLabel, - pageBuilder: (context, _, _) => - _AvatarLightbox(imageUrl: imageUrl, userIdHex: userIdHex), - transitionsBuilder: (context, animation, _, child) => - FadeTransition(opacity: animation, child: child), + final route = PageRouteBuilder( + opaque: false, + barrierColor: VineTheme.transparent, + barrierDismissible: true, + barrierLabel: context.l10n.profileAvatarLightboxBarrierLabel, + pageBuilder: (context, _, _) => + _AvatarLightbox(imageUrl: imageUrl, userIdHex: userIdHex), + transitionsBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ); + _runProfileDetached( + _awaitProfileResult( + Navigator.of(context, rootNavigator: true).push(route), ), + 'open avatar lightbox', ); + return route.animation!; } class _AvatarLightbox extends StatelessWidget { diff --git a/mobile/lib/widgets/profile/supporter_halo_wings.dart b/mobile/lib/widgets/profile/supporter_halo_wings.dart index ec6e00b061..80409d46bb 100644 --- a/mobile/lib/widgets/profile/supporter_halo_wings.dart +++ b/mobile/lib/widgets/profile/supporter_halo_wings.dart @@ -14,7 +14,12 @@ const bool supporterHaloPreview = bool.fromEnvironment( 'SUPPORTER_HALO_PREVIEW', ); -/// Wraps [child] (the avatar) with a halo above it and a wing on each side. +/// Called with the animation of a route that lifts the avatar away, such as +/// the avatar lightbox, so the halo and wings can follow it out and back. +typedef TrackCoveringTransition = void Function(Animation transition); + +/// Wraps the avatar from [builder] with a halo above it and a wing on each +/// side. /// /// Geometry comes from the Figma profile frame, where the avatar is 144pt. /// Every offset is stored as a fraction of that size so the treatment scales @@ -22,12 +27,16 @@ const bool supporterHaloPreview = bool.fromEnvironment( class SupporterHaloWings extends StatefulWidget { const SupporterHaloWings({ required this.avatarSize, - required this.child, + required this.builder, super.key, }); final double avatarSize; - final Widget child; + + /// Builds the avatar. Call the given callback with the route animation when + /// the avatar opens something that flies it away. + final Widget Function(BuildContext context, TrackCoveringTransition track) + builder; @override State createState() => _SupporterHaloWingsState(); @@ -80,6 +89,22 @@ class _SupporterHaloWingsState extends State final Key _visibilityKey = UniqueKey(); bool _visible = true; + /// The lightbox transition while it is open or closing, else null. + Animation? _cover; + + void _trackCover(Animation transition) { + void onStatus(AnimationStatus status) { + if (status != AnimationStatus.dismissed) return; + transition.removeStatusListener(onStatus); + if (mounted && identical(_cover, transition)) { + setState(() => _cover = null); + } + } + + transition.addStatusListener(onStatus); + setState(() => _cover = transition); + } + @override void initState() { super.initState(); @@ -153,10 +178,15 @@ class _SupporterHaloWingsState extends State top: wingTop, width: wingWidth, height: wingHeight, - child: _AnimatedPart( - animation: _controller, - builder: (t) => _Wing(t: t, isLeft: true), - child: const _PartImage('assets/supporter/wing_left.png'), + child: _CoverFade( + cover: _cover, + // The avatar's centre, in this wing's own coordinates. + origin: Offset(outset + size / 2, size / 2 - wingTop), + child: _AnimatedPart( + animation: _controller, + builder: (t) => _Wing(t: t, isLeft: true), + child: const _PartImage('assets/supporter/wing_left.png'), + ), ), ), Positioned( @@ -164,10 +194,19 @@ class _SupporterHaloWingsState extends State top: wingTop, width: wingWidth, height: wingHeight, - child: _AnimatedPart( - animation: _controller, - builder: (t) => _Wing(t: t, isLeft: false), - child: const _PartImage('assets/supporter/wing_right.png'), + child: _CoverFade( + cover: _cover, + origin: Offset( + wingWidth - outset - size / 2, + size / 2 - wingTop, + ), + child: _AnimatedPart( + animation: _controller, + builder: (t) => _Wing(t: t, isLeft: false), + child: const _PartImage( + 'assets/supporter/wing_right.png', + ), + ), ), ), Positioned( @@ -175,17 +214,24 @@ class _SupporterHaloWingsState extends State top: 0, width: size, height: size, - child: widget.child, + child: widget.builder(context, _trackCover), ), Positioned( left: outset + (size - haloWidth) / 2, top: size * _Geometry.haloTop, width: haloWidth, height: haloHeight, - child: _AnimatedPart( - animation: _controller, - builder: (t) => _Halo(t: t, avatarSize: size), - child: const _PartImage('assets/supporter/halo.png'), + child: _CoverFade( + cover: _cover, + origin: Offset( + haloWidth / 2, + size / 2 - size * _Geometry.haloTop, + ), + child: _AnimatedPart( + animation: _controller, + builder: (t) => _Halo(t: t, avatarSize: size), + child: const _PartImage('assets/supporter/halo.png'), + ), ), ), ], @@ -261,6 +307,58 @@ class _Group implements _PartTransform { Alignment get origin => Alignment.center; } +/// Hides a halo or wing while the avatar is lifted away by [cover]. +/// +/// It fades and draws in toward the avatar as the avatar takes off, and +/// returns only as the avatar lands, so neither is left floating around an +/// empty spot. Following the route animation keeps it in step when a close +/// is interrupted or swiped back. +class _CoverFade extends StatelessWidget { + const _CoverFade({ + required this.cover, + required this.origin, + required this.child, + }); + + /// Share of the opening transition the fade-out takes (~150 ms of 300). + static const Interval _leave = Interval(0, 0.5, curve: Curves.easeOut); + + /// Share of the closing transition, from its end, that the return takes. + static const Interval _land = Interval(0, 0.3, curve: Curves.easeIn); + + static const double _hiddenScale = 0.85; + + final Animation? cover; + final Offset origin; + final Widget child; + + @override + Widget build(BuildContext context) { + final cover = this.cover; + if (cover == null) return child; + final reduceMotion = context.reduceMotion; + return AnimatedBuilder( + animation: cover, + builder: (context, child) { + final hidden = reduceMotion + ? (cover.value > 0 ? 1.0 : 0.0) + : (cover.status == AnimationStatus.reverse ? _land : _leave) + .transform(cover.value); + final scale = 1 - (1 - _hiddenScale) * hidden; + return Opacity( + opacity: 1 - hidden, + child: Transform( + origin: origin, + transform: Matrix4.diagonal3Values(scale, scale, 1), + child: child, + ), + ); + }, + child: child, + ); + } +} + /// Applies a per-frame transform to an image without rebuilding it. class _AnimatedPart extends StatelessWidget { const _AnimatedPart({ From c75407cf0a3abea66682f03b626b7c4d3c967deb Mon Sep 17 00:00:00 2001 From: Sebastian Heit Date: Thu, 8 Oct 2026 15:40:48 +0200 Subject: [PATCH 03/32] feat(profile): open a supporter view from other supporters' avatars Tapping another supporter's avatar now opens a full-screen view where the avatar, halo and wings fly in as one piece and keep animating, with sparkles and hearts behind them, a short pitch, three benefits and a "Become a supporter" button. Your own profile keeps the plain lightbox. The new strings are English-only for now (known untranslated debt). Refs #9914. Co-Authored-By: Claude --- mobile/assets/supporter/heart.png | Bin 0 -> 6862 bytes mobile/assets/supporter/sparkle.png | Bin 0 -> 6244 bytes mobile/lib/l10n/app_en.arb | 14 + .../lib/l10n/generated/app_localizations.dart | 42 ++ .../l10n/generated/app_localizations_am.dart | 27 ++ .../l10n/generated/app_localizations_ar.dart | 27 ++ .../l10n/generated/app_localizations_bg.dart | 27 ++ .../l10n/generated/app_localizations_de.dart | 27 ++ .../l10n/generated/app_localizations_en.dart | 27 ++ .../l10n/generated/app_localizations_es.dart | 27 ++ .../l10n/generated/app_localizations_fil.dart | 27 ++ .../l10n/generated/app_localizations_fr.dart | 27 ++ .../l10n/generated/app_localizations_id.dart | 27 ++ .../l10n/generated/app_localizations_it.dart | 27 ++ .../l10n/generated/app_localizations_ja.dart | 27 ++ .../l10n/generated/app_localizations_ko.dart | 27 ++ .../l10n/generated/app_localizations_ms.dart | 27 ++ .../l10n/generated/app_localizations_nl.dart | 27 ++ .../l10n/generated/app_localizations_pl.dart | 27 ++ .../l10n/generated/app_localizations_pt.dart | 27 ++ .../l10n/generated/app_localizations_ro.dart | 27 ++ .../l10n/generated/app_localizations_sv.dart | 27 ++ .../l10n/generated/app_localizations_te.dart | 27 ++ .../l10n/generated/app_localizations_tr.dart | 27 ++ .../l10n/generated/app_localizations_ur.dart | 27 ++ .../l10n/generated/app_localizations_vi.dart | 27 ++ .../l10n/generated/app_localizations_zh.dart | 27 ++ .../profile/profile_header_identity.dart | 4 +- .../widgets/profile/profile_header_media.dart | 16 +- .../profile_header_supporter_lightbox.dart | 448 ++++++++++++++++++ .../profile/profile_header_widget.dart | 10 +- .../widgets/profile/supporter_halo_wings.dart | 68 ++- mobile/test/l10n/arb_consistency_test.dart | 10 + 33 files changed, 1226 insertions(+), 7 deletions(-) create mode 100644 mobile/assets/supporter/heart.png create mode 100644 mobile/assets/supporter/sparkle.png create mode 100644 mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart diff --git a/mobile/assets/supporter/heart.png b/mobile/assets/supporter/heart.png new file mode 100644 index 0000000000000000000000000000000000000000..34384e4d197ac3807fe86c668d0b9506caa0ecf7 GIT binary patch literal 6862 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 IM6N<$f@x6>d;kCd literal 0 HcmV?d00001 diff --git a/mobile/assets/supporter/sparkle.png b/mobile/assets/supporter/sparkle.png new file mode 100644 index 0000000000000000000000000000000000000000..b8078a39bd36a30953ee4ee9f968fac8d90ca0a8 GIT binary patch literal 6244 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 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ar.dart b/mobile/lib/l10n/generated/app_localizations_ar.dart index b5a7f0fefc..bf61cf0c15 100644 --- a/mobile/lib/l10n/generated/app_localizations_ar.dart +++ b/mobile/lib/l10n/generated/app_localizations_ar.dart @@ -12526,6 +12526,33 @@ class AppLocalizationsAr extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_bg.dart b/mobile/lib/l10n/generated/app_localizations_bg.dart index 645ce2af1a..ea77161196 100644 --- a/mobile/lib/l10n/generated/app_localizations_bg.dart +++ b/mobile/lib/l10n/generated/app_localizations_bg.dart @@ -12674,6 +12674,33 @@ class AppLocalizationsBg extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_de.dart b/mobile/lib/l10n/generated/app_localizations_de.dart index f12c9fbd4c..d71a54a92a 100644 --- a/mobile/lib/l10n/generated/app_localizations_de.dart +++ b/mobile/lib/l10n/generated/app_localizations_de.dart @@ -12696,6 +12696,33 @@ class AppLocalizationsDe extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_en.dart b/mobile/lib/l10n/generated/app_localizations_en.dart index be27a27bf0..70846a4793 100644 --- a/mobile/lib/l10n/generated/app_localizations_en.dart +++ b/mobile/lib/l10n/generated/app_localizations_en.dart @@ -12632,6 +12632,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_es.dart b/mobile/lib/l10n/generated/app_localizations_es.dart index ea5d1d1edd..84052a7028 100644 --- a/mobile/lib/l10n/generated/app_localizations_es.dart +++ b/mobile/lib/l10n/generated/app_localizations_es.dart @@ -12690,6 +12690,33 @@ class AppLocalizationsEs extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_fil.dart b/mobile/lib/l10n/generated/app_localizations_fil.dart index 089f79ab3e..3eb19d27e4 100644 --- a/mobile/lib/l10n/generated/app_localizations_fil.dart +++ b/mobile/lib/l10n/generated/app_localizations_fil.dart @@ -12657,6 +12657,33 @@ class AppLocalizationsFil extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_fr.dart b/mobile/lib/l10n/generated/app_localizations_fr.dart index 1f35be747b..ba8150fd21 100644 --- a/mobile/lib/l10n/generated/app_localizations_fr.dart +++ b/mobile/lib/l10n/generated/app_localizations_fr.dart @@ -12745,6 +12745,33 @@ class AppLocalizationsFr extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_id.dart b/mobile/lib/l10n/generated/app_localizations_id.dart index 6664dd0320..c4fb250c45 100644 --- a/mobile/lib/l10n/generated/app_localizations_id.dart +++ b/mobile/lib/l10n/generated/app_localizations_id.dart @@ -12447,6 +12447,33 @@ class AppLocalizationsId extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_it.dart b/mobile/lib/l10n/generated/app_localizations_it.dart index 50b8e5a09f..44106f24d7 100644 --- a/mobile/lib/l10n/generated/app_localizations_it.dart +++ b/mobile/lib/l10n/generated/app_localizations_it.dart @@ -12707,6 +12707,33 @@ class AppLocalizationsIt extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ja.dart b/mobile/lib/l10n/generated/app_localizations_ja.dart index 64a65c2660..ced94d88de 100644 --- a/mobile/lib/l10n/generated/app_localizations_ja.dart +++ b/mobile/lib/l10n/generated/app_localizations_ja.dart @@ -11915,6 +11915,33 @@ class AppLocalizationsJa extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ko.dart b/mobile/lib/l10n/generated/app_localizations_ko.dart index 1506bc52fb..1dcbdfd71d 100644 --- a/mobile/lib/l10n/generated/app_localizations_ko.dart +++ b/mobile/lib/l10n/generated/app_localizations_ko.dart @@ -11936,6 +11936,33 @@ class AppLocalizationsKo extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ms.dart b/mobile/lib/l10n/generated/app_localizations_ms.dart index b1bbd379eb..4cfabb3c36 100644 --- a/mobile/lib/l10n/generated/app_localizations_ms.dart +++ b/mobile/lib/l10n/generated/app_localizations_ms.dart @@ -12539,6 +12539,33 @@ class AppLocalizationsMs extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_nl.dart b/mobile/lib/l10n/generated/app_localizations_nl.dart index a48045200d..aee75e5f28 100644 --- a/mobile/lib/l10n/generated/app_localizations_nl.dart +++ b/mobile/lib/l10n/generated/app_localizations_nl.dart @@ -12610,6 +12610,33 @@ class AppLocalizationsNl extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_pl.dart b/mobile/lib/l10n/generated/app_localizations_pl.dart index 8d18a3c26a..344211f583 100644 --- a/mobile/lib/l10n/generated/app_localizations_pl.dart +++ b/mobile/lib/l10n/generated/app_localizations_pl.dart @@ -12806,6 +12806,33 @@ class AppLocalizationsPl extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_pt.dart b/mobile/lib/l10n/generated/app_localizations_pt.dart index 68af3cf448..1fdd03afe5 100644 --- a/mobile/lib/l10n/generated/app_localizations_pt.dart +++ b/mobile/lib/l10n/generated/app_localizations_pt.dart @@ -12654,6 +12654,33 @@ class AppLocalizationsPt extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ro.dart b/mobile/lib/l10n/generated/app_localizations_ro.dart index 55ba993ea6..5f82ecaed0 100644 --- a/mobile/lib/l10n/generated/app_localizations_ro.dart +++ b/mobile/lib/l10n/generated/app_localizations_ro.dart @@ -12818,6 +12818,33 @@ class AppLocalizationsRo extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_sv.dart b/mobile/lib/l10n/generated/app_localizations_sv.dart index 53df540c38..490f8d5dfc 100644 --- a/mobile/lib/l10n/generated/app_localizations_sv.dart +++ b/mobile/lib/l10n/generated/app_localizations_sv.dart @@ -12552,6 +12552,33 @@ class AppLocalizationsSv extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_te.dart b/mobile/lib/l10n/generated/app_localizations_te.dart index f3dfa1a935..a2483a1e27 100644 --- a/mobile/lib/l10n/generated/app_localizations_te.dart +++ b/mobile/lib/l10n/generated/app_localizations_te.dart @@ -12879,6 +12879,33 @@ class AppLocalizationsTe extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_tr.dart b/mobile/lib/l10n/generated/app_localizations_tr.dart index 34d880d48f..47a56c6741 100644 --- a/mobile/lib/l10n/generated/app_localizations_tr.dart +++ b/mobile/lib/l10n/generated/app_localizations_tr.dart @@ -12472,6 +12472,33 @@ class AppLocalizationsTr extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ur.dart b/mobile/lib/l10n/generated/app_localizations_ur.dart index 695091d014..7b6fb8eae7 100644 --- a/mobile/lib/l10n/generated/app_localizations_ur.dart +++ b/mobile/lib/l10n/generated/app_localizations_ur.dart @@ -12534,6 +12534,33 @@ class AppLocalizationsUr extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_vi.dart b/mobile/lib/l10n/generated/app_localizations_vi.dart index 53d19281f3..c9f33e42bf 100644 --- a/mobile/lib/l10n/generated/app_localizations_vi.dart +++ b/mobile/lib/l10n/generated/app_localizations_vi.dart @@ -12485,6 +12485,33 @@ class AppLocalizationsVi extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_zh.dart b/mobile/lib/l10n/generated/app_localizations_zh.dart index 6ff8d58256..17f4a9a3b6 100644 --- a/mobile/lib/l10n/generated/app_localizations_zh.dart +++ b/mobile/lib/l10n/generated/app_localizations_zh.dart @@ -11772,6 +11772,33 @@ class AppLocalizationsZh extends AppLocalizations { @override String get supporterJoinLabel => 'Become a supporter'; + @override + String supporterLightboxHeadline(String name, String badge) { + return '$name is a $badge'; + } + + @override + String get supporterLightboxBadge => 'Divine Supporter'; + + @override + String get supporterLightboxBody => + 'Supporting the independent app for a more open, creative internet.'; + + @override + String get supporterLightboxBenefitIndependent => + 'Helps keep Divine independent'; + + @override + String get supporterLightboxBenefitFeatures => + 'Supports new exciting features'; + + @override + String get supporterLightboxBenefitCommunity => + 'Powers a more creative community'; + + @override + String get supporterLightboxMaybeLater => 'Maybe later'; + @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/widgets/profile/profile_header_identity.dart b/mobile/lib/widgets/profile/profile_header_identity.dart index c834442cb2..730320c729 100644 --- a/mobile/lib/widgets/profile/profile_header_identity.dart +++ b/mobile/lib/widgets/profile/profile_header_identity.dart @@ -64,10 +64,10 @@ class _ProfileNameAndBio extends StatelessWidget { ], ), ), - // The halo and wings replace the heart chip while previewing them. + // The halo and wings replace the heart chips while previewing them. if (!isVanished && isOwnProfile && !supporterHaloPreview) const SupporterMembership(compact: true) - else if (!isVanished) + else if (!isVanished && !supporterHaloPreview) PublicSupporterBadge(pubkey: userIdHex), Skeleton.keep(child: _ProfileBadgesBlock(userIdHex: userIdHex)), Padding( diff --git a/mobile/lib/widgets/profile/profile_header_media.dart b/mobile/lib/widgets/profile/profile_header_media.dart index 3d14a9d853..6cad6bce99 100644 --- a/mobile/lib/widgets/profile/profile_header_media.dart +++ b/mobile/lib/widgets/profile/profile_header_media.dart @@ -441,9 +441,10 @@ class _ProfileAvatarWithColor extends StatelessWidget { required this.imageUrl, required this.userIdHex, required this.showSkeleton, + required this.showSupporterTreatment, this.profileColor, this.pendingActions = const [], - this.showSupporterTreatment = false, + this.opensSupporterLightbox = false, this.onActionTap, }); @@ -469,6 +470,10 @@ class _ProfileAvatarWithColor extends StatelessWidget { /// Whether to surround the avatar with the supporter halo and wings. final bool showSupporterTreatment; + /// Whether a tap opens the supporter lightbox, where the halo and wings fly + /// along with the avatar, instead of the plain photo lightbox. + final bool opensSupporterLightbox; + @override Widget build(BuildContext context) { const avatarSize = _headerAvatarSize; @@ -504,7 +509,14 @@ class _ProfileAvatarWithColor extends StatelessWidget { ); } - avatar = showSupporterTreatment + avatar = showSupporterTreatment && opensSupporterLightbox + ? _SupporterAvatarHero( + imageUrl: imageUrl, + userIdHex: userIdHex, + avatarSize: avatarSize, + avatar: avatarWidget, + ) + : showSupporterTreatment ? SupporterHaloWings( avatarSize: avatarSize, builder: (context, trackCover) => diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart new file mode 100644 index 0000000000..dc8729a968 --- /dev/null +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -0,0 +1,448 @@ +part of 'profile_header_widget.dart'; + +/// Avatar size in the supporter lightbox. Smaller than the plain lightbox so +/// the wings, the headline and the call to action fit on one screen. +const double _supporterLightboxAvatarSize = 192; + +/// Share of the route transition after which the text and buttons fade in, +/// so they arrive once the avatar has landed and leave first on close. +const Interval _supporterContentReveal = Interval( + 0.6, + 1, + curve: Curves.easeOut, +); + +/// Share of the route transition over which the sparkles fade in. +const Interval _supporterSparkleReveal = Interval(0.4, 1); + +/// Header avatar of another user's supporter profile. +/// +/// The whole group — avatar, wings and halo — is the Hero, so the treatment +/// flies into the supporter lightbox and keeps moving instead of being left +/// behind on the profile. +class _SupporterAvatarHero extends StatelessWidget { + const _SupporterAvatarHero({ + required this.userIdHex, + required this.avatarSize, + required this.avatar, + this.imageUrl, + }); + + final String? imageUrl; + final String userIdHex; + final double avatarSize; + final Widget avatar; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: context.l10n.supporterLightboxBadge, + child: GestureDetector( + onTap: () => _showSupporterLightbox( + context, + imageUrl: imageUrl, + userIdHex: userIdHex, + ), + child: Hero( + tag: _avatarHeroTag(userIdHex), + createRectTween: _supporterRectTween, + flightShuttleBuilder: _supporterShuttle(imageUrl, userIdHex), + child: SupporterHaloWings( + avatarSize: avatarSize, + builder: (_, _) => avatar, + ), + ), + ), + ); + } +} + +/// The group in flight: laid out once at its lightbox size and scaled as a +/// single piece, so the wings and halo can never drift from the avatar while +/// the Hero box grows or shrinks. +HeroFlightShuttleBuilder _supporterShuttle(String? imageUrl, String userIdHex) { + return (context, animation, _, _, _) => FittedBox( + child: SupporterHaloWings( + avatarSize: _supporterLightboxAvatarSize, + sparkles: CurvedAnimation( + parent: animation, + curve: _supporterSparkleReveal, + ), + builder: (_, _) => UserAvatar( + imageUrl: imageUrl, + placeholderSeed: userIdHex, + size: _supporterLightboxAvatarSize, + cornerRadius: _supporterLightboxAvatarSize * _avatarHeroCornerRatio, + decodeSize: _headerAvatarSize, + ), + ), + ); +} + +/// A straight-line Hero path. The default arc reshapes the box mid-flight, +/// which would stretch the group out of proportion. +RectTween _supporterRectTween(Rect? begin, Rect? end) => + RectTween(begin: begin, end: end); + +void _showSupporterLightbox( + BuildContext context, { + required String userIdHex, + String? imageUrl, +}) { + _runProfileDetached( + _awaitProfileResult( + Navigator.of(context, rootNavigator: true).push( + PageRouteBuilder( + opaque: false, + barrierColor: VineTheme.transparent, + barrierDismissible: true, + barrierLabel: context.l10n.profileAvatarLightboxBarrierLabel, + pageBuilder: (context, _, _) => + _SupporterLightbox(imageUrl: imageUrl, userIdHex: userIdHex), + transitionsBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ), + ), + ), + 'open supporter lightbox', + ); +} + +/// Full-screen view of another user's supporter avatar, explaining what the +/// status means and inviting the viewer to become a supporter too. +class _SupporterLightbox extends StatelessWidget { + const _SupporterLightbox({required this.userIdHex, this.imageUrl}); + + final String? imageUrl; + final String userIdHex; + + @override + Widget build(BuildContext context) { + final padding = MediaQuery.paddingOf(context); + final colors = context.vineColors; + final route = ModalRoute.of(context)!.animation!; + final reveal = context.reduceMotion + ? route + : CurvedAnimation(parent: route, curve: _supporterContentReveal); + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), + // Text here needs a Material ancestor for its default style; the route + // sits on the root navigator, above the app's scaffolds. + child: Material( + color: colors.background.withValues(alpha: 0.92), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(0, -0.35), + radius: 0.8, + colors: [ + VineTheme.vineGreen.withValues(alpha: 0.18), + VineTheme.vineGreen.withValues(alpha: 0), + ], + ), + ), + child: Stack( + children: [ + Positioned.fill( + child: Semantics( + label: context.l10n.profileAvatarLightboxCloseSemanticLabel, + button: true, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + 24, + padding.top + 72, + 24, + padding.bottom + 16, + ), + child: Column( + children: [ + const Spacer(), + _SupporterLightboxAvatar( + imageUrl: imageUrl, + userIdHex: userIdHex, + ), + const SizedBox(height: 30), + FadeTransition( + opacity: reveal, + child: Column( + children: [ + _SupporterLightboxHeadline(userIdHex: userIdHex), + const SizedBox(height: 12), + Text( + context.l10n.supporterLightboxBody, + textAlign: TextAlign.center, + style: VineTheme.bodyLargeFont( + color: colors.secondaryText, + ), + ), + const SizedBox(height: 32), + const _SupporterBenefits(), + ], + ), + ), + const Spacer(), + FadeTransition( + opacity: reveal, + child: const _SupporterLightboxActions(), + ), + ], + ), + ), + Positioned( + top: padding.top + 12, + left: 12, + child: DivineIconButton( + icon: DivineIconName.x, + type: DivineIconButtonType.ghostSecondary, + size: DivineIconButtonSize.small, + semanticLabel: + context.l10n.profileAvatarLightboxCloseSemanticLabel, + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SupporterLightboxAvatar extends StatelessWidget { + const _SupporterLightboxAvatar({required this.userIdHex, this.imageUrl}); + + final String? imageUrl; + final String userIdHex; + + @override + Widget build(BuildContext context) { + const size = _supporterLightboxAvatarSize; + return Hero( + tag: _avatarHeroTag(userIdHex), + createRectTween: _supporterRectTween, + flightShuttleBuilder: _supporterShuttle(imageUrl, userIdHex), + child: SupporterHaloWings( + avatarSize: size, + sparkles: kAlwaysCompleteAnimation, + builder: (_, _) => UserAvatar( + imageUrl: imageUrl, + placeholderSeed: userIdHex, + size: size, + cornerRadius: size * _avatarHeroCornerRatio, + previewDecodeSize: _headerAvatarSize, + ), + ), + ); + } +} + +/// "{name} is a Divine Supporter", with the name resolved like everywhere +/// else and the status in the brand accent, in whatever order the +/// translation puts them. +class _SupporterLightboxHeadline extends StatelessWidget { + const _SupporterLightboxHeadline({required this.userIdHex}); + + final String userIdHex; + + static const String _nameToken = '\u0001'; + static const String _badgeToken = '\u0002'; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = context.vineColors; + final style = VineTheme.headlineSmallFont(color: colors.primaryText); + final template = l10n.supporterLightboxHeadline(_nameToken, _badgeToken); + final spans = []; + for (final (index, part) in template.split(_nameToken).indexed) { + if (index > 0) { + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: UserName.fromPubKey( + userIdHex, + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + showProfileBadges: false, + ), + ), + ); + // A name that leads the sentence gets a line of its own, as in the + // design; otherwise it flows inline. + if (template.startsWith(_nameToken)) { + spans.add(const TextSpan(text: '\n')); + } + } + final pieces = part.trimLeft().split(_badgeToken); + for (final (badgeIndex, piece) in pieces.indexed) { + if (badgeIndex > 0) { + spans.add( + TextSpan( + text: l10n.supporterLightboxBadge, + style: style.copyWith(color: VineTheme.vineGreen), + ), + ); + } + spans.add(TextSpan(text: piece)); + } + } + return Text.rich( + TextSpan(children: spans), + textAlign: TextAlign.center, + style: style, + ); + } +} + +class _SupporterBenefits extends StatelessWidget { + const _SupporterBenefits(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + _SupporterBenefit( + icon: DivineIconName.heart, + label: l10n.supporterLightboxBenefitIndependent, + ), + _SupporterBenefit( + icon: DivineIconName.users, + label: l10n.supporterLightboxBenefitFeatures, + ), + _SupporterBenefit( + icon: DivineIconName.paintBrush, + label: l10n.supporterLightboxBenefitCommunity, + ), + ], + ); + } +} + +class _SupporterBenefit extends StatelessWidget { + const _SupporterBenefit({required this.icon, required this.label}); + + final DivineIconName icon; + final String label; + + @override + Widget build(BuildContext context) { + final colors = context.vineColors; + return Expanded( + child: MergeSemantics( + child: Column( + spacing: 12, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: colors.iconButton, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: DivineIcon( + icon: icon, + color: colors.accentBrand, + ), + ), + ), + Text( + label, + textAlign: TextAlign.center, + style: VineTheme.bodyMediumFont(color: colors.primaryText), + ), + ], + ), + ), + ); + } +} + +/// The call to action, hidden when the viewer already supports Divine. +class _SupporterLightboxActions extends ConsumerWidget { + const _SupporterLightboxActions(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!ref.watch(supporterApiConfiguredProvider)) { + return const _SupporterLightboxButtons(); + } + final repository = ref.watch(supporterRepositoryProvider); + return BlocProvider( + key: ValueKey(repository), + create: (_) => + SupporterCubit(repository: repository)..start(loadStore: false), + child: BlocSelector( + selector: (state) => state.isSupporter, + builder: (context, isSupporter) => isSupporter + ? const SizedBox.shrink() + : const _SupporterLightboxButtons(), + ), + ); + } +} + +class _SupporterLightboxButtons extends StatelessWidget { + const _SupporterLightboxButtons(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Column( + spacing: 8, + children: [ + DivineButton( + label: l10n.supporterJoinLabel, + expanded: true, + onPressed: () { + final router = GoRouter.of(context); + Navigator.of(context).pop(); + _runProfileDetached( + _awaitProfileResult(router.push(SupporterScreen.path)), + 'open supporter screen', + ); + }, + ), + const _SupporterMaybeLater(), + ], + ); + } +} + +/// Low-emphasis dismiss under the call to action: plain text, not a button. +class _SupporterMaybeLater extends StatelessWidget { + const _SupporterMaybeLater(); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48, minWidth: 48), + child: Center( + widthFactor: 1, + child: Text( + context.l10n.supporterLightboxMaybeLater, + style: VineTheme.bodyLargeFont( + color: context.vineColors.onSurfaceVariant, + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/profile/profile_header_widget.dart b/mobile/lib/widgets/profile/profile_header_widget.dart index d55e098e18..4ebbd76e55 100644 --- a/mobile/lib/widgets/profile/profile_header_widget.dart +++ b/mobile/lib/widgets/profile/profile_header_widget.dart @@ -14,7 +14,10 @@ import 'package:models/models.dart'; import 'package:openvine/blocs/background_publish/background_publish_bloc.dart'; import 'package:openvine/blocs/my_profile/my_profile_bloc.dart'; import 'package:openvine/blocs/other_profile/other_profile_bloc.dart'; +import 'package:openvine/blocs/supporter/supporter_cubit.dart'; +import 'package:openvine/blocs/supporter/supporter_state.dart'; import 'package:openvine/constants/semantic_ids.dart'; +import 'package:openvine/extensions/media_query_extensions.dart'; import 'package:openvine/features/feature_flags/models/feature_flag.dart'; import 'package:openvine/features/feature_flags/providers/feature_flag_providers.dart'; import 'package:openvine/features/monetization/monetization_analytics.dart'; @@ -28,6 +31,7 @@ import 'package:openvine/providers/nip05_verification_provider.dart'; import 'package:openvine/providers/og_diviner_eligibility_provider.dart'; import 'package:openvine/providers/og_viner_cache_provider.dart'; import 'package:openvine/providers/shared_preferences_provider.dart'; +import 'package:openvine/providers/supporter_providers.dart'; import 'package:openvine/providers/user_profile_providers.dart'; import 'package:openvine/router/route_paths.dart'; import 'package:openvine/router/widgets/followers_screen_router.dart'; @@ -36,6 +40,7 @@ import 'package:openvine/screens/badges/badge_editor_screen.dart'; import 'package:openvine/screens/badges/badges_screen.dart'; import 'package:openvine/screens/other_profile_screen.dart'; import 'package:openvine/screens/settings/settings_screen.dart'; +import 'package:openvine/screens/settings/supporter_screen.dart'; import 'package:openvine/utils/clipboard_utils.dart'; import 'package:openvine/utils/deferred_login_options_navigator.dart'; import 'package:openvine/utils/detached_future.dart'; @@ -68,6 +73,7 @@ import 'package:skeletonizer/skeletonizer.dart'; part 'profile_header_identity.dart'; part 'profile_header_media.dart'; +part 'profile_header_supporter_lightbox.dart'; void _runProfileDetached(Future operation, String description) { runDetached( @@ -416,8 +422,8 @@ class _ProfileHeaderWidgetState extends ConsumerState { showSkeleton: showIdentitySkeleton, profileColor: profileColor, pendingActions: pendingActions, - showSupporterTreatment: - supporterHaloPreview && widget.isOwnProfile, + showSupporterTreatment: supporterHaloPreview, + opensSupporterLightbox: !widget.isOwnProfile, onActionTap: pendingActions.isEmpty ? null : pendingActions.first == diff --git a/mobile/lib/widgets/profile/supporter_halo_wings.dart b/mobile/lib/widgets/profile/supporter_halo_wings.dart index 80409d46bb..c6b3221324 100644 --- a/mobile/lib/widgets/profile/supporter_halo_wings.dart +++ b/mobile/lib/widgets/profile/supporter_halo_wings.dart @@ -28,11 +28,16 @@ class SupporterHaloWings extends StatefulWidget { const SupporterHaloWings({ required this.avatarSize, required this.builder, + this.sparkles, super.key, }); final double avatarSize; + /// When set, twinkling sparkles surround the group, faded in by this value. + /// Used where the supporter status is the subject, such as its lightbox. + final Animation? sparkles; + /// Builds the avatar. Call the given callback with the route animation when /// the avatar opens something that flies it away. final Widget Function(BuildContext context, TrackCoveringTransition track) @@ -77,8 +82,25 @@ abstract class _Motion { /// The right wing trails the left by this share of a cycle. static const double wingPhaseLag = 0.08; + + /// Sparkles pulse between this scale and full size, each on its own beat. + static const double sparkleMinScale = 0.55; + static const double sparkleTiltRadians = 12 * math.pi / 180; } +/// Accents around the avatar: offset from its top-left corner and size, as +/// fractions of the avatar size, a phase so they do not pulse together, and +/// the image. Hearts sit in opposite corners, sparkles in the other two. +const _sparkleAsset = 'assets/supporter/sparkle.png'; +const _heartAsset = 'assets/supporter/heart.png'; +const List<({double dx, double dy, double size, double phase, String asset})> +_sparkles = [ + (dx: -0.12, dy: -0.04, size: 0.16, phase: 0, asset: _heartAsset), + (dx: -0.36, dy: 0.62, size: 20 / 192, phase: 0.35, asset: _sparkleAsset), + (dx: 0.98, dy: 0.12, size: 0.16, phase: 0.55, asset: _sparkleAsset), + (dx: 1.02, dy: 0.88, size: 20 / 192, phase: 0.8, asset: _heartAsset), +]; + class _SupporterHaloWingsState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( @@ -126,7 +148,15 @@ class _SupporterHaloWingsState extends State if (_controller.isAnimating) _controller.stop(); _controller.value = 0; } else if (_visible && appVisible) { - if (!_controller.isAnimating) _controller.repeat(); + if (!_controller.isAnimating) { + // Every instance starts from the wall clock, so the header and the + // lightbox copy stay in step while the avatar flies between them. + final periodMs = _Motion.period.inMilliseconds; + _controller + ..value = + (DateTime.now().millisecondsSinceEpoch % periodMs) / periodMs + ..repeat(); + } } else if (_controller.isAnimating) { _controller.stop(); } @@ -173,6 +203,23 @@ class _SupporterHaloWingsState extends State child: Stack( clipBehavior: Clip.none, children: [ + // Accents sit behind the wings, avatar and halo. + if (widget.sparkles case final visibility?) + for (final sparkle in _sparkles) + Positioned( + left: outset + (sparkle.dx - sparkle.size / 2) * size, + top: (sparkle.dy - sparkle.size / 2) * size, + width: sparkle.size * size, + height: sparkle.size * size, + child: FadeTransition( + opacity: visibility, + child: _AnimatedPart( + animation: _controller, + builder: (t) => _Sparkle(t: t, phase: sparkle.phase), + child: _PartImage(sparkle.asset), + ), + ), + ), Positioned( left: 0, top: wingTop, @@ -289,6 +336,25 @@ class _Halo implements _PartTransform { Alignment get origin => Alignment.center; } +class _Sparkle implements _PartTransform { + _Sparkle({required this.t, required this.phase}); + + final double t; + final double phase; + + @override + Matrix4 get matrix { + final wave = (math.sin((t + phase) * 2 * math.pi) + 1) / 2; + final scale = + _Motion.sparkleMinScale + (1 - _Motion.sparkleMinScale) * wave; + return Matrix4.diagonal3Values(scale, scale, 1) + ..rotateZ((wave - 0.5) * _Motion.sparkleTiltRadians); + } + + @override + Alignment get origin => Alignment.center; +} + class _Group implements _PartTransform { _Group({required this.t, required this.avatarSize}); diff --git a/mobile/test/l10n/arb_consistency_test.dart b/mobile/test/l10n/arb_consistency_test.dart index 2ac1b78531..9dc6673408 100644 --- a/mobile/test/l10n/arb_consistency_test.dart +++ b/mobile/test/l10n/arb_consistency_test.dart @@ -631,6 +631,16 @@ const _knownUntranslatedDebt = { 'contentPreferencesSubtitleLanguageFollowApp', 'contentPreferencesSubtitleKeepOriginal', 'contentPreferencesSubtitleKeepOriginalNone', + + // Supporter avatar lightbox prototype (#9914), English until translated. + 'supporterLightboxHeadline', + 'supporterLightboxBadge', + 'supporterLightboxBody', + 'supporterLightboxBenefitIndependent', + 'supporterLightboxBenefitFeatures', + 'supporterLightboxBenefitCommunity', + 'supporterLightboxMaybeLater', + // A people list whose members are all hidden from the viewer (#9895). 'peopleListsAllMembersHiddenTitle', 'peopleListsAllMembersHiddenSubtitle', From ed31d24f9d942832020efb88e8394f248278f502 Mon Sep 17 00:00:00 2001 From: Sebastian Heit Date: Tue, 13 Oct 2026 22:37:22 +0200 Subject: [PATCH 04/32] feat(profile): show the supporter halo for public supporters The halo and wings were behind a dev-only preview flag. They now follow each profile's public supporter status (haloVisible from the supporters API), replacing the old heart-and-label badge. Another supporter's avatar opens the supporter view; everyone else gets the plain photo lightbox. Refs #9914 Co-Authored-By: Claude --- .../profile/profile_header_identity.dart | 9 +- .../widgets/profile/profile_header_media.dart | 8 +- .../profile_header_supporter_lightbox.dart | 146 ++++++++++----- .../profile/profile_header_widget.dart | 84 ++++----- .../widgets/profile/supporter_halo_wings.dart | 81 ++++++--- mobile/lib/widgets/supporter_membership.dart | 40 ----- .../test/helpers/test_provider_overrides.dart | 4 +- .../profile/profile_header_widget_test.dart | 167 +++++++++++++++++- .../widgets/supporter_membership_test.dart | 92 ---------- 9 files changed, 366 insertions(+), 265 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_identity.dart b/mobile/lib/widgets/profile/profile_header_identity.dart index 730320c729..f9fb13f3a1 100644 --- a/mobile/lib/widgets/profile/profile_header_identity.dart +++ b/mobile/lib/widgets/profile/profile_header_identity.dart @@ -64,11 +64,10 @@ class _ProfileNameAndBio extends StatelessWidget { ], ), ), - // The halo and wings replace the heart chips while previewing them. - if (!isVanished && isOwnProfile && !supporterHaloPreview) - const SupporterMembership(compact: true) - else if (!isVanished && !supporterHaloPreview) - PublicSupporterBadge(pubkey: userIdHex), + // Public supporter status shows as the halo and wings around the + // avatar; this chip is the owner's private entry to membership. + if (!isVanished && isOwnProfile) + const SupporterMembership(compact: true), Skeleton.keep(child: _ProfileBadgesBlock(userIdHex: userIdHex)), Padding( padding: inset, diff --git a/mobile/lib/widgets/profile/profile_header_media.dart b/mobile/lib/widgets/profile/profile_header_media.dart index 6cad6bce99..2cbfa8a2fa 100644 --- a/mobile/lib/widgets/profile/profile_header_media.dart +++ b/mobile/lib/widgets/profile/profile_header_media.dart @@ -441,7 +441,6 @@ class _ProfileAvatarWithColor extends StatelessWidget { required this.imageUrl, required this.userIdHex, required this.showSkeleton, - required this.showSupporterTreatment, this.profileColor, this.pendingActions = const [], this.opensSupporterLightbox = false, @@ -467,9 +466,6 @@ class _ProfileAvatarWithColor extends StatelessWidget { /// Called when the label or avatar is tapped. final VoidCallback? onActionTap; - /// Whether to surround the avatar with the supporter halo and wings. - final bool showSupporterTreatment; - /// Whether a tap opens the supporter lightbox, where the halo and wings fly /// along with the avatar, instead of the plain photo lightbox. final bool opensSupporterLightbox; @@ -487,6 +483,9 @@ class _ProfileAvatarWithColor extends StatelessWidget { size: avatarSize, ); final hasAvatar = imageUrl != null && imageUrl!.isNotEmpty; + // Public status arrives after the header renders; the halo and wings + // fade in around the avatar, which stays put. + final showSupporterTreatment = _isPublicSupporter(context); Widget tappableAvatar({void Function(Animation)? onOpened}) { if (!hasAvatar) return avatarWidget; return GestureDetector( @@ -519,6 +518,7 @@ class _ProfileAvatarWithColor extends StatelessWidget { : showSupporterTreatment ? SupporterHaloWings( avatarSize: avatarSize, + fadeIn: true, builder: (context, trackCover) => tappableAvatar(onOpened: trackCover), ) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index dc8729a968..574104a7f2 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -50,6 +50,7 @@ class _SupporterAvatarHero extends StatelessWidget { flightShuttleBuilder: _supporterShuttle(imageUrl, userIdHex), child: SupporterHaloWings( avatarSize: avatarSize, + fadeIn: true, builder: (_, _) => avatar, ), ), @@ -144,55 +145,63 @@ class _SupporterLightbox extends StatelessWidget { ), child: Stack( children: [ - Positioned.fill( - child: Semantics( - label: context.l10n.profileAvatarLightboxCloseSemanticLabel, - button: true, - child: GestureDetector( - onTap: () => Navigator.of(context).pop(), - ), - ), - ), - Padding( - padding: EdgeInsets.fromLTRB( - 24, - padding.top + 72, - 24, - padding.bottom + 16, - ), - child: Column( - children: [ - const Spacer(), - _SupporterLightboxAvatar( - imageUrl: imageUrl, - userIdHex: userIdHex, - ), - const SizedBox(height: 30), - FadeTransition( - opacity: reveal, - child: Column( - children: [ - _SupporterLightboxHeadline(userIdHex: userIdHex), - const SizedBox(height: 12), - Text( - context.l10n.supporterLightboxBody, - textAlign: TextAlign.center, - style: VineTheme.bodyLargeFont( - color: colors.secondaryText, + // Scrolls only when short screens or large text overflow it. + CustomScrollView( + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + // Tapping the backdrop closes; the labelled close button + // is the accessible route, so this stays out of semantics. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: () => Navigator.of(context).pop(), + child: Padding( + padding: EdgeInsets.fromLTRB( + 24, + padding.top + 72, + 24, + padding.bottom + 16, + ), + child: Column( + children: [ + const Spacer(), + _SupporterLightboxAvatar( + imageUrl: imageUrl, + userIdHex: userIdHex, + ), + const SizedBox(height: 30), + FadeTransition( + opacity: reveal, + child: Column( + children: [ + _SupporterLightboxHeadline( + userIdHex: userIdHex, + ), + const SizedBox(height: 12), + Text( + context.l10n.supporterLightboxBody, + textAlign: TextAlign.center, + style: VineTheme.bodyLargeFont( + color: colors.secondaryText, + ), + ), + const SizedBox(height: 32), + const _SupporterBenefits(), + ], + ), + ), + const Spacer(), + FadeTransition( + opacity: reveal, + child: const _SupporterLightboxActions(), ), - ), - const SizedBox(height: 32), - const _SupporterBenefits(), - ], + ], + ), ), ), - const Spacer(), - FadeTransition( - opacity: reveal, - child: const _SupporterLightboxActions(), - ), - ], - ), + ), + ], ), Positioned( top: padding.top + 12, @@ -446,3 +455,48 @@ class _SupporterMaybeLater extends StatelessWidget { ); } } + +/// Provides one [PublicSupporterCubit] for a profile, so everything in the +/// header that reflects supporter status reads the same single request. +/// +/// Only opted-in public recognition is used, for every profile including the +/// viewer's own, so a profile looks the same to its owner as to everyone else. +class _PublicSupporterScope extends ConsumerWidget { + const _PublicSupporterScope({ + required this.pubkey, + required this.enabled, + required this.child, + }); + + final String pubkey; + + /// False for a deleted account, which never shows supporter status. + final bool enabled; + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!enabled || !ref.watch(supporterApiConfiguredProvider)) return child; + final client = ref.watch(supporterApiClientProvider); + if (client == null) return child; + return BlocProvider( + key: ValueKey((client, pubkey)), + create: (_) { + final cubit = PublicSupporterCubit(client: client, pubkey: pubkey); + unawaited(cubit.load()); + return cubit; + }, + child: child, + ); + } +} + +/// Whether the profile in the nearest [_PublicSupporterScope] shows its +/// supporter status publicly. False while loading and outside a scope. +bool _isPublicSupporter(BuildContext context) { + try { + return context.select((cubit) => cubit.state); + } on ProviderNotFoundException { + return false; + } +} diff --git a/mobile/lib/widgets/profile/profile_header_widget.dart b/mobile/lib/widgets/profile/profile_header_widget.dart index 4ebbd76e55..b823d6fcb1 100644 --- a/mobile/lib/widgets/profile/profile_header_widget.dart +++ b/mobile/lib/widgets/profile/profile_header_widget.dart @@ -14,6 +14,7 @@ import 'package:models/models.dart'; import 'package:openvine/blocs/background_publish/background_publish_bloc.dart'; import 'package:openvine/blocs/my_profile/my_profile_bloc.dart'; import 'package:openvine/blocs/other_profile/other_profile_bloc.dart'; +import 'package:openvine/blocs/supporter/public_supporter_cubit.dart'; import 'package:openvine/blocs/supporter/supporter_cubit.dart'; import 'package:openvine/blocs/supporter/supporter_state.dart'; import 'package:openvine/constants/semantic_ids.dart'; @@ -411,50 +412,53 @@ class _ProfileHeaderWidgetState extends ConsumerState { enabled: showIdentitySkeleton, enableSwitchAnimation: true, effect: vineSkeletonEffectOf(context), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Centered avatar with action label pill - Center( - child: _ProfileAvatarWithColor( - imageUrl: profilePictureUrl, - userIdHex: widget.userIdHex, - showSkeleton: showIdentitySkeleton, - profileColor: profileColor, - pendingActions: pendingActions, - showSupporterTreatment: supporterHaloPreview, - opensSupporterLightbox: !widget.isOwnProfile, - onActionTap: pendingActions.isEmpty - ? null - : pendingActions.first == - ProfileActionType.accountRestricted - ? () => context.push(RoutePaths.accountStatus) - : () => _showActionsSheet( - context, - pendingActions, - secureAccountPromptDismissal, - ), + child: _PublicSupporterScope( + pubkey: widget.userIdHex, + enabled: !isVanished, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Centered avatar with action label pill + Center( + child: _ProfileAvatarWithColor( + imageUrl: profilePictureUrl, + userIdHex: widget.userIdHex, + showSkeleton: showIdentitySkeleton, + profileColor: profileColor, + pendingActions: pendingActions, + opensSupporterLightbox: !widget.isOwnProfile, + onActionTap: pendingActions.isEmpty + ? null + : pendingActions.first == + ProfileActionType.accountRestricted + ? () => context.push(RoutePaths.accountStatus) + : () => _showActionsSheet( + context, + pendingActions, + secureAccountPromptDismissal, + ), + ), ), - ), - // Name, NIP-05, and bio. Horizontal inset lives on the - // children so the badge row can scroll edge-to-edge. - Padding( - padding: const EdgeInsets.only(top: 32, bottom: 16), - child: _ProfileNameAndBio( - profile: effectiveProfile, - userIdHex: widget.userIdHex, - about: about, - displayNameHint: displayNameHint, - isVanished: isVanished, - accentColor: profileColor, - isOwnProfile: widget.isOwnProfile, - monetizationLinks: monetizationLinksForCurrentStorefront( - effectiveProfile?.enabledMonetizationLinks ?? const [], + // Name, NIP-05, and bio. Horizontal inset lives on the + // children so the badge row can scroll edge-to-edge. + Padding( + padding: const EdgeInsets.only(top: 32, bottom: 16), + child: _ProfileNameAndBio( + profile: effectiveProfile, + userIdHex: widget.userIdHex, + about: about, + displayNameHint: displayNameHint, + isVanished: isVanished, + accentColor: profileColor, + isOwnProfile: widget.isOwnProfile, + monetizationLinks: monetizationLinksForCurrentStorefront( + effectiveProfile?.enabledMonetizationLinks ?? const [], + ), ), ), - ), - ], + ], + ), ), ), if (!widget.isOwnProfile) ...[ diff --git a/mobile/lib/widgets/profile/supporter_halo_wings.dart b/mobile/lib/widgets/profile/supporter_halo_wings.dart index c6b3221324..2876f2777a 100644 --- a/mobile/lib/widgets/profile/supporter_halo_wings.dart +++ b/mobile/lib/widgets/profile/supporter_halo_wings.dart @@ -7,13 +7,6 @@ import 'package:flutter/widgets.dart'; import 'package:openvine/extensions/media_query_extensions.dart'; import 'package:visibility_detector/visibility_detector.dart'; -// TODO(#9914): Prototype switch. Replace with real supporter status before -// this ships; for now `--dart-define=SUPPORTER_HALO_PREVIEW=true` forces the -// treatment on the signed-in user's own profile. -const bool supporterHaloPreview = bool.fromEnvironment( - 'SUPPORTER_HALO_PREVIEW', -); - /// Called with the animation of a route that lifts the avatar away, such as /// the avatar lightbox, so the halo and wings can follow it out and back. typedef TrackCoveringTransition = void Function(Animation transition); @@ -29,11 +22,17 @@ class SupporterHaloWings extends StatefulWidget { required this.avatarSize, required this.builder, this.sparkles, + this.fadeIn = false, super.key, }); final double avatarSize; + /// Whether the halo and wings fade in when this first appears, rather than + /// being there from the start. Set where supporter status arrives after the + /// avatar is already on screen; left off where the group flies in whole. + final bool fadeIn; + /// When set, twinkling sparkles surround the group, faded in by this value. /// Used where the supporter status is the subject, such as its lightbox. final Animation? sparkles; @@ -66,6 +65,7 @@ abstract class _Geometry { /// Motion amplitudes. Kept small: the avatar should breathe, not flap. abstract class _Motion { static const Duration period = Duration(milliseconds: 3000); + static const Duration reveal = Duration(milliseconds: 300); static const double haloBobFraction = 3 / 144; static const double haloTiltRadians = 2.5 * math.pi / 180; @@ -102,11 +102,18 @@ _sparkles = [ ]; class _SupporterHaloWingsState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: _Motion.period, ); + + /// Opacity of the halo and wings, below 1 only while fading in. + late final AnimationController _reveal = AnimationController( + vsync: this, + duration: _Motion.reveal, + value: widget.fadeIn ? 0 : 1, + ); late final AppLifecycleListener _lifecycle; final Key _visibilityKey = UniqueKey(); bool _visible = true; @@ -136,6 +143,13 @@ class _SupporterHaloWingsState extends State @override void didChangeDependencies() { super.didChangeDependencies(); + if (_reveal.isDismissed) { + if (context.reduceMotion) { + _reveal.value = 1; + } else { + _reveal.forward(); + } + } _sync(); } @@ -171,6 +185,7 @@ class _SupporterHaloWingsState extends State @override void dispose() { _lifecycle.dispose(); + _reveal.dispose(); _controller.dispose(); super.dispose(); } @@ -226,6 +241,7 @@ class _SupporterHaloWingsState extends State width: wingWidth, height: wingHeight, child: _CoverFade( + reveal: _reveal, cover: _cover, // The avatar's centre, in this wing's own coordinates. origin: Offset(outset + size / 2, size / 2 - wingTop), @@ -242,6 +258,7 @@ class _SupporterHaloWingsState extends State width: wingWidth, height: wingHeight, child: _CoverFade( + reveal: _reveal, cover: _cover, origin: Offset( wingWidth - outset - size / 2, @@ -269,6 +286,7 @@ class _SupporterHaloWingsState extends State width: haloWidth, height: haloHeight, child: _CoverFade( + reveal: _reveal, cover: _cover, origin: Offset( haloWidth / 2, @@ -373,7 +391,8 @@ class _Group implements _PartTransform { Alignment get origin => Alignment.center; } -/// Hides a halo or wing while the avatar is lifted away by [cover]. +/// Fades a halo or wing in with [reveal], and hides it while the avatar is +/// lifted away by [cover]. /// /// It fades and draws in toward the avatar as the avatar takes off, and /// returns only as the avatar lands, so neither is left floating around an @@ -381,6 +400,7 @@ class _Group implements _PartTransform { /// is interrupted or swiped back. class _CoverFade extends StatelessWidget { const _CoverFade({ + required this.reveal, required this.cover, required this.origin, required this.child, @@ -394,6 +414,8 @@ class _CoverFade extends StatelessWidget { static const double _hiddenScale = 0.85; + /// Fades the part in when supporter status arrives after the avatar. + final Animation reveal; final Animation? cover; final Offset origin; final Widget child; @@ -401,26 +423,29 @@ class _CoverFade extends StatelessWidget { @override Widget build(BuildContext context) { final cover = this.cover; - if (cover == null) return child; + if (cover == null) return FadeTransition(opacity: reveal, child: child); final reduceMotion = context.reduceMotion; - return AnimatedBuilder( - animation: cover, - builder: (context, child) { - final hidden = reduceMotion - ? (cover.value > 0 ? 1.0 : 0.0) - : (cover.status == AnimationStatus.reverse ? _land : _leave) - .transform(cover.value); - final scale = 1 - (1 - _hiddenScale) * hidden; - return Opacity( - opacity: 1 - hidden, - child: Transform( - origin: origin, - transform: Matrix4.diagonal3Values(scale, scale, 1), - child: child, - ), - ); - }, - child: child, + return FadeTransition( + opacity: reveal, + child: AnimatedBuilder( + animation: cover, + builder: (context, child) { + final hidden = reduceMotion + ? (cover.value > 0 ? 1.0 : 0.0) + : (cover.status == AnimationStatus.reverse ? _land : _leave) + .transform(cover.value); + final scale = 1 - (1 - _hiddenScale) * hidden; + return Opacity( + opacity: 1 - hidden, + child: Transform( + origin: origin, + transform: Matrix4.diagonal3Values(scale, scale, 1), + child: child, + ), + ); + }, + child: child, + ), ); } } diff --git a/mobile/lib/widgets/supporter_membership.dart b/mobile/lib/widgets/supporter_membership.dart index 7ac990f41b..3beed68c34 100644 --- a/mobile/lib/widgets/supporter_membership.dart +++ b/mobile/lib/widgets/supporter_membership.dart @@ -1,5 +1,4 @@ // ABOUTME: Account-scoped supporter acknowledgement and discovery entry points. -// ABOUTME: Public profile badges use only the opt-in public recognition endpoint. import 'dart:async'; @@ -8,7 +7,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; -import 'package:openvine/blocs/supporter/public_supporter_cubit.dart'; import 'package:openvine/blocs/supporter/supporter_cubit.dart'; import 'package:openvine/blocs/supporter/supporter_state.dart'; import 'package:openvine/l10n/l10n.dart'; @@ -82,44 +80,6 @@ class SupporterMembership extends ConsumerWidget { } } -class PublicSupporterBadge extends ConsumerWidget { - const PublicSupporterBadge({required this.pubkey, super.key}); - - final String pubkey; - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (!ref.watch(supporterApiConfiguredProvider)) { - return const SizedBox.shrink(); - } - final client = ref.watch(supporterApiClientProvider); - if (client == null) return const SizedBox.shrink(); - return BlocProvider( - key: ValueKey((client, pubkey)), - create: (_) { - final cubit = PublicSupporterCubit(client: client, pubkey: pubkey); - unawaited(cubit.load()); - return cubit; - }, - child: BlocBuilder( - builder: (context, visible) => visible - ? Material( - type: MaterialType.transparency, - child: Chip( - avatar: DivineIcon( - icon: DivineIconName.heart, - size: 16, - color: context.vineColors.primaryText, - ), - label: Text(context.l10n.supporterBadgeLabel), - ), - ) - : const SizedBox.shrink(), - ), - ); - } -} - class _MembershipEntry extends StatelessWidget { const _MembershipEntry({ required this.compact, diff --git a/mobile/test/helpers/test_provider_overrides.dart b/mobile/test/helpers/test_provider_overrides.dart index a3eff76dc6..9c155aeb1a 100644 --- a/mobile/test/helpers/test_provider_overrides.dart +++ b/mobile/test/helpers/test_provider_overrides.dart @@ -37,6 +37,7 @@ import 'package:openvine/services/nip05_verification_service.dart'; import 'package:openvine/services/og_diviner_eligibility_service.dart'; import 'package:openvine/services/openvine_media_cache.dart'; import 'package:openvine/services/subscription_manager.dart'; +import 'package:openvine/services/supporter_api_client.dart'; import 'package:openvine/services/user_ranking/user_ranking_signals_loader.dart'; import 'package:openvine/services/video_event_service.dart'; import 'package:profile_repository/profile_repository.dart'; @@ -430,6 +431,7 @@ List getStandardTestOverrides({ ModerationLabelService? mockModerationLabelService, FollowRepository? mockFollowRepository, UserRankingSignalsLoader? rankingSignalsLoader, + SupporterApiClient? supporterApiClient, }) { final mockPrefs = mockSharedPreferences ?? createMockSharedPreferences(); final mockAuth = mockAuthService ?? createMockAuthService(); @@ -459,7 +461,7 @@ List getStandardTestOverrides({ documentsPathProvider.overrideWithValue('/documents'), // Profile badges must not start HTTP requests or a native store connection // in unrelated widget tests. Dedicated supporter tests supply their own API. - supporterApiClientProvider.overrideWithValue(null), + supporterApiClientProvider.overrideWithValue(supporterApiClient), entitlementValidatorProvider.overrideWithValue(StubEntitlementValidator()), // Analytics has its own focused tests. Other widget tests must not create // its transport, timers, or session listeners as an incidental side effect. diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 5eafc1852b..e6c3d5ff40 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -31,6 +31,7 @@ import 'package:openvine/features/monetization/monetization_storefront_policy.da import 'package:openvine/features/people_lists/bloc/people_lists_bloc.dart'; import 'package:openvine/features/people_lists/view/people_list_membership_indicator.dart'; import 'package:openvine/l10n/l10n.dart'; +import 'package:openvine/models/auth_rpc_capability.dart'; import 'package:openvine/models/divine_video_draft.dart'; import 'package:openvine/providers/account_enforcement_providers.dart'; import 'package:openvine/providers/app_providers.dart'; @@ -45,6 +46,7 @@ import 'package:openvine/screens/other_profile_screen.dart'; import 'package:openvine/services/auth_service.dart' hide UserProfile; import 'package:openvine/services/og_viner_cache_service.dart'; import 'package:openvine/services/stats_visibility_preferences.dart'; +import 'package:openvine/services/supporter_api_client.dart'; import 'package:openvine/utils/divine_login_banner_dismissal.dart'; import 'package:openvine/utils/nostr_key_utils.dart'; import 'package:openvine/utils/secure_account_prompt_dismissal.dart'; @@ -56,6 +58,7 @@ import 'package:openvine/widgets/profile/profile_actions_sheet/profile_actions_s import 'package:openvine/widgets/profile/profile_header_widget.dart'; import 'package:openvine/widgets/profile/profile_stats_row_widget.dart'; import 'package:openvine/widgets/profile/profile_website_row.dart'; +import 'package:openvine/widgets/profile/supporter_halo_wings.dart'; import 'package:openvine/widgets/profile/verified_accounts_row.dart'; import 'package:openvine/widgets/special_profile_checkmark.dart'; import 'package:openvine/widgets/supporter_membership.dart'; @@ -65,6 +68,7 @@ import 'package:openvine/widgets/vine_cached_image.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:verifier_client/verifier_client.dart'; +import 'package:visibility_detector/visibility_detector.dart'; import '../../helpers/finders.dart'; import '../../helpers/go_router.dart'; @@ -89,6 +93,8 @@ class _MockBackgroundPublishBloc extends MockBloc implements BackgroundPublishBloc {} +class _MockSupporterApiClient extends Mock implements SupporterApiClient {} + class _MockBadgeRepository extends Mock implements BadgeRepository {} // Mock classes @@ -210,6 +216,15 @@ class MockAuthService extends Mock implements AuthService { Stream get authStateStream => Stream.value(AuthState.authenticated); + @override + Stream get authRpcCapabilityStream => const Stream.empty(); + + @override + AuthRpcCapability get authRpcCapability => AuthRpcCapability.unavailable; + + @override + bool get canPublishNostrWritesNow => false; + @override bool get hasExpiredOAuthSession => expiredOAuthSession.value; @@ -387,6 +402,7 @@ void main() { bool eligibilityIsLoading = false, Nip05VerificationStatus verificationStatus = Nip05VerificationStatus.verified, + SupporterApiClient? supporterClient, }) { // Pass authService when the test needs the same instance across pumps — // e.g. to read tryRefreshCallCount after the header has been unmounted. @@ -486,13 +502,17 @@ void main() { final scoped = ProviderScope( overrides: [ - // Membership has separate account/lifecycle coverage. - supporterApiConfiguredProvider.overrideWithValue(false), + // Membership has separate account/lifecycle coverage; a supporter + // client is only wired in by the public supporter status tests. + supporterApiConfiguredProvider.overrideWithValue( + supporterClient != null, + ), ...getStandardTestOverrides( mockNostrService: mockNostrClient, mockSharedPreferences: sharedPreferences, mockNip05VerificationService: createMockNip05VerificationService(), mockFollowRepository: mockFollowRepository, + supporterApiClient: supporterClient, ), profileVanishedProvider(userIdHex).overrideWith((ref) => isVanished), if ((profile ?? suppliedProfile)?.nip05 case final String claim) @@ -659,8 +679,8 @@ void main() { } testWidgets( - 'shows the ${ownProfile ? 'private membership chip' : 'public supporter badge'} ' - 'on ${ownProfile ? 'your own' : "someone else's"} profile', + '${ownProfile ? 'shows' : 'hides'} the private membership chip on ' + '${ownProfile ? 'your own' : "someone else's"} profile', (tester) async { // The private chip reads the signed-in account's membership, so on // anyone else's profile it would show the viewer's status, not theirs. @@ -677,10 +697,6 @@ void main() { find.byType(SupporterMembership), ownProfile ? findsOneWidget : findsNothing, ); - expect( - find.byType(PublicSupporterBadge), - ownProfile ? findsNothing : findsOneWidget, - ); }, ); @@ -699,11 +715,144 @@ void main() { await tester.pump(); expect(find.byType(SupporterMembership), findsNothing); - expect(find.byType(PublicSupporterBadge), findsNothing); + expect(find.byType(SupporterHaloWings), findsNothing); }, ); } + group('public supporter status', () { + const supporterPicture = 'https://example.com/supporter.jpg'; + final l10n = lookupAppLocalizations(const Locale('en')); + late Duration visibilityInterval; + + // The halo pauses off-screen; a zero interval keeps its visibility + // detector from leaving a timer behind when the tree is torn down. + setUp(() { + visibilityInterval = + VisibilityDetectorController.instance.updateInterval; + VisibilityDetectorController.instance.updateInterval = Duration.zero; + }); + + tearDown(() { + VisibilityDetectorController.instance.updateInterval = + visibilityInterval; + }); + + SupporterApiClient supporterClient({required bool isPublicSupporter}) { + final client = _MockSupporterApiClient(); + when( + () => client.fetchPublicRecognition(any()), + ).thenAnswer((_) async => isPublicSupporter); + return client; + } + + Future pumpHeader( + WidgetTester tester, { + required bool isOwnProfile, + required bool isPublicSupporter, + }) async { + await tester.pumpWidget( + buildTestWidget( + userIdHex: testUserHex, + isOwnProfile: isOwnProfile, + suppliedProfile: createTestProfile( + displayName: 'Ivy', + picture: supporterPicture, + ), + supporterClient: supporterClient( + isPublicSupporter: isPublicSupporter, + ), + // Holds the halo still so the tree can settle. + disableAnimations: true, + ), + ); + await tester.pumpAndSettle(); + } + + group('renders', () { + for (final isOwnProfile in [true, false]) { + final whose = isOwnProfile ? 'your own' : "someone else's"; + + testWidgets('shows the halo and wings on $whose public supporter ' + 'profile', (tester) async { + await pumpHeader( + tester, + isOwnProfile: isOwnProfile, + isPublicSupporter: true, + ); + + expect(find.byType(SupporterHaloWings), findsOneWidget); + }); + + testWidgets('shows no halo on $whose profile without public ' + 'supporter status', (tester) async { + await pumpHeader( + tester, + isOwnProfile: isOwnProfile, + isPublicSupporter: false, + ); + + expect(find.byType(SupporterHaloWings), findsNothing); + }); + } + + testWidgets('no longer shows the old supporter label', (tester) async { + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + ); + + expect(find.text(l10n.supporterBadgeLabel), findsNothing); + }); + }); + + group('interactions', () { + testWidgets("tapping another supporter's avatar opens the supporter " + 'view', (tester) async { + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + ); + + await tester.tap(find.byType(UserAvatar)); + // The pushed route keeps the halo looping, so it never settles; + // the first frame only lays the route out offstage for the Hero. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text(l10n.supporterLightboxBody), findsOneWidget); + }); + + for (final (label, isOwnProfile, isPublicSupporter) in [ + ("someone else's non-supporter avatar", false, false), + ('your own supporter avatar', true, true), + ]) { + testWidgets('tapping $label opens the photo lightbox', ( + tester, + ) async { + await pumpHeader( + tester, + isOwnProfile: isOwnProfile, + isPublicSupporter: isPublicSupporter, + ); + + await tester.tap(find.byType(UserAvatar)); + await tester.pumpAndSettle(); + + expect( + tester + .widgetList(find.byType(UserAvatar)) + .where((avatar) => avatar.size == 288), + isNotEmpty, + ); + expect(find.text(l10n.supporterLightboxBody), findsNothing); + }); + } + }); + }); + testWidgets('opens accepted NIP-58 badge details from profile header', ( tester, ) async { diff --git a/mobile/test/widgets/supporter_membership_test.dart b/mobile/test/widgets/supporter_membership_test.dart index 8347f2eadc..c80ca3bd65 100644 --- a/mobile/test/widgets/supporter_membership_test.dart +++ b/mobile/test/widgets/supporter_membership_test.dart @@ -279,96 +279,4 @@ void main() { ); } }); - - group('public profile recognition', () { - for (final visible in [true, false]) { - testWidgets('renders only an opted-in public result: $visible', ( - tester, - ) async { - var signatures = 0; - final client = SupporterApiClient( - baseUri: Uri.parse('https://supporters.test'), - authHeaderProvider: ({required url, required method, payload}) async { - signatures++; - return null; - }, - httpClient: MockClient( - (_) async => http.Response( - jsonEncode({ - 'supporters': visible - ? [ - {'pubkey': pubkey, 'haloVisible': true}, - ] - : [], - }), - 200, - ), - ), - ); - addTearDown(client.dispose); - await tester.pumpWidget( - ProviderScope( - overrides: [supporterApiClientProvider.overrideWithValue(client)], - child: buildLocalizedWidget( - const Scaffold(body: PublicSupporterBadge(pubkey: pubkey)), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.text('Supporter'), visible ? findsOneWidget : findsNothing); - expect(signatures, 0); - await tester.pumpWidget(const SizedBox.shrink()); - }); - } - - for (final (mode, theme, colors) in [ - ('light', VineTheme.lightTheme, VineTheme.lightColors), - ('dark', VineTheme.theme, VineTheme.darkColors), - ]) { - testWidgets('badge tints its heart with $mode primary text', ( - tester, - ) async { - final client = SupporterApiClient( - baseUri: Uri.parse('https://supporters.test'), - authHeaderProvider: ({ - required url, - required method, - payload, - }) async => null, - httpClient: MockClient( - (_) async => http.Response( - jsonEncode({ - 'supporters': [ - {'pubkey': pubkey, 'haloVisible': true}, - ], - }), - 200, - ), - ), - ); - addTearDown(client.dispose); - await tester.pumpWidget( - ProviderScope( - overrides: [supporterApiClientProvider.overrideWithValue(client)], - child: MaterialApp( - theme: theme, - localizationsDelegates: appLocalizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold(body: PublicSupporterBadge(pubkey: pubkey)), - ), - ), - ); - await tester.pumpAndSettle(); - - final heart = tester.widget( - find.descendant( - of: find.byType(Chip), - matching: find.byType(DivineIcon), - ), - ); - expect(heart.color, equals(colors.primaryText)); - await tester.pumpWidget(const SizedBox.shrink()); - }); - } - }); } From eb7589dcee52e83c86ced01fc98616629e909fe7 Mon Sep 17 00:00:00 2001 From: Sebastian Heit Date: Thu, 15 Oct 2026 20:19:17 +0200 Subject: [PATCH 05/32] fix(profile): stop the supporter halo flickering after the lightbox closes A Hero swaps its child for a placeholder while it flies, so the halo and wings remounted on landing and replayed their status fade-in. The fade now lives above the Hero and only plays when supporter status first arrives. Refs #9914 Co-Authored-By: Claude --- .../widgets/profile/profile_header_media.dart | 35 ++++---- .../profile_header_supporter_lightbox.dart | 6 +- .../widgets/profile/supporter_halo_wings.dart | 86 +++++++++++++------ .../profile/profile_header_widget_test.dart | 51 +++++++++++ 4 files changed, 138 insertions(+), 40 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_media.dart b/mobile/lib/widgets/profile/profile_header_media.dart index 2cbfa8a2fa..b4a00c8b41 100644 --- a/mobile/lib/widgets/profile/profile_header_media.dart +++ b/mobile/lib/widgets/profile/profile_header_media.dart @@ -508,21 +508,26 @@ class _ProfileAvatarWithColor extends StatelessWidget { ); } - avatar = showSupporterTreatment && opensSupporterLightbox - ? _SupporterAvatarHero( - imageUrl: imageUrl, - userIdHex: userIdHex, - avatarSize: avatarSize, - avatar: avatarWidget, - ) - : showSupporterTreatment - ? SupporterHaloWings( - avatarSize: avatarSize, - fadeIn: true, - builder: (context, trackCover) => - tappableAvatar(onOpened: trackCover), - ) - : tappableAvatar(); + avatar = SupporterReveal( + visible: showSupporterTreatment, + builder: (context, reveal) => + showSupporterTreatment && opensSupporterLightbox + ? _SupporterAvatarHero( + imageUrl: imageUrl, + userIdHex: userIdHex, + avatarSize: avatarSize, + avatar: avatarWidget, + reveal: reveal, + ) + : showSupporterTreatment + ? SupporterHaloWings( + avatarSize: avatarSize, + reveal: reveal, + builder: (context, trackCover) => + tappableAvatar(onOpened: trackCover), + ) + : tappableAvatar(), + ); } if (pendingActions.isEmpty) return avatar; diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index 574104a7f2..5679a3ffb2 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -25,6 +25,7 @@ class _SupporterAvatarHero extends StatelessWidget { required this.userIdHex, required this.avatarSize, required this.avatar, + required this.reveal, this.imageUrl, }); @@ -33,6 +34,9 @@ class _SupporterAvatarHero extends StatelessWidget { final double avatarSize; final Widget avatar; + /// Owned outside the Hero, which remounts its child when a flight lands. + final Animation reveal; + @override Widget build(BuildContext context) { return Semantics( @@ -50,7 +54,7 @@ class _SupporterAvatarHero extends StatelessWidget { flightShuttleBuilder: _supporterShuttle(imageUrl, userIdHex), child: SupporterHaloWings( avatarSize: avatarSize, - fadeIn: true, + reveal: reveal, builder: (_, _) => avatar, ), ), diff --git a/mobile/lib/widgets/profile/supporter_halo_wings.dart b/mobile/lib/widgets/profile/supporter_halo_wings.dart index 2876f2777a..c5f7575a95 100644 --- a/mobile/lib/widgets/profile/supporter_halo_wings.dart +++ b/mobile/lib/widgets/profile/supporter_halo_wings.dart @@ -22,16 +22,16 @@ class SupporterHaloWings extends StatefulWidget { required this.avatarSize, required this.builder, this.sparkles, - this.fadeIn = false, + this.reveal, super.key, }); final double avatarSize; - /// Whether the halo and wings fade in when this first appears, rather than - /// being there from the start. Set where supporter status arrives after the - /// avatar is already on screen; left off where the group flies in whole. - final bool fadeIn; + /// Opacity of the halo and wings, from a [SupporterReveal] above any Hero + /// so a remount after a flight does not fade them in again. Null shows them + /// fully. + final Animation? reveal; /// When set, twinkling sparkles surround the group, faded in by this value. /// Used where the supporter status is the subject, such as its lightbox. @@ -46,6 +46,59 @@ class SupporterHaloWings extends StatefulWidget { State createState() => _SupporterHaloWingsState(); } +/// Fades the supporter treatment in when [visible] turns on while mounted, +/// and hands that animation to [builder]. +/// +/// Sits above the avatar Hero: a Hero swaps its child for a placeholder while +/// it flies, so state kept inside it restarts when the flight lands. +class SupporterReveal extends StatefulWidget { + const SupporterReveal({ + required this.visible, + required this.builder, + super.key, + }); + + /// Whether the treatment is shown. Visible from the first build means no + /// fade, so a cached status appears with the avatar. + final bool visible; + + final Widget Function(BuildContext context, Animation reveal) builder; + + @override + State createState() => _SupporterRevealState(); +} + +class _SupporterRevealState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _reveal = AnimationController( + vsync: this, + duration: _Motion.reveal, + value: widget.visible ? 1 : 0, + ); + + @override + void didUpdateWidget(SupporterReveal oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.visible == oldWidget.visible) return; + if (!widget.visible) { + _reveal.value = 0; + } else if (context.reduceMotion) { + _reveal.value = 1; + } else { + _reveal.forward(from: 0); + } + } + + @override + void dispose() { + _reveal.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _reveal); +} + /// Figma geometry, as fractions of the 144pt avatar. abstract class _Geometry { static const double _figmaAvatar = 144; @@ -102,18 +155,11 @@ _sparkles = [ ]; class _SupporterHaloWingsState extends State - with TickerProviderStateMixin { + with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: _Motion.period, ); - - /// Opacity of the halo and wings, below 1 only while fading in. - late final AnimationController _reveal = AnimationController( - vsync: this, - duration: _Motion.reveal, - value: widget.fadeIn ? 0 : 1, - ); late final AppLifecycleListener _lifecycle; final Key _visibilityKey = UniqueKey(); bool _visible = true; @@ -143,13 +189,6 @@ class _SupporterHaloWingsState extends State @override void didChangeDependencies() { super.didChangeDependencies(); - if (_reveal.isDismissed) { - if (context.reduceMotion) { - _reveal.value = 1; - } else { - _reveal.forward(); - } - } _sync(); } @@ -185,7 +224,6 @@ class _SupporterHaloWingsState extends State @override void dispose() { _lifecycle.dispose(); - _reveal.dispose(); _controller.dispose(); super.dispose(); } @@ -241,7 +279,7 @@ class _SupporterHaloWingsState extends State width: wingWidth, height: wingHeight, child: _CoverFade( - reveal: _reveal, + reveal: widget.reveal ?? kAlwaysCompleteAnimation, cover: _cover, // The avatar's centre, in this wing's own coordinates. origin: Offset(outset + size / 2, size / 2 - wingTop), @@ -258,7 +296,7 @@ class _SupporterHaloWingsState extends State width: wingWidth, height: wingHeight, child: _CoverFade( - reveal: _reveal, + reveal: widget.reveal ?? kAlwaysCompleteAnimation, cover: _cover, origin: Offset( wingWidth - outset - size / 2, @@ -286,7 +324,7 @@ class _SupporterHaloWingsState extends State width: haloWidth, height: haloHeight, child: _CoverFade( - reveal: _reveal, + reveal: widget.reveal ?? kAlwaysCompleteAnimation, cover: _cover, origin: Offset( haloWidth / 2, diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index e6c3d5ff40..a9c86f51ad 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -808,6 +808,57 @@ void main() { }); group('interactions', () { + testWidgets('keeps the halo and wings at full opacity when the ' + 'supporter view closes', (tester) async { + await tester.pumpWidget( + buildTestWidget( + userIdHex: testUserHex, + isOwnProfile: false, + suppliedProfile: createTestProfile( + displayName: 'Ivy', + picture: supporterPicture, + ), + supporterClient: supporterClient(isPublicSupporter: true), + ), + ); + // The halo loops forever with motion allowed, so step past the + // status load and its fade-in instead of settling. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + await tester.tap(find.byType(UserAvatar)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + Navigator.of( + tester.element(find.text(l10n.supporterLightboxBody)), + ).pop(); + // Step frame by frame: a single long pump lands the flight + // without the Hero ever swapping in its placeholder. ~480 ms covers + // the 300 ms return flight with a restarted fade still short of 1. + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + for (final part in [ + 'assets/supporter/halo.png', + 'assets/supporter/wing_left.png', + 'assets/supporter/wing_right.png', + ]) { + final fades = tester.widgetList( + find.ancestor( + of: find.image(AssetImage(part)), + matching: find.byType(FadeTransition), + ), + ); + expect(fades, isNotEmpty, reason: part); + expect( + fades.map((fade) => fade.opacity.value), + everyElement(1.0), + reason: part, + ); + } + }); + testWidgets("tapping another supporter's avatar opens the supporter " 'view', (tester) async { await pumpHeader( From 581aaf3a438875fb022c3eb51a69340a3e2962eb Mon Sep 17 00:00:00 2001 From: Sebastian Heit Date: Mon, 19 Oct 2026 21:34:01 +0200 Subject: [PATCH 06/32] feat(profile): replace the supporter label with the own supporter view The halo and wings already mark a supporter, so the heart label on the owner's profile was redundant. The owner's halo now follows their public status, and tapping it opens a supporter view in the same style as the one other people see. Refs #9914 Co-Authored-By: Claude --- .../lib/blocs/supporter/supporter_cubit.dart | 8 +- .../lib/blocs/supporter/supporter_state.dart | 21 +- .../profile/profile_header_identity.dart | 4 +- .../widgets/profile/profile_header_media.dart | 100 ++++----- .../profile_header_supporter_lightbox.dart | 193 ++++++++++++---- .../profile/profile_header_widget.dart | 5 +- .../widgets/profile/supporter_halo_wings.dart | 116 +--------- mobile/lib/widgets/supporter_membership.dart | 88 ++++++-- .../profile/profile_header_widget_test.dart | 209 +++++++++++++++--- .../widgets/supporter_membership_test.dart | 27 ++- 10 files changed, 504 insertions(+), 267 deletions(-) diff --git a/mobile/lib/blocs/supporter/supporter_cubit.dart b/mobile/lib/blocs/supporter/supporter_cubit.dart index eebf497f56..1eab63ad55 100644 --- a/mobile/lib/blocs/supporter/supporter_cubit.dart +++ b/mobile/lib/blocs/supporter/supporter_cubit.dart @@ -87,7 +87,7 @@ class SupporterCubit extends Cubit { Future setPublicRecognition(bool visible) async { final snapshot = state.snapshot; if (snapshot == null || state.savingRecognition) return; - _emit(state.copyWith(savingRecognition: true, clearFailure: true)); + _emit(state.copyWith(requestedHaloVisible: visible, clearFailure: true)); try { await _repository.updateRecognition( haloVisible: visible, @@ -97,12 +97,12 @@ class SupporterCubit extends Cubit { _emit( state.copyWith( snapshot: _repository.snapshot, - savingRecognition: false, + clearRequestedHaloVisible: true, ), ); } on SupporterApiException catch (error) { _emitApiFailure(error); - _emit(state.copyWith(savingRecognition: false)); + _emit(state.copyWith(clearRequestedHaloVisible: true)); } on Object catch (error, stackTrace) { // A failure outside the typed contract, such as a signer error, must // still end the save, or the recognition toggle stays disabled. @@ -114,7 +114,7 @@ class SupporterCubit extends Cubit { state.copyWith( status: SupporterStatus.error, failure: SupporterFailure.unknown, - savingRecognition: false, + clearRequestedHaloVisible: true, ), ); } diff --git a/mobile/lib/blocs/supporter/supporter_state.dart b/mobile/lib/blocs/supporter/supporter_state.dart index f7a8813506..212eef2544 100644 --- a/mobile/lib/blocs/supporter/supporter_state.dart +++ b/mobile/lib/blocs/supporter/supporter_state.dart @@ -42,7 +42,7 @@ enum SupporterFailure { class SupporterState extends Equatable { const SupporterState({ this.snapshot, - this.savingRecognition = false, + this.requestedHaloVisible, this.tiers = const [], this.entitlement = SupporterEntitlement.inactive, this.status = SupporterStatus.idle, @@ -51,7 +51,10 @@ class SupporterState extends Equatable { }); final SupporterAccountSnapshot? snapshot; - final bool savingRecognition; + + /// The public recognition being saved, shown until the save settles so the + /// setting flips as soon as it is changed. Null when nothing is saving. + final bool? requestedHaloVisible; /// Purchasable supporter tiers loaded from the store. final List tiers; @@ -66,6 +69,11 @@ class SupporterState extends Equatable { final bool awaitingPurchaseConfirmation; bool get isSupporter => entitlement.isSupporter; + bool get savingRecognition => requestedHaloVisible != null; + + /// Whether supporter status shows publicly, including a change still saving. + bool get haloVisible => + requestedHaloVisible ?? snapshot?.haloVisible ?? false; bool get isConfirmedInactive => snapshot?.status == SupporterServerStatus.expired; bool get isBusy => @@ -78,7 +86,8 @@ class SupporterState extends Equatable { SupporterState copyWith({ SupporterAccountSnapshot? snapshot, - bool? savingRecognition, + bool? requestedHaloVisible, + bool clearRequestedHaloVisible = false, List? tiers, SupporterEntitlement? entitlement, SupporterStatus? status, @@ -88,7 +97,9 @@ class SupporterState extends Equatable { }) { return SupporterState( snapshot: snapshot ?? this.snapshot, - savingRecognition: savingRecognition ?? this.savingRecognition, + requestedHaloVisible: clearRequestedHaloVisible + ? null + : requestedHaloVisible ?? this.requestedHaloVisible, awaitingPurchaseConfirmation: awaitingPurchaseConfirmation ?? this.awaitingPurchaseConfirmation, tiers: tiers ?? this.tiers, @@ -101,7 +112,7 @@ class SupporterState extends Equatable { @override List get props => [ snapshot, - savingRecognition, + requestedHaloVisible, tiers, entitlement, status, diff --git a/mobile/lib/widgets/profile/profile_header_identity.dart b/mobile/lib/widgets/profile/profile_header_identity.dart index f9fb13f3a1..77abcafc2e 100644 --- a/mobile/lib/widgets/profile/profile_header_identity.dart +++ b/mobile/lib/widgets/profile/profile_header_identity.dart @@ -64,8 +64,8 @@ class _ProfileNameAndBio extends StatelessWidget { ], ), ), - // Public supporter status shows as the halo and wings around the - // avatar; this chip is the owner's private entry to membership. + // Supporter status shows as the halo and wings around the avatar; + // this entry invites the owner to join and hides once they have. if (!isVanished && isOwnProfile) const SupporterMembership(compact: true), Skeleton.keep(child: _ProfileBadgesBlock(userIdHex: userIdHex)), diff --git a/mobile/lib/widgets/profile/profile_header_media.dart b/mobile/lib/widgets/profile/profile_header_media.dart index b4a00c8b41..bf69a8cdcc 100644 --- a/mobile/lib/widgets/profile/profile_header_media.dart +++ b/mobile/lib/widgets/profile/profile_header_media.dart @@ -443,7 +443,7 @@ class _ProfileAvatarWithColor extends StatelessWidget { required this.showSkeleton, this.profileColor, this.pendingActions = const [], - this.opensSupporterLightbox = false, + this.isOwnProfile = false, this.onActionTap, }); @@ -466,9 +466,9 @@ class _ProfileAvatarWithColor extends StatelessWidget { /// Called when the label or avatar is tapped. final VoidCallback? onActionTap; - /// Whether a tap opens the supporter lightbox, where the halo and wings fly - /// along with the avatar, instead of the plain photo lightbox. - final bool opensSupporterLightbox; + /// Whether this is the viewer's own profile, which reflects their own + /// membership and opens their supporter view, with its public setting. + final bool isOwnProfile; @override Widget build(BuildContext context) { @@ -483,50 +483,42 @@ class _ProfileAvatarWithColor extends StatelessWidget { size: avatarSize, ); final hasAvatar = imageUrl != null && imageUrl!.isNotEmpty; - // Public status arrives after the header renders; the halo and wings - // fade in around the avatar, which stays put. - final showSupporterTreatment = _isPublicSupporter(context); - Widget tappableAvatar({void Function(Animation)? onOpened}) { - if (!hasAvatar) return avatarWidget; - return GestureDetector( - onTap: () { - final transition = _showAvatarLightbox( - context, - imageUrl: imageUrl, - userIdHex: userIdHex, - ); - onOpened?.call(transition); - }, - child: Hero( - tag: _avatarHeroTag(userIdHex), - flightShuttleBuilder: (_, _, _, _, _) => _AvatarHeroFlightShuttle( - imageUrl: imageUrl, - userIdHex: userIdHex, - ), - child: avatarWidget, - ), - ); - } - + final plainAvatar = hasAvatar + ? GestureDetector( + onTap: () => _showAvatarLightbox( + context, + imageUrl: imageUrl, + userIdHex: userIdHex, + ), + child: Hero( + tag: _avatarHeroTag(userIdHex), + flightShuttleBuilder: (_, _, _, _, _) => + _AvatarHeroFlightShuttle( + imageUrl: imageUrl, + userIdHex: userIdHex, + ), + child: avatarWidget, + ), + ) + : avatarWidget; + // Status arrives after the header renders; the halo and wings fade in + // around the avatar, which stays put. + final isSupporter = _showsSupporterTreatment( + context, + isOwnProfile: isOwnProfile, + ); avatar = SupporterReveal( - visible: showSupporterTreatment, - builder: (context, reveal) => - showSupporterTreatment && opensSupporterLightbox + visible: isSupporter, + builder: (context, reveal) => isSupporter ? _SupporterAvatarHero( imageUrl: imageUrl, userIdHex: userIdHex, avatarSize: avatarSize, avatar: avatarWidget, reveal: reveal, + isOwnProfile: isOwnProfile, ) - : showSupporterTreatment - ? SupporterHaloWings( - avatarSize: avatarSize, - reveal: reveal, - builder: (context, trackCover) => - tappableAvatar(onOpened: trackCover), - ) - : tappableAvatar(), + : plainAvatar, ); } @@ -728,9 +720,7 @@ class _AvatarHeroFlightShuttle extends StatelessWidget { } } -/// Opens the lightbox and returns its route animation, so decorations around -/// the header avatar can follow the transition both ways. -Animation _showAvatarLightbox( +void _showAvatarLightbox( BuildContext context, { required String userIdHex, String? imageUrl, @@ -743,23 +733,23 @@ Animation _showAvatarLightbox( // from the current PageRoute of nested navigators, so the header avatar // (branch) and the lightbox (root) are matched. A PageRoute — not a // PopupRoute like showGeneralDialog — is required for the HeroController. - final route = PageRouteBuilder( - opaque: false, - barrierColor: VineTheme.transparent, - barrierDismissible: true, - barrierLabel: context.l10n.profileAvatarLightboxBarrierLabel, - pageBuilder: (context, _, _) => - _AvatarLightbox(imageUrl: imageUrl, userIdHex: userIdHex), - transitionsBuilder: (context, animation, _, child) => - FadeTransition(opacity: animation, child: child), - ); _runProfileDetached( _awaitProfileResult( - Navigator.of(context, rootNavigator: true).push(route), + Navigator.of(context, rootNavigator: true).push( + PageRouteBuilder( + opaque: false, + barrierColor: VineTheme.transparent, + barrierDismissible: true, + barrierLabel: context.l10n.profileAvatarLightboxBarrierLabel, + pageBuilder: (context, _, _) => + _AvatarLightbox(imageUrl: imageUrl, userIdHex: userIdHex), + transitionsBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ), + ), ), 'open avatar lightbox', ); - return route.animation!; } class _AvatarLightbox extends StatelessWidget { diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index 5679a3ffb2..b1cd3bf62c 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -15,7 +15,7 @@ const Interval _supporterContentReveal = Interval( /// Share of the route transition over which the sparkles fade in. const Interval _supporterSparkleReveal = Interval(0.4, 1); -/// Header avatar of another user's supporter profile. +/// Header avatar of a supporter's profile. /// /// The whole group — avatar, wings and halo — is the Hero, so the treatment /// flies into the supporter lightbox and keeps moving instead of being left @@ -26,6 +26,7 @@ class _SupporterAvatarHero extends StatelessWidget { required this.avatarSize, required this.avatar, required this.reveal, + required this.isOwnProfile, this.imageUrl, }); @@ -37,6 +38,10 @@ class _SupporterAvatarHero extends StatelessWidget { /// Owned outside the Hero, which remounts its child when a flight lands. final Animation reveal; + /// Opens the owner's own supporter view rather than the one other viewers + /// see. + final bool isOwnProfile; + @override Widget build(BuildContext context) { return Semantics( @@ -47,6 +52,7 @@ class _SupporterAvatarHero extends StatelessWidget { context, imageUrl: imageUrl, userIdHex: userIdHex, + isOwnProfile: isOwnProfile, ), child: Hero( tag: _avatarHeroTag(userIdHex), @@ -55,7 +61,7 @@ class _SupporterAvatarHero extends StatelessWidget { child: SupporterHaloWings( avatarSize: avatarSize, reveal: reveal, - builder: (_, _) => avatar, + avatar: avatar, ), ), ), @@ -74,7 +80,7 @@ HeroFlightShuttleBuilder _supporterShuttle(String? imageUrl, String userIdHex) { parent: animation, curve: _supporterSparkleReveal, ), - builder: (_, _) => UserAvatar( + avatar: UserAvatar( imageUrl: imageUrl, placeholderSeed: userIdHex, size: _supporterLightboxAvatarSize, @@ -93,6 +99,7 @@ RectTween _supporterRectTween(Rect? begin, Rect? end) => void _showSupporterLightbox( BuildContext context, { required String userIdHex, + required bool isOwnProfile, String? imageUrl, }) { _runProfileDetached( @@ -103,8 +110,11 @@ void _showSupporterLightbox( barrierColor: VineTheme.transparent, barrierDismissible: true, barrierLabel: context.l10n.profileAvatarLightboxBarrierLabel, - pageBuilder: (context, _, _) => - _SupporterLightbox(imageUrl: imageUrl, userIdHex: userIdHex), + pageBuilder: (context, _, _) => _SupporterLightbox( + imageUrl: imageUrl, + userIdHex: userIdHex, + isOwnProfile: isOwnProfile, + ), transitionsBuilder: (context, animation, _, child) => FadeTransition(opacity: animation, child: child), ), @@ -114,13 +124,18 @@ void _showSupporterLightbox( ); } -/// Full-screen view of another user's supporter avatar, explaining what the -/// status means and inviting the viewer to become a supporter too. +/// Full-screen view of a supporter's avatar. Other viewers see what the +/// status means and an invitation to join; the owner sees their membership. class _SupporterLightbox extends StatelessWidget { - const _SupporterLightbox({required this.userIdHex, this.imageUrl}); + const _SupporterLightbox({ + required this.userIdHex, + required this.isOwnProfile, + this.imageUrl, + }); final String? imageUrl; final String userIdHex; + final bool isOwnProfile; @override Widget build(BuildContext context) { @@ -177,29 +192,16 @@ class _SupporterLightbox extends StatelessWidget { const SizedBox(height: 30), FadeTransition( opacity: reveal, - child: Column( - children: [ - _SupporterLightboxHeadline( - userIdHex: userIdHex, - ), - const SizedBox(height: 12), - Text( - context.l10n.supporterLightboxBody, - textAlign: TextAlign.center, - style: VineTheme.bodyLargeFont( - color: colors.secondaryText, - ), - ), - const SizedBox(height: 32), - const _SupporterBenefits(), - ], - ), + child: isOwnProfile + ? const _OwnSupporterDetails() + : _SupporterDetails(userIdHex: userIdHex), ), const Spacer(), - FadeTransition( - opacity: reveal, - child: const _SupporterLightboxActions(), - ), + if (!isOwnProfile) + FadeTransition( + opacity: reveal, + child: const _SupporterLightboxActions(), + ), ], ), ), @@ -227,6 +229,85 @@ class _SupporterLightbox extends StatelessWidget { } } +/// What another user's supporter status means. +class _SupporterDetails extends StatelessWidget { + const _SupporterDetails({required this.userIdHex}); + + final String userIdHex; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _SupporterLightboxHeadline(userIdHex: userIdHex), + const SizedBox(height: 12), + Text( + context.l10n.supporterLightboxBody, + textAlign: TextAlign.center, + style: VineTheme.bodyLargeFont( + color: context.vineColors.secondaryText, + ), + ), + const SizedBox(height: 32), + const _SupporterBenefits(), + ], + ); + } +} + +/// The owner's own membership, in the words of the Supporter settings. +class _OwnSupporterDetails extends StatelessWidget { + const _OwnSupporterDetails(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = context.vineColors; + return Column( + children: [ + Text( + l10n.supporterHeroTitle, + textAlign: TextAlign.center, + style: VineTheme.headlineSmallFont(color: colors.primaryText), + ), + const SizedBox(height: 12), + Text( + l10n.supporterMembershipBody, + textAlign: TextAlign.center, + style: VineTheme.bodyLargeFont(color: colors.secondaryText), + ), + const SizedBox(height: 32), + MergeSemantics( + child: Row( + spacing: 12, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: colors.iconButton, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: DivineIcon( + icon: DivineIconName.heart, + color: colors.accentBrand, + ), + ), + ), + Expanded( + child: Text( + l10n.supporterActiveBadge, + style: VineTheme.bodyLargeFont(color: colors.primaryText), + ), + ), + ], + ), + ), + ], + ); + } +} + class _SupporterLightboxAvatar extends StatelessWidget { const _SupporterLightboxAvatar({required this.userIdHex, this.imageUrl}); @@ -243,7 +324,7 @@ class _SupporterLightboxAvatar extends StatelessWidget { child: SupporterHaloWings( avatarSize: size, sparkles: kAlwaysCompleteAnimation, - builder: (_, _) => UserAvatar( + avatar: UserAvatar( imageUrl: imageUrl, placeholderSeed: userIdHex, size: size, @@ -460,19 +541,23 @@ class _SupporterMaybeLater extends StatelessWidget { } } -/// Provides one [PublicSupporterCubit] for a profile, so everything in the -/// header that reflects supporter status reads the same single request. +/// Provides the supporter status every part of the header reads, from one +/// request per profile: the halo and wings show only where the profile's +/// supporter status is public, so a profile looks the same to its owner as +/// to everyone else. /// -/// Only opted-in public recognition is used, for every profile including the -/// viewer's own, so a profile looks the same to its owner as to everyone else. -class _PublicSupporterScope extends ConsumerWidget { - const _PublicSupporterScope({ +/// The owner's profile also gets their membership, which carries a change +/// to their public setting as soon as it is saved. +class _SupporterStatusScope extends ConsumerWidget { + const _SupporterStatusScope({ required this.pubkey, + required this.isOwnProfile, required this.enabled, required this.child, }); final String pubkey; + final bool isOwnProfile; /// False for a deleted account, which never shows supporter status. final bool enabled; @@ -481,8 +566,11 @@ class _PublicSupporterScope extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { if (!enabled || !ref.watch(supporterApiConfiguredProvider)) return child; + final scoped = isOwnProfile + ? SupporterMembershipScope(child: child) + : child; final client = ref.watch(supporterApiClientProvider); - if (client == null) return child; + if (client == null) return scoped; return BlocProvider( key: ValueKey((client, pubkey)), create: (_) { @@ -490,17 +578,40 @@ class _PublicSupporterScope extends ConsumerWidget { unawaited(cubit.load()); return cubit; }, - child: child, + child: scoped, ); } } -/// Whether the profile in the nearest [_PublicSupporterScope] shows its -/// supporter status publicly. False while loading and outside a scope. -bool _isPublicSupporter(BuildContext context) { +/// Whether the header shows the halo and wings: the profile's public +/// supporter status. On the owner's profile, their membership answers as +/// soon as it is known, so changing the public setting shows straight away. +/// False while loading and outside a [_SupporterStatusScope]. +bool _showsSupporterTreatment( + BuildContext context, { + required bool isOwnProfile, +}) { + if (isOwnProfile) { + final own = _ownPublicRecognition(context); + if (own != null) return own; + } try { return context.select((cubit) => cubit.state); } on ProviderNotFoundException { return false; } } + +/// The owner's published supporter status, or null until their account has +/// been read. +bool? _ownPublicRecognition(BuildContext context) { + try { + return context.select((cubit) { + final state = cubit.state; + if (state.snapshot == null) return null; + return state.isSupporter && state.haloVisible; + }); + } on ProviderNotFoundException { + return null; + } +} diff --git a/mobile/lib/widgets/profile/profile_header_widget.dart b/mobile/lib/widgets/profile/profile_header_widget.dart index b823d6fcb1..66f03de953 100644 --- a/mobile/lib/widgets/profile/profile_header_widget.dart +++ b/mobile/lib/widgets/profile/profile_header_widget.dart @@ -412,8 +412,9 @@ class _ProfileHeaderWidgetState extends ConsumerState { enabled: showIdentitySkeleton, enableSwitchAnimation: true, effect: vineSkeletonEffectOf(context), - child: _PublicSupporterScope( + child: _SupporterStatusScope( pubkey: widget.userIdHex, + isOwnProfile: widget.isOwnProfile, enabled: !isVanished, child: Column( mainAxisSize: MainAxisSize.min, @@ -426,7 +427,7 @@ class _ProfileHeaderWidgetState extends ConsumerState { showSkeleton: showIdentitySkeleton, profileColor: profileColor, pendingActions: pendingActions, - opensSupporterLightbox: !widget.isOwnProfile, + isOwnProfile: widget.isOwnProfile, onActionTap: pendingActions.isEmpty ? null : pendingActions.first == diff --git a/mobile/lib/widgets/profile/supporter_halo_wings.dart b/mobile/lib/widgets/profile/supporter_halo_wings.dart index c5f7575a95..217ae7e4e7 100644 --- a/mobile/lib/widgets/profile/supporter_halo_wings.dart +++ b/mobile/lib/widgets/profile/supporter_halo_wings.dart @@ -7,11 +7,7 @@ import 'package:flutter/widgets.dart'; import 'package:openvine/extensions/media_query_extensions.dart'; import 'package:visibility_detector/visibility_detector.dart'; -/// Called with the animation of a route that lifts the avatar away, such as -/// the avatar lightbox, so the halo and wings can follow it out and back. -typedef TrackCoveringTransition = void Function(Animation transition); - -/// Wraps the avatar from [builder] with a halo above it and a wing on each +/// Wraps [avatar] with a halo above it and a wing on each /// side. /// /// Geometry comes from the Figma profile frame, where the avatar is 144pt. @@ -20,7 +16,7 @@ typedef TrackCoveringTransition = void Function(Animation transition); class SupporterHaloWings extends StatefulWidget { const SupporterHaloWings({ required this.avatarSize, - required this.builder, + required this.avatar, this.sparkles, this.reveal, super.key, @@ -37,10 +33,8 @@ class SupporterHaloWings extends StatefulWidget { /// Used where the supporter status is the subject, such as its lightbox. final Animation? sparkles; - /// Builds the avatar. Call the given callback with the route animation when - /// the avatar opens something that flies it away. - final Widget Function(BuildContext context, TrackCoveringTransition track) - builder; + /// The avatar the treatment surrounds, [avatarSize] square. + final Widget avatar; @override State createState() => _SupporterHaloWingsState(); @@ -164,22 +158,6 @@ class _SupporterHaloWingsState extends State final Key _visibilityKey = UniqueKey(); bool _visible = true; - /// The lightbox transition while it is open or closing, else null. - Animation? _cover; - - void _trackCover(Animation transition) { - void onStatus(AnimationStatus status) { - if (status != AnimationStatus.dismissed) return; - transition.removeStatusListener(onStatus); - if (mounted && identical(_cover, transition)) { - setState(() => _cover = null); - } - } - - transition.addStatusListener(onStatus); - setState(() => _cover = transition); - } - @override void initState() { super.initState(); @@ -278,11 +256,8 @@ class _SupporterHaloWingsState extends State top: wingTop, width: wingWidth, height: wingHeight, - child: _CoverFade( - reveal: widget.reveal ?? kAlwaysCompleteAnimation, - cover: _cover, - // The avatar's centre, in this wing's own coordinates. - origin: Offset(outset + size / 2, size / 2 - wingTop), + child: FadeTransition( + opacity: widget.reveal ?? kAlwaysCompleteAnimation, child: _AnimatedPart( animation: _controller, builder: (t) => _Wing(t: t, isLeft: true), @@ -295,13 +270,8 @@ class _SupporterHaloWingsState extends State top: wingTop, width: wingWidth, height: wingHeight, - child: _CoverFade( - reveal: widget.reveal ?? kAlwaysCompleteAnimation, - cover: _cover, - origin: Offset( - wingWidth - outset - size / 2, - size / 2 - wingTop, - ), + child: FadeTransition( + opacity: widget.reveal ?? kAlwaysCompleteAnimation, child: _AnimatedPart( animation: _controller, builder: (t) => _Wing(t: t, isLeft: false), @@ -316,20 +286,15 @@ class _SupporterHaloWingsState extends State top: 0, width: size, height: size, - child: widget.builder(context, _trackCover), + child: widget.avatar, ), Positioned( left: outset + (size - haloWidth) / 2, top: size * _Geometry.haloTop, width: haloWidth, height: haloHeight, - child: _CoverFade( - reveal: widget.reveal ?? kAlwaysCompleteAnimation, - cover: _cover, - origin: Offset( - haloWidth / 2, - size / 2 - size * _Geometry.haloTop, - ), + child: FadeTransition( + opacity: widget.reveal ?? kAlwaysCompleteAnimation, child: _AnimatedPart( animation: _controller, builder: (t) => _Halo(t: t, avatarSize: size), @@ -429,65 +394,6 @@ class _Group implements _PartTransform { Alignment get origin => Alignment.center; } -/// Fades a halo or wing in with [reveal], and hides it while the avatar is -/// lifted away by [cover]. -/// -/// It fades and draws in toward the avatar as the avatar takes off, and -/// returns only as the avatar lands, so neither is left floating around an -/// empty spot. Following the route animation keeps it in step when a close -/// is interrupted or swiped back. -class _CoverFade extends StatelessWidget { - const _CoverFade({ - required this.reveal, - required this.cover, - required this.origin, - required this.child, - }); - - /// Share of the opening transition the fade-out takes (~150 ms of 300). - static const Interval _leave = Interval(0, 0.5, curve: Curves.easeOut); - - /// Share of the closing transition, from its end, that the return takes. - static const Interval _land = Interval(0, 0.3, curve: Curves.easeIn); - - static const double _hiddenScale = 0.85; - - /// Fades the part in when supporter status arrives after the avatar. - final Animation reveal; - final Animation? cover; - final Offset origin; - final Widget child; - - @override - Widget build(BuildContext context) { - final cover = this.cover; - if (cover == null) return FadeTransition(opacity: reveal, child: child); - final reduceMotion = context.reduceMotion; - return FadeTransition( - opacity: reveal, - child: AnimatedBuilder( - animation: cover, - builder: (context, child) { - final hidden = reduceMotion - ? (cover.value > 0 ? 1.0 : 0.0) - : (cover.status == AnimationStatus.reverse ? _land : _leave) - .transform(cover.value); - final scale = 1 - (1 - _hiddenScale) * hidden; - return Opacity( - opacity: 1 - hidden, - child: Transform( - origin: origin, - transform: Matrix4.diagonal3Values(scale, scale, 1), - child: child, - ), - ); - }, - child: child, - ), - ); - } -} - /// Applies a per-frame transform to an image without rebuilding it. class _AnimatedPart extends StatelessWidget { const _AnimatedPart({ diff --git a/mobile/lib/widgets/supporter_membership.dart b/mobile/lib/widgets/supporter_membership.dart index 3beed68c34..90be1046ae 100644 --- a/mobile/lib/widgets/supporter_membership.dart +++ b/mobile/lib/widgets/supporter_membership.dart @@ -16,7 +16,11 @@ import 'package:openvine/providers/auth_providers.dart'; import 'package:openvine/providers/supporter_providers.dart'; import 'package:openvine/screens/settings/supporter_screen.dart'; -/// Private acknowledgement remains visible even when public recognition is off. +/// Entry to supporter membership. The settings tile acknowledges supporters; +/// the compact profile entry hides once the account is a supporter. +/// +/// Reads the [SupporterCubit] of an enclosing [SupporterMembershipScope] when +/// there is one, so it shares that scope's single membership read. class SupporterMembership extends ConsumerWidget { const SupporterMembership({this.compact = false, super.key}); @@ -28,14 +32,36 @@ class SupporterMembership extends ConsumerWidget { return const SizedBox.shrink(); } ref.watch(currentAuthStateProvider); - final auth = ref.watch(authServiceProvider); - if (!auth.isAuthenticated) { + if (!ref.watch(authServiceProvider).isAuthenticated) { return _MembershipEntry( compact: compact, label: context.l10n.supporterTitle, subtitle: context.l10n.supporterTileSubtitle, ); } + final status = _MembershipStatus(compact: compact); + return _hasSupporterCubit(context) + ? status + : SupporterMembershipScope(child: status); + } +} + +/// Provides the signed-in account's [SupporterCubit] to [child], refreshing +/// the cached membership only where that cannot prompt an external signer. +/// +/// Leaves [child] without a cubit when supporters are not configured or no +/// one is signed in. +class SupporterMembershipScope extends ConsumerWidget { + const SupporterMembershipScope({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!ref.watch(supporterApiConfiguredProvider)) return child; + ref.watch(currentAuthStateProvider); + final auth = ref.watch(authServiceProvider); + if (!auth.isAuthenticated) return child; ref.watch(currentAuthRpcCapabilityProvider); final repository = ref.watch(supporterRepositoryProvider); // External signers may prompt. Passive entry must not interrupt browsing; @@ -59,23 +85,45 @@ class SupporterMembership extends ConsumerWidget { if (canRefresh) unawaited(repository.refreshIfStale()); return cubit; }, - child: BlocBuilder( - builder: (context, state) { - final confirmedInactive = state.isConfirmedInactive; - final label = state.isSupporter - ? context.l10n.supporterBadgeLabel - : confirmedInactive - ? context.l10n.supporterJoinLabel - : context.l10n.supporterTitle; - return _MembershipEntry( - compact: compact, - label: label, - subtitle: state.isSupporter - ? context.l10n.supporterActiveBadge - : context.l10n.supporterTileSubtitle, - ); - }, - ), + child: child, + ); + } +} + +bool _hasSupporterCubit(BuildContext context) { + try { + context.read(); + return true; + } on ProviderNotFoundException { + return false; + } +} + +class _MembershipStatus extends StatelessWidget { + const _MembershipStatus({required this.compact}); + + final bool compact; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + // On the profile, the halo and wings are the supporter's mark; the + // compact entry only remains as a way in for everyone else. + if (compact && state.isSupporter) return const SizedBox.shrink(); + final label = state.isSupporter + ? context.l10n.supporterBadgeLabel + : state.isConfirmedInactive + ? context.l10n.supporterJoinLabel + : context.l10n.supporterTitle; + return _MembershipEntry( + compact: compact, + label: label, + subtitle: state.isSupporter + ? context.l10n.supporterActiveBadge + : context.l10n.supporterTileSubtitle, + ); + }, ); } } diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index a9c86f51ad..5c958d575e 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -14,6 +14,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:follow_repository/follow_repository.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:iap_repository/iap_repository.dart'; import 'package:material_ui/material_ui.dart'; import 'package:mocktail/mocktail.dart'; import 'package:models/models.dart'; @@ -47,6 +50,7 @@ import 'package:openvine/services/auth_service.dart' hide UserProfile; import 'package:openvine/services/og_viner_cache_service.dart'; import 'package:openvine/services/stats_visibility_preferences.dart'; import 'package:openvine/services/supporter_api_client.dart'; +import 'package:openvine/services/supporter_repository.dart'; import 'package:openvine/utils/divine_login_banner_dismissal.dart'; import 'package:openvine/utils/nostr_key_utils.dart'; import 'package:openvine/utils/secure_account_prompt_dismissal.dart'; @@ -403,6 +407,7 @@ void main() { Nip05VerificationStatus verificationStatus = Nip05VerificationStatus.verified, SupporterApiClient? supporterClient, + SupporterRepository? supporterRepository, }) { // Pass authService when the test needs the same instance across pumps — // e.g. to read tryRefreshCallCount after the header has been unmounted. @@ -514,6 +519,8 @@ void main() { mockFollowRepository: mockFollowRepository, supporterApiClient: supporterClient, ), + if (supporterRepository != null) + supporterRepositoryProvider.overrideWithValue(supporterRepository), profileVanishedProvider(userIdHex).overrideWith((ref) => isVanished), if ((profile ?? suppliedProfile)?.nip05 case final String claim) nip05ClaimVerificationProvider( @@ -679,10 +686,10 @@ void main() { } testWidgets( - '${ownProfile ? 'shows' : 'hides'} the private membership chip on ' + '${ownProfile ? 'shows' : 'hides'} the supporter membership entry on ' '${ownProfile ? 'your own' : "someone else's"} profile', (tester) async { - // The private chip reads the signed-in account's membership, so on + // The entry reads the signed-in account's membership, so on // anyone else's profile it would show the viewer's status, not theirs. await tester.pumpWidget( buildTestWidget( @@ -720,7 +727,7 @@ void main() { ); } - group('public supporter status', () { + group('supporter status', () { const supporterPicture = 'https://example.com/supporter.jpg'; final l10n = lookupAppLocalizations(const Locale('en')); late Duration visibilityInterval; @@ -746,10 +753,58 @@ void main() { return client; } + String accountJson({required bool active, required bool haloVisible}) => + jsonEncode({ + 'status': active ? 'active' : 'expired', + 'entitlement': {'source': 'server', 'isActive': active}, + 'recognition': {'haloVisible': haloVisible}, + }); + + /// The viewer's own membership, read from a stubbed supporters API. + Future membership( + WidgetTester tester, { + required bool isSupporter, + bool haloVisible = false, + }) async { + final client = SupporterApiClient( + baseUri: Uri.parse('https://supporters.test'), + authHeaderProvider: + ({required url, required method, payload}) async => + (authorizationHeader: 'Nostr fixture', pubkey: testUserHex), + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/recognition')) { + final body = jsonDecode(request.body) as Map; + return http.Response( + accountJson( + active: isSupporter, + haloVisible: body['halo_visible'] as bool, + ), + 200, + ); + } + return http.Response( + accountJson(active: isSupporter, haloVisible: haloVisible), + 200, + ); + }), + ); + addTearDown(client.dispose); + final repository = SupporterRepository( + pubkey: testUserHex, + validator: StubEntitlementValidator(), + prefs: await SharedPreferences.getInstance(), + apiClient: client, + ); + addTearDown(repository.dispose); + await tester.runAsync(repository.refreshFromServer); + return repository; + } + Future pumpHeader( WidgetTester tester, { required bool isOwnProfile, - required bool isPublicSupporter, + bool isPublicSupporter = false, + SupporterRepository? ownMembership, }) async { await tester.pumpWidget( buildTestWidget( @@ -762,6 +817,7 @@ void main() { supporterClient: supporterClient( isPublicSupporter: isPublicSupporter, ), + supporterRepository: ownMembership, // Holds the halo still so the tree can settle. disableAnimations: true, ), @@ -769,34 +825,106 @@ void main() { await tester.pumpAndSettle(); } + /// Opens the supporter view, whose halo keeps looping on the pushed + /// route, so it never settles; the first frame only lays the route + /// out offstage for the Hero. + Future openSupporterView(WidgetTester tester) async { + await tester.tap(find.byType(UserAvatar)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + } + group('renders', () { - for (final isOwnProfile in [true, false]) { - final whose = isOwnProfile ? 'your own' : "someone else's"; + testWidgets("shows the halo and wings on someone else's public " + 'supporter profile', (tester) async { + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + ); - testWidgets('shows the halo and wings on $whose public supporter ' - 'profile', (tester) async { - await pumpHeader( + expect(find.byType(SupporterHaloWings), findsOneWidget); + }); + + testWidgets("shows no halo on someone else's profile without public " + 'supporter status', (tester) async { + await pumpHeader(tester, isOwnProfile: false); + + expect(find.byType(SupporterHaloWings), findsNothing); + }); + + testWidgets('shows the halo and wings on your own profile while ' + 'your supporter status is public', (tester) async { + await pumpHeader( + tester, + isOwnProfile: true, + ownMembership: await membership( tester, - isOwnProfile: isOwnProfile, - isPublicSupporter: true, - ); + isSupporter: true, + haloVisible: true, + ), + ); - expect(find.byType(SupporterHaloWings), findsOneWidget); - }); + expect(find.byType(SupporterHaloWings), findsOneWidget); + }); + + testWidgets('shows no halo on your own profile while your supporter ' + 'status is private', (tester) async { + await pumpHeader( + tester, + isOwnProfile: true, + // Public status from the API is stale; the account is current. + isPublicSupporter: true, + ownMembership: await membership(tester, isSupporter: true), + ); + + expect(find.byType(SupporterHaloWings), findsNothing); + }); + + testWidgets('shows the halo on your own profile as soon as you make ' + 'your supporter status public', (tester) async { + final repository = await membership(tester, isSupporter: true); + await pumpHeader( + tester, + isOwnProfile: true, + ownMembership: repository, + ); + expect(find.byType(SupporterHaloWings), findsNothing); + + await tester.runAsync( + () => repository.updateRecognition( + haloVisible: true, + discoveryVisible: false, + foundingHistoryVisible: false, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SupporterHaloWings), findsOneWidget); + }); - testWidgets('shows no halo on $whose profile without public ' - 'supporter status', (tester) async { + for (final haloVisible in [true, false]) { + testWidgets('shows no supporter label on your own supporter profile ' + 'with public status ${haloVisible ? 'on' : 'off'}', ( + tester, + ) async { await pumpHeader( tester, - isOwnProfile: isOwnProfile, - isPublicSupporter: false, + isOwnProfile: true, + ownMembership: await membership( + tester, + isSupporter: true, + haloVisible: haloVisible, + ), ); - expect(find.byType(SupporterHaloWings), findsNothing); + expect(find.byType(ActionChip), findsNothing); + expect(find.text(l10n.supporterBadgeLabel), findsNothing); }); } - testWidgets('no longer shows the old supporter label', (tester) async { + testWidgets("shows no supporter label on someone else's supporter " + 'profile', (tester) async { await pumpHeader( tester, isOwnProfile: false, @@ -826,9 +954,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(seconds: 1)); - await tester.tap(find.byType(UserAvatar)); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); + await openSupporterView(tester); Navigator.of( tester.element(find.text(l10n.supporterLightboxBody)), ).pop(); @@ -867,18 +993,36 @@ void main() { isPublicSupporter: true, ); - await tester.tap(find.byType(UserAvatar)); - // The pushed route keeps the halo looping, so it never settles; - // the first frame only lays the route out offstage for the Hero. - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); + await openSupporterView(tester); expect(find.text(l10n.supporterLightboxBody), findsOneWidget); + expect(find.text(l10n.supporterActiveBadge), findsNothing); + }); + + testWidgets('tapping your own supporter avatar opens your supporter ' + 'view, without its public setting', (tester) async { + await pumpHeader( + tester, + isOwnProfile: true, + ownMembership: await membership( + tester, + isSupporter: true, + haloVisible: true, + ), + ); + + await openSupporterView(tester); + + expect(find.text(l10n.supporterActiveBadge), findsOneWidget); + expect(find.byType(DivineSwitch), findsNothing); + expect(find.text(l10n.supporterLightboxBody), findsNothing); + expect(find.text(l10n.supporterJoinLabel), findsNothing); }); - for (final (label, isOwnProfile, isPublicSupporter) in [ + for (final (label, isOwnProfile, isSupporter) in [ ("someone else's non-supporter avatar", false, false), - ('your own supporter avatar', true, true), + ('your own avatar without a membership', true, false), + ('your own avatar with a private membership', true, true), ]) { testWidgets('tapping $label opens the photo lightbox', ( tester, @@ -886,7 +1030,9 @@ void main() { await pumpHeader( tester, isOwnProfile: isOwnProfile, - isPublicSupporter: isPublicSupporter, + ownMembership: isOwnProfile + ? await membership(tester, isSupporter: isSupporter) + : null, ); await tester.tap(find.byType(UserAvatar)); @@ -899,6 +1045,7 @@ void main() { isNotEmpty, ); expect(find.text(l10n.supporterLightboxBody), findsNothing); + expect(find.text(l10n.supporterActiveBadge), findsNothing); }); } }); diff --git a/mobile/test/widgets/supporter_membership_test.dart b/mobile/test/widgets/supporter_membership_test.dart index c80ca3bd65..ad1c14f605 100644 --- a/mobile/test/widgets/supporter_membership_test.dart +++ b/mobile/test/widgets/supporter_membership_test.dart @@ -1,4 +1,4 @@ -// ABOUTME: Tests persistent own-account acknowledgement and public opt-in badges. +// ABOUTME: Tests own-account membership entries in settings and on the profile. // ABOUTME: Covers refresh without store work and suppression of external prompts. import 'dart:async'; @@ -271,8 +271,31 @@ void main() { expect(find.text('Become a supporter'), findsNothing); } else { expect(requests, 1); - expect(find.text('Supporter'), findsOneWidget); + expect(repo.isSupporter, isTrue); + // The halo and wings mark a supporter's profile instead. + expect(find.byType(ActionChip), findsNothing); expect(repo.snapshot?.haloVisible, isFalse); + + // Settings still acknowledges the membership. + await tester.pumpWidget( + ProviderScope( + overrides: [ + authServiceProvider.overrideWithValue(auth), + currentAuthStateProvider.overrideWithValue( + AuthState.authenticated, + ), + currentAuthRpcCapabilityProvider.overrideWithValue( + AuthRpcCapability.rpcReady, + ), + supporterRepositoryProvider.overrideWithValue(repo), + ], + child: buildLocalizedWidget( + const Scaffold(body: SupporterMembership()), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Supporter'), findsOneWidget); } await tester.pumpWidget(const SizedBox.shrink()); }, From fcc163a06338136ceea174754492d3c60858ae53 Mon Sep 17 00:00:00 2001 From: Sebastian Heit Date: Mon, 19 Oct 2026 21:34:07 +0200 Subject: [PATCH 07/32] feat(profile): add the supporter visibility toggle to edit profile Supporters can now choose whether their halo shows publicly from Edit Profile, next to the rest of what others see on their profile. The setting flips as soon as it is tapped and reverts with an error if saving fails. Refs #9914 Co-Authored-By: Claude --- .../view/profile_setup_view.dart | 2 + .../widgets/profile_setup_rows.dart | 47 ++++++ .../supporter_recognition_section.dart | 81 +++++++++ .../profile_setup/widgets/widgets.dart | 1 + .../screens/settings/supporter_screen.dart | 36 ++-- .../profile_setup_rows_test.dart | 50 +++++- .../supporter_recognition_section_test.dart | 159 ++++++++++++++++++ 7 files changed, 354 insertions(+), 22 deletions(-) create mode 100644 mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart create mode 100644 mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart diff --git a/mobile/lib/screens/profile_setup/view/profile_setup_view.dart b/mobile/lib/screens/profile_setup/view/profile_setup_view.dart index e35dbeae37..5d17a860a6 100644 --- a/mobile/lib/screens/profile_setup/view/profile_setup_view.dart +++ b/mobile/lib/screens/profile_setup/view/profile_setup_view.dart @@ -202,6 +202,8 @@ class _ProfileSetupScreenViewState controller: _websiteController, ), const VerifiedAccountsSection(), + if (!widget.isNewUser) + const SupporterRecognitionSection(), ], ), ), diff --git a/mobile/lib/screens/profile_setup/widgets/profile_setup_rows.dart b/mobile/lib/screens/profile_setup/widgets/profile_setup_rows.dart index c2c07a6aee..a543f425ea 100644 --- a/mobile/lib/screens/profile_setup/widgets/profile_setup_rows.dart +++ b/mobile/lib/screens/profile_setup/widgets/profile_setup_rows.dart @@ -108,6 +108,53 @@ class ProfileSelectRow extends StatelessWidget { } } +/// A form card with one line of text and a switch. Tapping anywhere on the +/// card flips it, and the label and switch read as one control. +class ProfileSwitchRow extends StatelessWidget { + /// Creates a switch form card. + const ProfileSwitchRow({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + /// The setting the switch controls. + final String label; + + final bool value; + + /// Called with the new value, or null to disable the row. + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final onChanged = this.onChanged; + final colors = context.vineColors; + return MergeSemantics( + child: _FormCard( + onTap: onChanged == null ? null : () => onChanged(!value), + child: Row( + spacing: 8, + children: [ + Expanded( + child: Text( + label, + style: VineTheme.bodyLargeFont( + color: onChanged == null + ? colors.disabled + : colors.primaryText, + ), + ), + ), + DivineSwitch(value: value, onChanged: onChanged), + ], + ), + ), + ); + } +} + /// A read-only form card: small label above a value, with a trailing action. /// /// Used for the npub row, which Figma draws as a field the user can copy but diff --git a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart new file mode 100644 index 0000000000..ae6614429d --- /dev/null +++ b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart @@ -0,0 +1,81 @@ +import 'package:divine_ui/divine_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:openvine/blocs/supporter/supporter_cubit.dart'; +import 'package:openvine/blocs/supporter/supporter_state.dart'; +import 'package:openvine/l10n/l10n.dart'; +import 'package:openvine/screens/profile_setup/widgets/profile_setup_rows.dart'; +import 'package:openvine/screens/settings/supporter_screen.dart'; +import 'package:openvine/widgets/supporter_membership.dart'; + +/// Whether a supporter's halo and wings show on their profile to others. +/// +/// Only supporters see it. Absent when supporters are not configured or no +/// one is signed in. +class SupporterRecognitionSection extends StatelessWidget { + const SupporterRecognitionSection({super.key}); + + @override + Widget build(BuildContext context) { + return const SupporterMembershipScope(child: _SupporterRecognition()); + } +} + +class _SupporterRecognition extends StatelessWidget { + const _SupporterRecognition(); + + @override + Widget build(BuildContext context) { + final SupporterCubit cubit; + try { + cubit = context.read(); + } on ProviderNotFoundException { + return const SizedBox.shrink(); + } + final l10n = context.l10n; + return BlocConsumer( + bloc: cubit, + listenWhen: (previous, current) => + current.failure != null && previous.failure != current.failure, + listener: (context, state) { + ScaffoldMessenger.of(context).showSnackBar( + DivineSnackbarContainer.snackBar( + state.failure!.message(l10n), + error: true, + ), + ); + cubit.dismissError(); + }, + builder: (context, state) { + if (!state.isSupporter) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + l10n.supporterLightboxBadge, + style: VineTheme.labelMediumFont( + color: context.vineColors.mutedText, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ProfileSwitchRow( + label: l10n.supporterPublicRecognition, + value: state.haloVisible, + onChanged: state.snapshot == null || state.savingRecognition + ? null + : cubit.setPublicRecognition, + ), + ProfileFieldSupportingText( + l10n.supporterPublicRecognitionBody, + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/mobile/lib/screens/profile_setup/widgets/widgets.dart b/mobile/lib/screens/profile_setup/widgets/widgets.dart index 2f11166fdd..08441c5ae1 100644 --- a/mobile/lib/screens/profile_setup/widgets/widgets.dart +++ b/mobile/lib/screens/profile_setup/widgets/widgets.dart @@ -12,6 +12,7 @@ export 'profile_setup_rows.dart'; export 'profile_setup_upload_errors.dart'; export 'public_key_row.dart'; export 'save_button.dart'; +export 'supporter_recognition_section.dart'; export 'username_field.dart'; export 'verified_accounts_section.dart'; export 'website_field.dart'; diff --git a/mobile/lib/screens/settings/supporter_screen.dart b/mobile/lib/screens/settings/supporter_screen.dart index efd545b4c1..e770759798 100644 --- a/mobile/lib/screens/settings/supporter_screen.dart +++ b/mobile/lib/screens/settings/supporter_screen.dart @@ -106,7 +106,7 @@ class _SupporterScreenViewState extends State { subtitle: Text( context.l10n.supporterPublicRecognitionBody, ), - value: state.snapshot?.haloVisible ?? false, + value: state.haloVisible, onChanged: state.snapshot == null || state.savingRecognition ? null @@ -437,7 +437,7 @@ class _FailureBanner extends StatelessWidget { color: VineTheme.accentOrange, ), const SizedBox(width: 8), - Expanded(child: Text(_message(context, failure))), + Expanded(child: Text(failure.message(context.l10n))), DivineIconButton( icon: DivineIconName.x, type: DivineIconButtonType.ghostSecondary, @@ -447,26 +447,20 @@ class _FailureBanner extends StatelessWidget { ], ); } +} - String _message(BuildContext context, SupporterFailure failure) { - final l10n = context.l10n; - switch (failure) { - case SupporterFailure.storeUnavailable: - return l10n.supporterErrorStoreUnavailable; - case SupporterFailure.purchaseFailed: - return l10n.supporterErrorPurchaseFailed; - case SupporterFailure.purchasePending: - return l10n.supporterErrorPurchasePending; - case SupporterFailure.restoreFailed: - return l10n.supporterErrorRestoreFailed; - case SupporterFailure.ownershipConflict: - return l10n.supporterErrorOwnershipConflict; - case SupporterFailure.verificationUnavailable: - return l10n.supporterErrorVerificationUnavailable; - case SupporterFailure.unknown: - return l10n.supporterErrorUnknown; - } - } +/// User-facing wording for a [SupporterFailure]. +extension SupporterFailureMessage on SupporterFailure { + String message(AppLocalizations l10n) => switch (this) { + SupporterFailure.storeUnavailable => l10n.supporterErrorStoreUnavailable, + SupporterFailure.purchaseFailed => l10n.supporterErrorPurchaseFailed, + SupporterFailure.purchasePending => l10n.supporterErrorPurchasePending, + SupporterFailure.restoreFailed => l10n.supporterErrorRestoreFailed, + SupporterFailure.ownershipConflict => l10n.supporterErrorOwnershipConflict, + SupporterFailure.verificationUnavailable => + l10n.supporterErrorVerificationUnavailable, + SupporterFailure.unknown => l10n.supporterErrorUnknown, + }; } class _Disclaimer extends StatelessWidget { diff --git a/mobile/test/screens/profile_setup/profile_setup_rows_test.dart b/mobile/test/screens/profile_setup/profile_setup_rows_test.dart index d5702889ff..e427ebf9a4 100644 --- a/mobile/test/screens/profile_setup/profile_setup_rows_test.dart +++ b/mobile/test/screens/profile_setup/profile_setup_rows_test.dart @@ -1,5 +1,6 @@ // ABOUTME: Widget tests for the edit-profile form's non-editable cards. -// ABOUTME: Covers the select row's two states and what each announces. +// ABOUTME: Covers the select row's two states, the switch row, and what each +// ABOUTME: announces. import 'dart:ui' show Tristate; @@ -127,6 +128,53 @@ void main() { }); }); + group(ProfileSwitchRow, () { + testWidgets('flips its value when the card is tapped', (tester) async { + final changes = []; + await pump( + tester, + ProfileSwitchRow( + label: 'Show publicly', + value: false, + onChanged: changes.add, + ), + ); + + await tester.tap(find.text('Show publicly')); + + expect(changes, [true]); + }); + + testWidgets('announces the label and state as one toggle', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pump( + tester, + ProfileSwitchRow( + label: 'Show publicly', + value: true, + onChanged: (_) {}, + ), + ); + + expect( + tester.getSemantics(find.byType(ProfileSwitchRow)), + matchesSemantics( + label: 'Show publicly', + hasToggledState: true, + isToggled: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + handle.dispose(); + }); + }); + group(ProfileValueRow, () { testWidgets('keeps the whole value rather than shortening it', ( tester, diff --git a/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart b/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart new file mode 100644 index 0000000000..b9e44864d5 --- /dev/null +++ b/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart @@ -0,0 +1,159 @@ +// ABOUTME: Tests the Edit Profile setting that makes supporter status public. +// ABOUTME: Covers who sees it, the instant flip, and a failed save. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:divine_ui/divine_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:iap_repository/iap_repository.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:openvine/l10n/l10n.dart'; +import 'package:openvine/models/auth_rpc_capability.dart'; +import 'package:openvine/providers/auth_providers.dart'; +import 'package:openvine/providers/supporter_providers.dart'; +import 'package:openvine/screens/profile_setup/widgets/supporter_recognition_section.dart'; +import 'package:openvine/services/auth_service.dart'; +import 'package:openvine/services/supporter_api_client.dart'; +import 'package:openvine/services/supporter_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../helpers/l10n.dart'; + +class _MockAuth extends Mock implements AuthService {} + +const _pubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +String _accountJson({required bool active, required bool haloVisible}) => + jsonEncode({ + 'status': active ? 'active' : 'expired', + 'entitlement': {'source': 'server', 'isActive': active}, + 'recognition': {'haloVisible': haloVisible}, + }); + +void main() { + final l10n = lookupAppLocalizations(const Locale('en')); + + /// Pumps the section for an account read from a stubbed supporters API, + /// with public recognition off. [onRecognition] answers a change. + Future pumpSection( + WidgetTester tester, { + required bool isSupporter, + Future Function(http.Request request)? onRecognition, + }) async { + SharedPreferences.setMockInitialValues({}); + final auth = _MockAuth(); + when(() => auth.isAuthenticated).thenReturn(true); + // No refresh from the screen; the account is read up front. + when(() => auth.canPublishNostrWritesNow).thenReturn(false); + final client = SupporterApiClient( + baseUri: Uri.parse('https://supporters.test'), + authHeaderProvider: ({required url, required method, payload}) async => + (authorizationHeader: 'Nostr fixture', pubkey: _pubkey), + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/recognition')) { + return onRecognition!(request); + } + return http.Response( + _accountJson(active: isSupporter, haloVisible: false), + 200, + ); + }), + ); + addTearDown(client.dispose); + final repository = SupporterRepository( + pubkey: _pubkey, + validator: StubEntitlementValidator(), + prefs: await SharedPreferences.getInstance(), + apiClient: client, + ); + addTearDown(repository.dispose); + await tester.runAsync(repository.refreshFromServer); + await tester.pumpWidget( + ProviderScope( + overrides: [ + supporterApiConfiguredProvider.overrideWithValue(true), + authServiceProvider.overrideWithValue(auth), + currentAuthStateProvider.overrideWithValue(AuthState.authenticated), + currentAuthRpcCapabilityProvider.overrideWithValue( + AuthRpcCapability.rpcReady, + ), + supporterRepositoryProvider.overrideWithValue(repository), + ], + child: buildLocalizedWidget( + const Scaffold(body: SupporterRecognitionSection()), + ), + ), + ); + await tester.pump(); + } + + bool switchValue(WidgetTester tester) => + tester.widget(find.byType(DivineSwitch)).value; + + group(SupporterRecognitionSection, () { + group('renders', () { + testWidgets('shows the public setting to supporters', (tester) async { + await pumpSection(tester, isSupporter: true); + + expect(find.text(l10n.supporterPublicRecognition), findsOneWidget); + expect(switchValue(tester), isFalse); + }); + + testWidgets('shows nothing to anyone else', (tester) async { + await pumpSection(tester, isSupporter: false); + + expect(find.text(l10n.supporterPublicRecognition), findsNothing); + expect(find.byType(DivineSwitch), findsNothing); + }); + }); + + group('interactions', () { + testWidgets('flips on as soon as it is tapped and saves the change', ( + tester, + ) async { + final response = Completer(); + final sent = []; + await pumpSection( + tester, + isSupporter: true, + onRecognition: (request) { + sent.add(jsonDecode(request.body)); + return response.future; + }, + ); + + await tester.tap(find.text(l10n.supporterPublicRecognition)); + await tester.pump(); + expect(switchValue(tester), isTrue); + + response.complete( + http.Response(_accountJson(active: true, haloVisible: true), 200), + ); + await tester.pumpAndSettle(); + + expect(sent, [containsPair('halo_visible', true)]); + expect(switchValue(tester), isTrue); + }); + + testWidgets('flips back and says so when saving fails', (tester) async { + await pumpSection( + tester, + isSupporter: true, + onRecognition: (_) async => http.Response('{}', 500), + ); + + await tester.tap(find.text(l10n.supporterPublicRecognition)); + await tester.pumpAndSettle(); + + expect(switchValue(tester), isFalse); + expect(find.byType(SnackBar), findsOneWidget); + }); + }); + }); +} From f965989f18708f9e21e52f169a33585dda152998 Mon Sep 17 00:00:00 2001 From: Sebastian Heit Date: Mon, 19 Oct 2026 21:54:43 +0200 Subject: [PATCH 08/32] feat(l10n): translate the supporter view into every locale The supporter view's copy was English-only and listed as untranslated debt; it now ships in all 22 locales, using each one's existing word for supporter. Refs #9914 Co-Authored-By: Claude --- mobile/lib/l10n/app_am.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_ar.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_bg.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_de.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_es.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_fil.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_fr.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_id.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_it.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_ja.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_ko.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_ms.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_nl.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_pl.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_pt.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_ro.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_sv.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_te.arb | 14 +++++++++++++ mobile/lib/l10n/app_tr.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_ur.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_vi.arb | 21 +++++++++++++++++++ mobile/lib/l10n/app_zh.arb | 21 +++++++++++++++++++ .../l10n/generated/app_localizations_am.dart | 17 +++++++-------- .../l10n/generated/app_localizations_ar.dart | 16 +++++++------- .../l10n/generated/app_localizations_bg.dart | 14 ++++++------- .../l10n/generated/app_localizations_de.dart | 15 +++++++------ .../l10n/generated/app_localizations_es.dart | 14 ++++++------- .../l10n/generated/app_localizations_fil.dart | 12 +++++------ .../l10n/generated/app_localizations_fr.dart | 14 ++++++------- .../l10n/generated/app_localizations_id.dart | 14 ++++++------- .../l10n/generated/app_localizations_it.dart | 14 ++++++------- .../l10n/generated/app_localizations_ja.dart | 17 +++++++-------- .../l10n/generated/app_localizations_ko.dart | 18 +++++++--------- .../l10n/generated/app_localizations_ms.dart | 14 ++++++------- .../l10n/generated/app_localizations_nl.dart | 15 +++++++------ .../l10n/generated/app_localizations_pl.dart | 14 ++++++------- .../l10n/generated/app_localizations_pt.dart | 14 ++++++------- .../l10n/generated/app_localizations_ro.dart | 14 ++++++------- .../l10n/generated/app_localizations_sv.dart | 14 ++++++------- .../l10n/generated/app_localizations_te.dart | 14 ++++++------- .../l10n/generated/app_localizations_tr.dart | 14 ++++++------- .../l10n/generated/app_localizations_ur.dart | 15 +++++++------ .../l10n/generated/app_localizations_vi.dart | 15 +++++++------ .../l10n/generated/app_localizations_zh.dart | 18 +++++++--------- mobile/test/l10n/arb_consistency_test.dart | 9 -------- 45 files changed, 608 insertions(+), 182 deletions(-) diff --git a/mobile/lib/l10n/app_am.arb b/mobile/lib/l10n/app_am.arb index cb01b193f7..37d17f110c 100644 --- a/mobile/lib/l10n/app_am.arb +++ b/mobile/lib/l10n/app_am.arb @@ -1117,6 +1117,27 @@ } } }, + "supporterLightboxHeadline": "{name} {badge} ናቸው", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "የDivine ደጋፊ", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "የበለጠ ክፍት እና ፈጠራ የተሞላ ኢንተርኔት እንዲኖር ራሱን የቻለውን መተግበሪያ ይደግፋሉ።", + "supporterLightboxBenefitIndependent": "Divine ራሱን ችሎ እንዲቆይ ይረዳል", + "supporterLightboxBenefitFeatures": "አዳዲስ አስደሳች ባህሪያትን ይደግፋል", + "supporterLightboxBenefitCommunity": "የበለጠ ፈጠራ ያለው ማህበረሰብን ያጎለብታል", + "supporterLightboxMaybeLater": "ምናልባት በኋላ", "supporterTitle": "የDivine ደጋፊዎች", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_ar.arb b/mobile/lib/l10n/app_ar.arb index 95d8adbe97..6d6854080f 100644 --- a/mobile/lib/l10n/app_ar.arb +++ b/mobile/lib/l10n/app_ar.arb @@ -3398,6 +3398,27 @@ } } }, + "supporterLightboxHeadline": "{name} من {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "داعمي Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "دعمٌ للتطبيق المستقل من أجل إنترنت أكثر انفتاحًا وإبداعًا.", + "supporterLightboxBenefitIndependent": "يساعد على بقاء Divine مستقلًا", + "supporterLightboxBenefitFeatures": "يدعم ميزات جديدة ومثيرة", + "supporterLightboxBenefitCommunity": "يغذّي مجتمعًا أكثر إبداعًا", + "supporterLightboxMaybeLater": "ربما لاحقًا", "supporterTitle": "داعمو Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_bg.arb b/mobile/lib/l10n/app_bg.arb index 2b2643f3e6..f7fae46443 100644 --- a/mobile/lib/l10n/app_bg.arb +++ b/mobile/lib/l10n/app_bg.arb @@ -3515,6 +3515,27 @@ } } }, + "supporterLightboxHeadline": "{name} е от {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "поддръжниците на Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Подкрепя независимото приложение за по-отворен и креативен интернет.", + "supporterLightboxBenefitIndependent": "Помага Divine да остане независим", + "supporterLightboxBenefitFeatures": "Подкрепя нови вълнуващи функции", + "supporterLightboxBenefitCommunity": "Захранва по-креативна общност", + "supporterLightboxMaybeLater": "Може би по-късно", "supporterTitle": "Поддръжници на Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_de.arb b/mobile/lib/l10n/app_de.arb index 5981689523..5871e205e2 100644 --- a/mobile/lib/l10n/app_de.arb +++ b/mobile/lib/l10n/app_de.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} ist {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Divine-Supporter", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Unterstützt die unabhängige App für ein offeneres, kreativeres Internet.", + "supporterLightboxBenefitIndependent": "Hält Divine unabhängig", + "supporterLightboxBenefitFeatures": "Unterstützt spannende neue Funktionen", + "supporterLightboxBenefitCommunity": "Stärkt eine kreativere Community", + "supporterLightboxMaybeLater": "Vielleicht später", "supporterTitle": "Divine-Supporter", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_es.arb b/mobile/lib/l10n/app_es.arb index dcb47858ec..921d6addb2 100644 --- a/mobile/lib/l10n/app_es.arb +++ b/mobile/lib/l10n/app_es.arb @@ -3409,6 +3409,27 @@ } } }, + "supporterLightboxHeadline": "{name} es {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Supporter de Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Apoya a la app independiente por un internet más abierto y creativo.", + "supporterLightboxBenefitIndependent": "Ayuda a que Divine siga independiente", + "supporterLightboxBenefitFeatures": "Impulsa funciones nuevas y geniales", + "supporterLightboxBenefitCommunity": "Da vida a una comunidad más creativa", + "supporterLightboxMaybeLater": "Quizás más tarde", "supporterTitle": "Supporters de Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_fil.arb b/mobile/lib/l10n/app_fil.arb index b775d8213f..7cef7735b5 100644 --- a/mobile/lib/l10n/app_fil.arb +++ b/mobile/lib/l10n/app_fil.arb @@ -2649,6 +2649,27 @@ } } }, + "supporterLightboxHeadline": "Isang {badge} si {name}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Divine Supporter", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Sinusuportahan ang independent na app para sa mas bukas at malikhaing internet.", + "supporterLightboxBenefitIndependent": "Tumutulong panatilihing independent ang Divine", + "supporterLightboxBenefitFeatures": "Sumusuporta sa mga bagong exciting na feature", + "supporterLightboxBenefitCommunity": "Nagpapalakas ng mas malikhaing community", + "supporterLightboxMaybeLater": "Sa susunod na lang", "supporterTitle": "Mga Divine Supporter", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_fr.arb b/mobile/lib/l10n/app_fr.arb index f34d35fca2..fa5db46f5c 100644 --- a/mobile/lib/l10n/app_fr.arb +++ b/mobile/lib/l10n/app_fr.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} est {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Supporter Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Soutient l'app indépendante pour un internet plus ouvert et créatif.", + "supporterLightboxBenefitIndependent": "Aide Divine à rester indépendant", + "supporterLightboxBenefitFeatures": "Soutient de nouvelles fonctionnalités géniales", + "supporterLightboxBenefitCommunity": "Fait vivre une communauté plus créative", + "supporterLightboxMaybeLater": "Peut-être plus tard", "supporterTitle": "Supporters Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_id.arb b/mobile/lib/l10n/app_id.arb index 2147d828df..8920b72144 100644 --- a/mobile/lib/l10n/app_id.arb +++ b/mobile/lib/l10n/app_id.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} adalah {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Pendukung Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Mendukung aplikasi independen untuk internet yang lebih terbuka dan kreatif.", + "supporterLightboxBenefitIndependent": "Membantu Divine tetap independen", + "supporterLightboxBenefitFeatures": "Mendukung fitur-fitur baru yang seru", + "supporterLightboxBenefitCommunity": "Menghidupkan komunitas yang lebih kreatif", + "supporterLightboxMaybeLater": "Nanti saja", "supporterTitle": "Pendukung Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_it.arb b/mobile/lib/l10n/app_it.arb index 051682a421..fa10c1f97f 100644 --- a/mobile/lib/l10n/app_it.arb +++ b/mobile/lib/l10n/app_it.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} è tra i {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Sostenitori Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Sostiene l'app indipendente per un internet più aperto e creativo.", + "supporterLightboxBenefitIndependent": "Aiuta Divine a restare indipendente", + "supporterLightboxBenefitFeatures": "Sostiene nuove funzioni entusiasmanti", + "supporterLightboxBenefitCommunity": "Alimenta una community più creativa", + "supporterLightboxMaybeLater": "Forse più tardi", "supporterTitle": "Sostenitori Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_ja.arb b/mobile/lib/l10n/app_ja.arb index 5a38276f38..457a54f23f 100644 --- a/mobile/lib/l10n/app_ja.arb +++ b/mobile/lib/l10n/app_ja.arb @@ -3398,6 +3398,27 @@ } } }, + "supporterLightboxHeadline": "{name}さんは{badge}です", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Divine サポーター", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "もっとオープンでクリエイティブなインターネットのために、独立したアプリを応援しています。", + "supporterLightboxBenefitIndependent": "Divine の独立を支えます", + "supporterLightboxBenefitFeatures": "ワクワクする新機能を後押しします", + "supporterLightboxBenefitCommunity": "よりクリエイティブなコミュニティを育てます", + "supporterLightboxMaybeLater": "あとで", "supporterTitle": "Divine サポーター", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_ko.arb b/mobile/lib/l10n/app_ko.arb index 26f6300212..523778eaed 100644 --- a/mobile/lib/l10n/app_ko.arb +++ b/mobile/lib/l10n/app_ko.arb @@ -2944,6 +2944,27 @@ } } }, + "supporterLightboxHeadline": "{name}님은 {badge}예요", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Divine 서포터", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "더 열려 있고 창의적인 인터넷을 위해 독립 앱을 후원하고 있어요.", + "supporterLightboxBenefitIndependent": "Divine의 독립을 지켜요", + "supporterLightboxBenefitFeatures": "새롭고 신나는 기능을 지원해요", + "supporterLightboxBenefitCommunity": "더 창의적인 커뮤니티에 힘을 보태요", + "supporterLightboxMaybeLater": "나중에", "supporterTitle": "Divine 서포터", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_ms.arb b/mobile/lib/l10n/app_ms.arb index a84bb9018b..107c627d8b 100644 --- a/mobile/lib/l10n/app_ms.arb +++ b/mobile/lib/l10n/app_ms.arb @@ -6456,6 +6456,27 @@ } } }, + "supporterLightboxHeadline": "{name} ialah {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Penyokong Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Menyokong aplikasi bebas untuk internet yang lebih terbuka dan kreatif.", + "supporterLightboxBenefitIndependent": "Membantu Divine kekal bebas", + "supporterLightboxBenefitFeatures": "Menyokong ciri baharu yang menarik", + "supporterLightboxBenefitCommunity": "Menyemarakkan komuniti yang lebih kreatif", + "supporterLightboxMaybeLater": "Mungkin nanti", "supporterTitle": "Penyokong Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_nl.arb b/mobile/lib/l10n/app_nl.arb index 6ef0789274..326d17a09a 100644 --- a/mobile/lib/l10n/app_nl.arb +++ b/mobile/lib/l10n/app_nl.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} is een {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Divine-supporter", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Steunt de onafhankelijke app voor een opener, creatiever internet.", + "supporterLightboxBenefitIndependent": "Houdt Divine onafhankelijk", + "supporterLightboxBenefitFeatures": "Steunt gave nieuwe functies", + "supporterLightboxBenefitCommunity": "Voedt een creatievere community", + "supporterLightboxMaybeLater": "Misschien later", "supporterTitle": "Divine-supporters", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_pl.arb b/mobile/lib/l10n/app_pl.arb index cf24d8467e..a390342b92 100644 --- a/mobile/lib/l10n/app_pl.arb +++ b/mobile/lib/l10n/app_pl.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} jest w gronie {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Wspierających Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Wspiera niezależną aplikację dla bardziej otwartego i kreatywnego internetu.", + "supporterLightboxBenefitIndependent": "Pomaga utrzymać niezależność Divine", + "supporterLightboxBenefitFeatures": "Wspiera nowe, ekscytujące funkcje", + "supporterLightboxBenefitCommunity": "Napędza bardziej kreatywną społeczność", + "supporterLightboxMaybeLater": "Może później", "supporterTitle": "Wspierający Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_pt.arb b/mobile/lib/l10n/app_pt.arb index 59ba43e5c1..51e722dbc6 100644 --- a/mobile/lib/l10n/app_pt.arb +++ b/mobile/lib/l10n/app_pt.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} está entre os {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Apoiadores do Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Apoia o app independente por uma internet mais aberta e criativa.", + "supporterLightboxBenefitIndependent": "Ajuda a manter o Divine independente", + "supporterLightboxBenefitFeatures": "Apoia novos recursos incríveis", + "supporterLightboxBenefitCommunity": "Fortalece uma comunidade mais criativa", + "supporterLightboxMaybeLater": "Talvez depois", "supporterTitle": "Apoiadores do Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_ro.arb b/mobile/lib/l10n/app_ro.arb index d7347707e0..7a3f0da913 100644 --- a/mobile/lib/l10n/app_ro.arb +++ b/mobile/lib/l10n/app_ro.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} se numără printre {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Susținătorii Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Susține aplicația independentă pentru un internet mai deschis și mai creativ.", + "supporterLightboxBenefitIndependent": "Ajută Divine să rămână independent", + "supporterLightboxBenefitFeatures": "Susține funcții noi și interesante", + "supporterLightboxBenefitCommunity": "Hrănește o comunitate mai creativă", + "supporterLightboxMaybeLater": "Poate mai târziu", "supporterTitle": "Susținătorii Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_sv.arb b/mobile/lib/l10n/app_sv.arb index 6c61d1fdab..f5466f0668 100644 --- a/mobile/lib/l10n/app_sv.arb +++ b/mobile/lib/l10n/app_sv.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} är {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Divine-supporter", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Stöttar den oberoende appen för ett öppnare, mer kreativt internet.", + "supporterLightboxBenefitIndependent": "Hjälper Divine att förbli oberoende", + "supporterLightboxBenefitFeatures": "Stöttar spännande nya funktioner", + "supporterLightboxBenefitCommunity": "Driver en mer kreativ community", + "supporterLightboxMaybeLater": "Kanske senare", "supporterTitle": "Divine-supportrar", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_te.arb b/mobile/lib/l10n/app_te.arb index 6cc78e0548..e9d8a495a3 100644 --- a/mobile/lib/l10n/app_te.arb +++ b/mobile/lib/l10n/app_te.arb @@ -7495,6 +7495,20 @@ } } }, + "supporterLightboxHeadline": "{name} ఒక {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { "name": { "type": "String" }, "badge": { "type": "String" } } + }, + "supporterLightboxBadge": "Divine మద్దతుదారు", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "మరింత ఓపెన్, సృజనాత్మక ఇంటర్నెట్ కోసం స్వతంత్ర యాప్‌కు మద్దతు ఇస్తున్నారు.", + "supporterLightboxBenefitIndependent": "Divine స్వతంత్రంగా ఉండేందుకు సహాయపడుతుంది", + "supporterLightboxBenefitFeatures": "కొత్త ఆసక్తికరమైన ఫీచర్లకు మద్దతు ఇస్తుంది", + "supporterLightboxBenefitCommunity": "మరింత సృజనాత్మక కమ్యూనిటీకి శక్తినిస్తుంది", + "supporterLightboxMaybeLater": "బహుశా తర్వాత", "supporterTitle": "Divine మద్దతుదారులు\nఐచ్ఛిక నెలవారీ సభ్యత్వంతో ", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_tr.arb b/mobile/lib/l10n/app_tr.arb index 41e99e2126..40708634e2 100644 --- a/mobile/lib/l10n/app_tr.arb +++ b/mobile/lib/l10n/app_tr.arb @@ -3405,6 +3405,27 @@ } } }, + "supporterLightboxHeadline": "{name} bir {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Divine Destekçisi", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Daha açık ve yaratıcı bir internet için bağımsız uygulamayı destekliyor.", + "supporterLightboxBenefitIndependent": "Divine'ın bağımsız kalmasına yardım eder", + "supporterLightboxBenefitFeatures": "Heyecan verici yeni özellikleri destekler", + "supporterLightboxBenefitCommunity": "Daha yaratıcı bir topluluğa güç verir", + "supporterLightboxMaybeLater": "Belki sonra", "supporterTitle": "Divine Destekçileri", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_ur.arb b/mobile/lib/l10n/app_ur.arb index 9dddbde47a..779debc6f3 100644 --- a/mobile/lib/l10n/app_ur.arb +++ b/mobile/lib/l10n/app_ur.arb @@ -6456,6 +6456,27 @@ } } }, + "supporterLightboxHeadline": "{name} ایک {badge} ہیں", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Divine سپورٹر", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "زیادہ کھلے اور تخلیقی انٹرنیٹ کے لیے آزاد ایپ کی سپورٹ۔", + "supporterLightboxBenefitIndependent": "Divine کو آزاد رکھنے میں مدد", + "supporterLightboxBenefitFeatures": "نئے دلچسپ فیچرز کی سپورٹ", + "supporterLightboxBenefitCommunity": "زیادہ تخلیقی کمیونٹی کو تقویت", + "supporterLightboxMaybeLater": "شاید بعد میں", "supporterTitle": "Divine سپورٹرز", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_vi.arb b/mobile/lib/l10n/app_vi.arb index eb5723ba90..205b239e7f 100644 --- a/mobile/lib/l10n/app_vi.arb +++ b/mobile/lib/l10n/app_vi.arb @@ -6456,6 +6456,27 @@ } } }, + "supporterLightboxHeadline": "{name} là {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Người ủng hộ Divine", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "Ủng hộ ứng dụng độc lập vì một internet cởi mở và sáng tạo hơn.", + "supporterLightboxBenefitIndependent": "Giúp Divine luôn độc lập", + "supporterLightboxBenefitFeatures": "Hỗ trợ những tính năng mới thú vị", + "supporterLightboxBenefitCommunity": "Tiếp sức cho cộng đồng sáng tạo hơn", + "supporterLightboxMaybeLater": "Để sau", "supporterTitle": "Người ủng hộ Divine", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/app_zh.arb b/mobile/lib/l10n/app_zh.arb index 61f1a77fc7..bbb03bbf72 100644 --- a/mobile/lib/l10n/app_zh.arb +++ b/mobile/lib/l10n/app_zh.arb @@ -6456,6 +6456,27 @@ } } }, + "supporterLightboxHeadline": "{name} 是 {badge}", + "@supporterLightboxHeadline": { + "description": "Headline of another user's supporter lightbox. {name} is their display name; {badge} is supporterLightboxBadge, shown in the accent color. Keep both placeholders; reorder freely.", + "placeholders": { + "name": { + "type": "String" + }, + "badge": { + "type": "String" + } + } + }, + "supporterLightboxBadge": "Divine 支持者", + "@supporterLightboxBadge": { + "description": "Highlighted status name inserted into supporterLightboxHeadline." + }, + "supporterLightboxBody": "支持这款独立应用,让互联网更开放、更有创意。", + "supporterLightboxBenefitIndependent": "帮助 Divine 保持独立", + "supporterLightboxBenefitFeatures": "支持令人兴奋的新功能", + "supporterLightboxBenefitCommunity": "助力更有创意的社区", + "supporterLightboxMaybeLater": "以后再说", "supporterTitle": "Divine 支持者", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." diff --git a/mobile/lib/l10n/generated/app_localizations_am.dart b/mobile/lib/l10n/generated/app_localizations_am.dart index ff90ca0855..824ea6a99a 100644 --- a/mobile/lib/l10n/generated/app_localizations_am.dart +++ b/mobile/lib/l10n/generated/app_localizations_am.dart @@ -12236,30 +12236,27 @@ class AppLocalizationsAm extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name $badge ናቸው'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'የDivine ደጋፊ'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'የበለጠ ክፍት እና ፈጠራ የተሞላ ኢንተርኔት እንዲኖር ራሱን የቻለውን መተግበሪያ ይደግፋሉ።'; @override - String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + String get supporterLightboxBenefitIndependent => 'Divine ራሱን ችሎ እንዲቆይ ይረዳል'; @override - String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + String get supporterLightboxBenefitFeatures => 'አዳዲስ አስደሳች ባህሪያትን ይደግፋል'; @override - String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + String get supporterLightboxBenefitCommunity => 'የበለጠ ፈጠራ ያለው ማህበረሰብን ያጎለብታል'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'ምናልባት በኋላ'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ar.dart b/mobile/lib/l10n/generated/app_localizations_ar.dart index bf61cf0c15..68ddda1cc7 100644 --- a/mobile/lib/l10n/generated/app_localizations_ar.dart +++ b/mobile/lib/l10n/generated/app_localizations_ar.dart @@ -12528,30 +12528,28 @@ class AppLocalizationsAr extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name من $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'داعمي Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'دعمٌ للتطبيق المستقل من أجل إنترنت أكثر انفتاحًا وإبداعًا.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'يساعد على بقاء Divine مستقلًا'; @override - String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + String get supporterLightboxBenefitFeatures => 'يدعم ميزات جديدة ومثيرة'; @override - String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + String get supporterLightboxBenefitCommunity => 'يغذّي مجتمعًا أكثر إبداعًا'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'ربما لاحقًا'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_bg.dart b/mobile/lib/l10n/generated/app_localizations_bg.dart index ea77161196..a46fcec640 100644 --- a/mobile/lib/l10n/generated/app_localizations_bg.dart +++ b/mobile/lib/l10n/generated/app_localizations_bg.dart @@ -12676,30 +12676,30 @@ class AppLocalizationsBg extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name е от $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'поддръжниците на Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Подкрепя независимото приложение за по-отворен и креативен интернет.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Помага Divine да остане независим'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Подкрепя нови вълнуващи функции'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Захранва по-креативна общност'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Може би по-късно'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_de.dart b/mobile/lib/l10n/generated/app_localizations_de.dart index d71a54a92a..7e2d7b3fbc 100644 --- a/mobile/lib/l10n/generated/app_localizations_de.dart +++ b/mobile/lib/l10n/generated/app_localizations_de.dart @@ -12698,30 +12698,29 @@ class AppLocalizationsDe extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name ist $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Divine-Supporter'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Unterstützt die unabhängige App für ein offeneres, kreativeres Internet.'; @override - String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + String get supporterLightboxBenefitIndependent => 'Hält Divine unabhängig'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Unterstützt spannende neue Funktionen'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Stärkt eine kreativere Community'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Vielleicht später'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_es.dart b/mobile/lib/l10n/generated/app_localizations_es.dart index 84052a7028..b6076ba50f 100644 --- a/mobile/lib/l10n/generated/app_localizations_es.dart +++ b/mobile/lib/l10n/generated/app_localizations_es.dart @@ -12692,30 +12692,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name es $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Supporter de Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Apoya a la app independiente por un internet más abierto y creativo.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Ayuda a que Divine siga independiente'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Impulsa funciones nuevas y geniales'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Da vida a una comunidad más creativa'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Quizás más tarde'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_fil.dart b/mobile/lib/l10n/generated/app_localizations_fil.dart index 3eb19d27e4..4b37ca9a3c 100644 --- a/mobile/lib/l10n/generated/app_localizations_fil.dart +++ b/mobile/lib/l10n/generated/app_localizations_fil.dart @@ -12659,7 +12659,7 @@ class AppLocalizationsFil extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return 'Isang $badge si $name'; } @override @@ -12667,22 +12667,22 @@ class AppLocalizationsFil extends AppLocalizations { @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Sinusuportahan ang independent na app para sa mas bukas at malikhaing internet.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Tumutulong panatilihing independent ang Divine'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Sumusuporta sa mga bagong exciting na feature'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Nagpapalakas ng mas malikhaing community'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Sa susunod na lang'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_fr.dart b/mobile/lib/l10n/generated/app_localizations_fr.dart index ba8150fd21..fa31995a79 100644 --- a/mobile/lib/l10n/generated/app_localizations_fr.dart +++ b/mobile/lib/l10n/generated/app_localizations_fr.dart @@ -12747,30 +12747,30 @@ class AppLocalizationsFr extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name est $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Supporter Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Soutient l\'app indépendante pour un internet plus ouvert et créatif.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Aide Divine à rester indépendant'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Soutient de nouvelles fonctionnalités géniales'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Fait vivre une communauté plus créative'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Peut-être plus tard'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_id.dart b/mobile/lib/l10n/generated/app_localizations_id.dart index c4fb250c45..9569933023 100644 --- a/mobile/lib/l10n/generated/app_localizations_id.dart +++ b/mobile/lib/l10n/generated/app_localizations_id.dart @@ -12449,30 +12449,30 @@ class AppLocalizationsId extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name adalah $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Pendukung Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Mendukung aplikasi independen untuk internet yang lebih terbuka dan kreatif.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Membantu Divine tetap independen'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Mendukung fitur-fitur baru yang seru'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Menghidupkan komunitas yang lebih kreatif'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Nanti saja'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_it.dart b/mobile/lib/l10n/generated/app_localizations_it.dart index 44106f24d7..936480ad4e 100644 --- a/mobile/lib/l10n/generated/app_localizations_it.dart +++ b/mobile/lib/l10n/generated/app_localizations_it.dart @@ -12709,30 +12709,30 @@ class AppLocalizationsIt extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name è tra i $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Sostenitori Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Sostiene l\'app indipendente per un internet più aperto e creativo.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Aiuta Divine a restare indipendente'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Sostiene nuove funzioni entusiasmanti'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Alimenta una community più creativa'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Forse più tardi'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ja.dart b/mobile/lib/l10n/generated/app_localizations_ja.dart index ced94d88de..b389d3276e 100644 --- a/mobile/lib/l10n/generated/app_localizations_ja.dart +++ b/mobile/lib/l10n/generated/app_localizations_ja.dart @@ -11917,30 +11917,27 @@ class AppLocalizationsJa extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$nameさんは$badgeです'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Divine サポーター'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'もっとオープンでクリエイティブなインターネットのために、独立したアプリを応援しています。'; @override - String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + String get supporterLightboxBenefitIndependent => 'Divine の独立を支えます'; @override - String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + String get supporterLightboxBenefitFeatures => 'ワクワクする新機能を後押しします'; @override - String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + String get supporterLightboxBenefitCommunity => 'よりクリエイティブなコミュニティを育てます'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'あとで'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ko.dart b/mobile/lib/l10n/generated/app_localizations_ko.dart index 1dcbdfd71d..4df90b70a3 100644 --- a/mobile/lib/l10n/generated/app_localizations_ko.dart +++ b/mobile/lib/l10n/generated/app_localizations_ko.dart @@ -11938,30 +11938,26 @@ class AppLocalizationsKo extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name님은 $badge예요'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Divine 서포터'; @override - String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + String get supporterLightboxBody => '더 열려 있고 창의적인 인터넷을 위해 독립 앱을 후원하고 있어요.'; @override - String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + String get supporterLightboxBenefitIndependent => 'Divine의 독립을 지켜요'; @override - String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + String get supporterLightboxBenefitFeatures => '새롭고 신나는 기능을 지원해요'; @override - String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + String get supporterLightboxBenefitCommunity => '더 창의적인 커뮤니티에 힘을 보태요'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => '나중에'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ms.dart b/mobile/lib/l10n/generated/app_localizations_ms.dart index 4cfabb3c36..e116768392 100644 --- a/mobile/lib/l10n/generated/app_localizations_ms.dart +++ b/mobile/lib/l10n/generated/app_localizations_ms.dart @@ -12541,30 +12541,30 @@ class AppLocalizationsMs extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name ialah $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Penyokong Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Menyokong aplikasi bebas untuk internet yang lebih terbuka dan kreatif.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Membantu Divine kekal bebas'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Menyokong ciri baharu yang menarik'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Menyemarakkan komuniti yang lebih kreatif'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Mungkin nanti'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_nl.dart b/mobile/lib/l10n/generated/app_localizations_nl.dart index aee75e5f28..80a8b404c9 100644 --- a/mobile/lib/l10n/generated/app_localizations_nl.dart +++ b/mobile/lib/l10n/generated/app_localizations_nl.dart @@ -12612,30 +12612,29 @@ class AppLocalizationsNl extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name is een $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Divine-supporter'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Steunt de onafhankelijke app voor een opener, creatiever internet.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Houdt Divine onafhankelijk'; @override - String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + String get supporterLightboxBenefitFeatures => 'Steunt gave nieuwe functies'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Voedt een creatievere community'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Misschien later'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_pl.dart b/mobile/lib/l10n/generated/app_localizations_pl.dart index 344211f583..dea5e0804f 100644 --- a/mobile/lib/l10n/generated/app_localizations_pl.dart +++ b/mobile/lib/l10n/generated/app_localizations_pl.dart @@ -12808,30 +12808,30 @@ class AppLocalizationsPl extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name jest w gronie $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Wspierających Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Wspiera niezależną aplikację dla bardziej otwartego i kreatywnego internetu.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Pomaga utrzymać niezależność Divine'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Wspiera nowe, ekscytujące funkcje'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Napędza bardziej kreatywną społeczność'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Może później'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_pt.dart b/mobile/lib/l10n/generated/app_localizations_pt.dart index 1fdd03afe5..67a4d8ae04 100644 --- a/mobile/lib/l10n/generated/app_localizations_pt.dart +++ b/mobile/lib/l10n/generated/app_localizations_pt.dart @@ -12656,30 +12656,30 @@ class AppLocalizationsPt extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name está entre os $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Apoiadores do Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Apoia o app independente por uma internet mais aberta e criativa.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Ajuda a manter o Divine independente'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Apoia novos recursos incríveis'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Fortalece uma comunidade mais criativa'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Talvez depois'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ro.dart b/mobile/lib/l10n/generated/app_localizations_ro.dart index 5f82ecaed0..8cd0568689 100644 --- a/mobile/lib/l10n/generated/app_localizations_ro.dart +++ b/mobile/lib/l10n/generated/app_localizations_ro.dart @@ -12820,30 +12820,30 @@ class AppLocalizationsRo extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name se numără printre $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Susținătorii Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Susține aplicația independentă pentru un internet mai deschis și mai creativ.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Ajută Divine să rămână independent'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Susține funcții noi și interesante'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Hrănește o comunitate mai creativă'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Poate mai târziu'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_sv.dart b/mobile/lib/l10n/generated/app_localizations_sv.dart index 490f8d5dfc..c595b55ebc 100644 --- a/mobile/lib/l10n/generated/app_localizations_sv.dart +++ b/mobile/lib/l10n/generated/app_localizations_sv.dart @@ -12554,30 +12554,30 @@ class AppLocalizationsSv extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name är $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Divine-supporter'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Stöttar den oberoende appen för ett öppnare, mer kreativt internet.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Hjälper Divine att förbli oberoende'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Stöttar spännande nya funktioner'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Driver en mer kreativ community'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Kanske senare'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_te.dart b/mobile/lib/l10n/generated/app_localizations_te.dart index a2483a1e27..ff75abfc81 100644 --- a/mobile/lib/l10n/generated/app_localizations_te.dart +++ b/mobile/lib/l10n/generated/app_localizations_te.dart @@ -12881,30 +12881,30 @@ class AppLocalizationsTe extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name ఒక $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Divine మద్దతుదారు'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'మరింత ఓపెన్, సృజనాత్మక ఇంటర్నెట్ కోసం స్వతంత్ర యాప్‌కు మద్దతు ఇస్తున్నారు.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Divine స్వతంత్రంగా ఉండేందుకు సహాయపడుతుంది'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'కొత్త ఆసక్తికరమైన ఫీచర్లకు మద్దతు ఇస్తుంది'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'మరింత సృజనాత్మక కమ్యూనిటీకి శక్తినిస్తుంది'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'బహుశా తర్వాత'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_tr.dart b/mobile/lib/l10n/generated/app_localizations_tr.dart index 47a56c6741..4c798fe00b 100644 --- a/mobile/lib/l10n/generated/app_localizations_tr.dart +++ b/mobile/lib/l10n/generated/app_localizations_tr.dart @@ -12474,30 +12474,30 @@ class AppLocalizationsTr extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name bir $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Divine Destekçisi'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Daha açık ve yaratıcı bir internet için bağımsız uygulamayı destekliyor.'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Divine\'ın bağımsız kalmasına yardım eder'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Heyecan verici yeni özellikleri destekler'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Daha yaratıcı bir topluluğa güç verir'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Belki sonra'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_ur.dart b/mobile/lib/l10n/generated/app_localizations_ur.dart index 7b6fb8eae7..cc91eb917c 100644 --- a/mobile/lib/l10n/generated/app_localizations_ur.dart +++ b/mobile/lib/l10n/generated/app_localizations_ur.dart @@ -12536,30 +12536,29 @@ class AppLocalizationsUr extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name ایک $badge ہیں'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Divine سپورٹر'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'زیادہ کھلے اور تخلیقی انٹرنیٹ کے لیے آزاد ایپ کی سپورٹ۔'; @override String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + 'Divine کو آزاد رکھنے میں مدد'; @override - String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + String get supporterLightboxBenefitFeatures => 'نئے دلچسپ فیچرز کی سپورٹ'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'زیادہ تخلیقی کمیونٹی کو تقویت'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'شاید بعد میں'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_vi.dart b/mobile/lib/l10n/generated/app_localizations_vi.dart index c9f33e42bf..9662cb7db8 100644 --- a/mobile/lib/l10n/generated/app_localizations_vi.dart +++ b/mobile/lib/l10n/generated/app_localizations_vi.dart @@ -12487,30 +12487,29 @@ class AppLocalizationsVi extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name là $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Người ủng hộ Divine'; @override String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + 'Ủng hộ ứng dụng độc lập vì một internet cởi mở và sáng tạo hơn.'; @override - String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + String get supporterLightboxBenefitIndependent => 'Giúp Divine luôn độc lập'; @override String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + 'Hỗ trợ những tính năng mới thú vị'; @override String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + 'Tiếp sức cho cộng đồng sáng tạo hơn'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => 'Để sau'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/lib/l10n/generated/app_localizations_zh.dart b/mobile/lib/l10n/generated/app_localizations_zh.dart index 17f4a9a3b6..1c602b0e68 100644 --- a/mobile/lib/l10n/generated/app_localizations_zh.dart +++ b/mobile/lib/l10n/generated/app_localizations_zh.dart @@ -11774,30 +11774,26 @@ class AppLocalizationsZh extends AppLocalizations { @override String supporterLightboxHeadline(String name, String badge) { - return '$name is a $badge'; + return '$name 是 $badge'; } @override - String get supporterLightboxBadge => 'Divine Supporter'; + String get supporterLightboxBadge => 'Divine 支持者'; @override - String get supporterLightboxBody => - 'Supporting the independent app for a more open, creative internet.'; + String get supporterLightboxBody => '支持这款独立应用,让互联网更开放、更有创意。'; @override - String get supporterLightboxBenefitIndependent => - 'Helps keep Divine independent'; + String get supporterLightboxBenefitIndependent => '帮助 Divine 保持独立'; @override - String get supporterLightboxBenefitFeatures => - 'Supports new exciting features'; + String get supporterLightboxBenefitFeatures => '支持令人兴奋的新功能'; @override - String get supporterLightboxBenefitCommunity => - 'Powers a more creative community'; + String get supporterLightboxBenefitCommunity => '助力更有创意的社区'; @override - String get supporterLightboxMaybeLater => 'Maybe later'; + String get supporterLightboxMaybeLater => '以后再说'; @override String get supporterPublicRecognition => 'Show my Supporter badge publicly'; diff --git a/mobile/test/l10n/arb_consistency_test.dart b/mobile/test/l10n/arb_consistency_test.dart index 9dc6673408..be27a8954c 100644 --- a/mobile/test/l10n/arb_consistency_test.dart +++ b/mobile/test/l10n/arb_consistency_test.dart @@ -632,15 +632,6 @@ const _knownUntranslatedDebt = { 'contentPreferencesSubtitleKeepOriginal', 'contentPreferencesSubtitleKeepOriginalNone', - // Supporter avatar lightbox prototype (#9914), English until translated. - 'supporterLightboxHeadline', - 'supporterLightboxBadge', - 'supporterLightboxBody', - 'supporterLightboxBenefitIndependent', - 'supporterLightboxBenefitFeatures', - 'supporterLightboxBenefitCommunity', - 'supporterLightboxMaybeLater', - // A people list whose members are all hidden from the viewer (#9895). 'peopleListsAllMembersHiddenTitle', 'peopleListsAllMembersHiddenSubtitle', From 1fe762d11940ce4546322124f51b1dc45c6a04e4 Mon Sep 17 00:00:00 2001 From: Daniel Cadenas Date: Thu, 22 Oct 2026 07:16:27 -0300 Subject: [PATCH 09/32] fix(profile): keep supporter name honorifics on the same line --- .../profile_header_supporter_lightbox.dart | 6 ++-- mobile/test/l10n/arb_consistency_test.dart | 1 - .../profile/profile_header_widget_test.dart | 32 +++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index b1cd3bf62c..94089ff594 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -369,9 +369,9 @@ class _SupporterLightboxHeadline extends StatelessWidget { ), ), ); - // A name that leads the sentence gets a line of its own, as in the - // design; otherwise it flows inline. - if (template.startsWith(_nameToken)) { + // Give a leading name its own line only at a word boundary. Attached + // honorifics (for example in Japanese and Korean) stay with the name. + if (template.startsWith(_nameToken) && part.startsWith(RegExp(r'\s'))) { spans.add(const TextSpan(text: '\n')); } } diff --git a/mobile/test/l10n/arb_consistency_test.dart b/mobile/test/l10n/arb_consistency_test.dart index be27a8954c..2ac1b78531 100644 --- a/mobile/test/l10n/arb_consistency_test.dart +++ b/mobile/test/l10n/arb_consistency_test.dart @@ -631,7 +631,6 @@ const _knownUntranslatedDebt = { 'contentPreferencesSubtitleLanguageFollowApp', 'contentPreferencesSubtitleKeepOriginal', 'contentPreferencesSubtitleKeepOriginalNone', - // A people list whose members are all hidden from the viewer (#9895). 'peopleListsAllMembersHiddenTitle', 'peopleListsAllMembersHiddenSubtitle', diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 5c958d575e..42e14b7891 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -805,6 +805,7 @@ void main() { required bool isOwnProfile, bool isPublicSupporter = false, SupporterRepository? ownMembership, + Locale locale = const Locale('en'), }) async { await tester.pumpWidget( buildTestWidget( @@ -818,6 +819,7 @@ void main() { isPublicSupporter: isPublicSupporter, ), supporterRepository: ownMembership, + locale: locale, // Holds the halo still so the tree can settle. disableAnimations: true, ), @@ -936,6 +938,36 @@ void main() { }); group('interactions', () { + for (final language in ['ja', 'ko', 'en']) { + testWidgets('keeps $language name suffixes attached in the ' + 'supporter headline', (tester) async { + final locale = Locale(language); + final localized = lookupAppLocalizations(locale); + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + locale: locale, + ); + await openSupporterView(tester); + + final headline = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Text && + widget.textSpan != null && + widget.textSpan!.toPlainText().contains( + localized.supporterLightboxBadge, + ), + ), + ); + expect( + headline.textSpan!.toPlainText().contains('\n'), + language == 'en', + ); + }); + } + testWidgets('keeps the halo and wings at full opacity when the ' 'supporter view closes', (tester) async { await tester.pumpWidget( From 36c7993ea256111f56881a856affe05e0e13e45c Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 22 Oct 2026 12:34:02 +0200 Subject: [PATCH 10/32] docs(profile): drop the public setting from the own supporter view's doc The avatar's isOwnProfile doc said the owner's supporter view carries the public setting, but that view deliberately has none: a test asserts there is no switch, and the setting lives in Edit Profile and the Supporter screen. Also rejoins a wrapped doc line on SupporterHaloWings. --- mobile/lib/widgets/profile/profile_header_media.dart | 3 ++- mobile/lib/widgets/profile/supporter_halo_wings.dart | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_media.dart b/mobile/lib/widgets/profile/profile_header_media.dart index bf69a8cdcc..94297f1627 100644 --- a/mobile/lib/widgets/profile/profile_header_media.dart +++ b/mobile/lib/widgets/profile/profile_header_media.dart @@ -467,7 +467,8 @@ class _ProfileAvatarWithColor extends StatelessWidget { final VoidCallback? onActionTap; /// Whether this is the viewer's own profile, which reflects their own - /// membership and opens their supporter view, with its public setting. + /// membership and opens their own supporter view. That view has no public + /// setting; it lives in Edit Profile and the Supporter screen. final bool isOwnProfile; @override diff --git a/mobile/lib/widgets/profile/supporter_halo_wings.dart b/mobile/lib/widgets/profile/supporter_halo_wings.dart index 217ae7e4e7..660dc00b0c 100644 --- a/mobile/lib/widgets/profile/supporter_halo_wings.dart +++ b/mobile/lib/widgets/profile/supporter_halo_wings.dart @@ -7,8 +7,7 @@ import 'package:flutter/widgets.dart'; import 'package:openvine/extensions/media_query_extensions.dart'; import 'package:visibility_detector/visibility_detector.dart'; -/// Wraps [avatar] with a halo above it and a wing on each -/// side. +/// Wraps [avatar] with a halo above it and a wing on each side. /// /// Geometry comes from the Figma profile frame, where the avatar is 144pt. /// Every offset is stored as a fraction of that size so the treatment scales From 238faccd8aa8df0d2049f5a23e396a3c37c82929 Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 22 Oct 2026 12:34:58 +0200 Subject: [PATCH 11/32] refactor(profile): share one route between the avatar lightboxes The supporter view copied the photo lightbox's whole route: the root navigator, the transparent barrier, the fade and the detached push. Both now go through one helper, so the comment explaining why the route sits on the root navigator, and must be a PageRoute for the Hero, covers both. --- .../widgets/profile/profile_header_media.dart | 19 ++++++++++++--- .../profile_header_supporter_lightbox.dart | 23 +++++-------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_media.dart b/mobile/lib/widgets/profile/profile_header_media.dart index 94297f1627..faae0241db 100644 --- a/mobile/lib/widgets/profile/profile_header_media.dart +++ b/mobile/lib/widgets/profile/profile_header_media.dart @@ -726,6 +726,20 @@ void _showAvatarLightbox( required String userIdHex, String? imageUrl, }) { + _pushAvatarLightbox( + context, + _AvatarLightbox(imageUrl: imageUrl, userIdHex: userIdHex), + 'open avatar lightbox', + ); +} + +/// Pushes [lightbox], which the header avatar's Hero flies into, over the +/// whole app. +void _pushAvatarLightbox( + BuildContext context, + Widget lightbox, + String description, +) { // Push on the root navigator so the full-screen blurred backdrop covers the // bottom navigation bar: on the own-profile tab the nearest navigator is the // StatefulShellRoute branch, confined to the Scaffold body. (The previous @@ -742,14 +756,13 @@ void _showAvatarLightbox( barrierColor: VineTheme.transparent, barrierDismissible: true, barrierLabel: context.l10n.profileAvatarLightboxBarrierLabel, - pageBuilder: (context, _, _) => - _AvatarLightbox(imageUrl: imageUrl, userIdHex: userIdHex), + pageBuilder: (_, _, _) => lightbox, transitionsBuilder: (context, animation, _, child) => FadeTransition(opacity: animation, child: child), ), ), ), - 'open avatar lightbox', + description, ); } diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index 94089ff594..bb110311bc 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -102,23 +102,12 @@ void _showSupporterLightbox( required bool isOwnProfile, String? imageUrl, }) { - _runProfileDetached( - _awaitProfileResult( - Navigator.of(context, rootNavigator: true).push( - PageRouteBuilder( - opaque: false, - barrierColor: VineTheme.transparent, - barrierDismissible: true, - barrierLabel: context.l10n.profileAvatarLightboxBarrierLabel, - pageBuilder: (context, _, _) => _SupporterLightbox( - imageUrl: imageUrl, - userIdHex: userIdHex, - isOwnProfile: isOwnProfile, - ), - transitionsBuilder: (context, animation, _, child) => - FadeTransition(opacity: animation, child: child), - ), - ), + _pushAvatarLightbox( + context, + _SupporterLightbox( + imageUrl: imageUrl, + userIdHex: userIdHex, + isOwnProfile: isOwnProfile, ), 'open supporter lightbox', ); From 923f53810c9cd504f9d4236e91ade538b195aba1 Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 22 Oct 2026 12:38:51 +0200 Subject: [PATCH 12/32] test(profile): pin who the supporter view invites to join The description promises that viewers who already support Divine see no invitation in another supporter's view, but no test checked either side. These fail if the call to action is always or never shown. --- .../profile/profile_header_widget_test.dart | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 42e14b7891..27d29f642c 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -1031,6 +1031,36 @@ void main() { expect(find.text(l10n.supporterActiveBadge), findsNothing); }); + testWidgets('invites a viewer who is not a supporter to become one', ( + tester, + ) async { + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + ); + + await openSupporterView(tester); + + expect(find.text(l10n.supporterJoinLabel), findsOneWidget); + }); + + testWidgets('does not invite a viewer who already supports Divine', ( + tester, + ) async { + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + ownMembership: await membership(tester, isSupporter: true), + ); + + await openSupporterView(tester); + + expect(find.text(l10n.supporterLightboxBody), findsOneWidget); + expect(find.text(l10n.supporterJoinLabel), findsNothing); + }); + testWidgets('tapping your own supporter avatar opens your supporter ' 'view, without its public setting', (tester) async { await pumpHeader( From ac388c4f18bd64fecf3d3977abff88e92a862e7f Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 22 Oct 2026 12:41:24 +0200 Subject: [PATCH 13/32] fix(profile): keep the space after a mid-sentence supporter name The headline trimmed the leading space of every piece after the name, not only the one the line break replaces. A translation that puts the name in the middle of the sentence, which the ARB note invites, would render it glued to the next word. No shipped locale does that today, so the test overrides the English headline to put the name mid-sentence. --- .../profile_header_supporter_lightbox.dart | 7 ++- .../profile/profile_header_widget_test.dart | 63 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index bb110311bc..5f1bc667e9 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -344,6 +344,7 @@ class _SupporterLightboxHeadline extends StatelessWidget { final template = l10n.supporterLightboxHeadline(_nameToken, _badgeToken); final spans = []; for (final (index, part) in template.split(_nameToken).indexed) { + var text = part; if (index > 0) { spans.add( WidgetSpan( @@ -360,11 +361,13 @@ class _SupporterLightboxHeadline extends StatelessWidget { ); // Give a leading name its own line only at a word boundary. Attached // honorifics (for example in Japanese and Korean) stay with the name. - if (template.startsWith(_nameToken) && part.startsWith(RegExp(r'\s'))) { + if (template.startsWith(_nameToken) && text.startsWith(RegExp(r'\s'))) { spans.add(const TextSpan(text: '\n')); + // The break replaces that space; a name mid-sentence keeps its own. + text = text.trimLeft(); } } - final pieces = part.trimLeft().split(_badgeToken); + final pieces = text.split(_badgeToken); for (final (badgeIndex, piece) in pieces.indexed) { if (badgeIndex > 0) { spans.add( diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 27d29f642c..917c4147c5 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -33,6 +33,7 @@ import 'package:openvine/features/feature_flags/providers/feature_flag_providers import 'package:openvine/features/monetization/monetization_storefront_policy.dart'; import 'package:openvine/features/people_lists/bloc/people_lists_bloc.dart'; import 'package:openvine/features/people_lists/view/people_list_membership_indicator.dart'; +import 'package:openvine/l10n/generated/app_localizations_en.dart'; import 'package:openvine/l10n/l10n.dart'; import 'package:openvine/models/auth_rpc_capability.dart'; import 'package:openvine/models/divine_video_draft.dart'; @@ -99,6 +100,28 @@ class _MockBackgroundPublishBloc class _MockSupporterApiClient extends Mock implements SupporterApiClient {} +/// English with the supporter headline's name in mid-sentence, as a +/// translation may place it. +class _MidSentenceNameLocalizations extends AppLocalizationsEn { + @override + String supporterLightboxHeadline(String name, String badge) => + 'As a $badge, $name keeps Divine going'; +} + +class _MidSentenceNameDelegate extends LocalizationsDelegate { + const _MidSentenceNameDelegate(); + + @override + bool isSupported(Locale locale) => locale.languageCode == 'en'; + + @override + Future load(Locale locale) => + SynchronousFuture(_MidSentenceNameLocalizations()); + + @override + bool shouldReload(_MidSentenceNameDelegate old) => false; +} + class _MockBadgeRepository extends Mock implements BadgeRepository {} // Mock classes @@ -398,6 +421,8 @@ void main() { bool disableAnimations = false, TextScaler? textScaler, Locale locale = const Locale('en'), + // Ahead of the app's own, so one of these wins for its type. + List> extraDelegates = const [], bool renderHeader = true, MockAuthService? authService, bool isVanished = false, @@ -556,7 +581,10 @@ void main() { ).overrideWith((ref) => monetizationLinksEnabled), ], child: MaterialApp( - localizationsDelegates: appLocalizationsDelegates, + localizationsDelegates: [ + ...extraDelegates, + ...appLocalizationsDelegates, + ], supportedLocales: AppLocalizations.supportedLocales, locale: locale, theme: theme, @@ -806,6 +834,7 @@ void main() { bool isPublicSupporter = false, SupporterRepository? ownMembership, Locale locale = const Locale('en'), + List> extraDelegates = const [], }) async { await tester.pumpWidget( buildTestWidget( @@ -820,6 +849,7 @@ void main() { ), supporterRepository: ownMembership, locale: locale, + extraDelegates: extraDelegates, // Holds the halo still so the tree can settle. disableAnimations: true, ), @@ -968,6 +998,37 @@ void main() { }); } + testWidgets('keeps the space after a name in the middle of the ' + 'supporter headline', (tester) async { + final strings = _MidSentenceNameLocalizations(); + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + extraDelegates: const [_MidSentenceNameDelegate()], + ); + await openSupporterView(tester); + + final headline = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Text && + (widget.textSpan?.toPlainText().contains( + strings.supporterLightboxBadge, + ) ?? + false), + ), + ); + // The display name is a widget, which plain text shows as U+FFFC. + expect( + headline.textSpan!.toPlainText(), + strings.supporterLightboxHeadline( + String.fromCharCode(PlaceholderSpan.placeholderCodeUnit), + strings.supporterLightboxBadge, + ), + ); + }); + testWidgets('keeps the halo and wings at full opacity when the ' 'supporter view closes', (tester) async { await tester.pumpWidget( From 93e1f4b477f5e5564f9537f6fad97fea51d626f5 Mon Sep 17 00:00:00 2001 From: Daniel Cadenas Date: Thu, 22 Oct 2026 08:04:17 -0300 Subject: [PATCH 14/32] fix(l10n): use standalone supporter titles outside the headline --- .../supporter_recognition_section.dart | 2 +- .../profile_header_supporter_lightbox.dart | 2 +- .../supporter_recognition_section_test.dart | 2 ++ .../profile/profile_header_widget_test.dart | 20 +++++++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart index ae6614429d..50bc2f39e5 100644 --- a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart +++ b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart @@ -53,7 +53,7 @@ class _SupporterRecognition extends StatelessWidget { spacing: 8, children: [ Text( - l10n.supporterLightboxBadge, + l10n.supporterTitle, style: VineTheme.labelMediumFont( color: context.vineColors.mutedText, ), diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index 5f1bc667e9..dac954febd 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -46,7 +46,7 @@ class _SupporterAvatarHero extends StatelessWidget { Widget build(BuildContext context) { return Semantics( button: true, - label: context.l10n.supporterLightboxBadge, + label: context.l10n.supporterTitle, child: GestureDetector( onTap: () => _showSupporterLightbox( context, diff --git a/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart b/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart index b9e44864d5..db2f5e5d57 100644 --- a/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart +++ b/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart @@ -102,6 +102,8 @@ void main() { await pumpSection(tester, isSupporter: true); expect(find.text(l10n.supporterPublicRecognition), findsOneWidget); + expect(find.text(l10n.supporterTitle), findsOneWidget); + expect(find.text(l10n.supporterLightboxBadge), findsNothing); expect(switchValue(tester), isFalse); }); diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 917c4147c5..68ecf6168e 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -867,6 +867,26 @@ void main() { } group('renders', () { + testWidgets('labels the supporter avatar with the standalone title', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + ); + + expect( + find.bySemanticsLabel(RegExp(RegExp.escape(l10n.supporterTitle))), + findsOneWidget, + ); + } finally { + semantics.dispose(); + } + }); + testWidgets("shows the halo and wings on someone else's public " 'supporter profile', (tester) async { await pumpHeader( From 28818df00eb3815fba4dda63275f059afa4405ef Mon Sep 17 00:00:00 2001 From: Daniel Cadenas Date: Thu, 22 Oct 2026 08:13:45 -0300 Subject: [PATCH 15/32] fix(l10n): keep the Telugu supporter title free of subtitle text --- mobile/lib/l10n/app_te.arb | 2 +- mobile/lib/l10n/generated/app_localizations_te.dart | 3 +-- mobile/test/l10n/arb_consistency_test.dart | 6 ++++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mobile/lib/l10n/app_te.arb b/mobile/lib/l10n/app_te.arb index e9d8a495a3..fe925d3ef2 100644 --- a/mobile/lib/l10n/app_te.arb +++ b/mobile/lib/l10n/app_te.arb @@ -7509,7 +7509,7 @@ "supporterLightboxBenefitFeatures": "కొత్త ఆసక్తికరమైన ఫీచర్లకు మద్దతు ఇస్తుంది", "supporterLightboxBenefitCommunity": "మరింత సృజనాత్మక కమ్యూనిటీకి శక్తినిస్తుంది", "supporterLightboxMaybeLater": "బహుశా తర్వాత", - "supporterTitle": "Divine మద్దతుదారులు\nఐచ్ఛిక నెలవారీ సభ్యత్వంతో ", + "supporterTitle": "Divine మద్దతుదారులు", "@supporterTitle": { "description": "Title of the supporter subscription settings screen and its settings tile." }, diff --git a/mobile/lib/l10n/generated/app_localizations_te.dart b/mobile/lib/l10n/generated/app_localizations_te.dart index ff75abfc81..e8422ace91 100644 --- a/mobile/lib/l10n/generated/app_localizations_te.dart +++ b/mobile/lib/l10n/generated/app_localizations_te.dart @@ -12914,8 +12914,7 @@ class AppLocalizationsTe extends AppLocalizations { 'Let others see that you support Divine. This is optional and is not a verification badge.'; @override - String get supporterTitle => - 'Divine మద్దతుదారులు\nఐచ్ఛిక నెలవారీ సభ్యత్వంతో '; + String get supporterTitle => 'Divine మద్దతుదారులు'; @override String get supporterTileSubtitle => 'మద్దతు Divine.'; diff --git a/mobile/test/l10n/arb_consistency_test.dart b/mobile/test/l10n/arb_consistency_test.dart index 2ac1b78531..3a11ead1f9 100644 --- a/mobile/test/l10n/arb_consistency_test.dart +++ b/mobile/test/l10n/arb_consistency_test.dart @@ -8,6 +8,12 @@ import 'package:test/test.dart'; void main() { group('ARB consistency', () { + test('Telugu supporter title does not include a subtitle fragment', () { + final telugu = _readArb(File('lib/l10n/app_te.arb')); + + expect(telugu['supporterTitle'], 'Divine మద్దతుదారులు'); + }); + test('conversation dismissal and removal have distinct action labels', () { final arbFiles = Directory('lib/l10n').listSync().whereType().where( (file) => file.path.endsWith('.arb'), From 841659d054c7f3a4bdb048c8f86faabf35cc46ae Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 17:57:31 +0500 Subject: [PATCH 16/32] fix(profile): use the light-mode accent for the supporter badge text The "Divine Supporter" words in the supporter view headline used the fixed brand green, which sits at about 2:1 against the light page and fails the 3:1 bar for large text. The accent token is that same green in dark and a deep green in light, so dark mode is unchanged. --- .../profile_header_supporter_lightbox.dart | 2 +- .../profile/profile_header_widget_test.dart | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index dac954febd..f4a6e6f137 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -373,7 +373,7 @@ class _SupporterLightboxHeadline extends StatelessWidget { spans.add( TextSpan( text: l10n.supporterLightboxBadge, - style: style.copyWith(color: VineTheme.vineGreen), + style: style.copyWith(color: colors.accentBrand), ), ); } diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 68ecf6168e..ca2885c7dd 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -835,6 +835,7 @@ void main() { SupporterRepository? ownMembership, Locale locale = const Locale('en'), List> extraDelegates = const [], + ThemeData? theme, }) async { await tester.pumpWidget( buildTestWidget( @@ -850,6 +851,7 @@ void main() { supporterRepository: ownMembership, locale: locale, extraDelegates: extraDelegates, + theme: theme, // Holds the halo still so the tree can settle. disableAnimations: true, ), @@ -1049,6 +1051,44 @@ void main() { ); }); + for (final (appearance, theme, accent) in [ + ('dark', VineTheme.theme, VineTheme.darkColors.accentBrand), + ('light', VineTheme.lightTheme, VineTheme.lightColors.accentBrand), + ]) { + testWidgets('draws the supporter badge in the $appearance accent', ( + tester, + ) async { + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + theme: theme, + ); + await openSupporterView(tester); + + final headline = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Text && + (widget.textSpan?.toPlainText().contains( + l10n.supporterLightboxBadge, + ) ?? + false), + ), + ); + final spans = []; + headline.textSpan!.visitChildren((span) { + if (span is TextSpan && + span.text == l10n.supporterLightboxBadge) { + spans.add(span); + } + return true; + }); + expect(spans, hasLength(1)); + expect(spans.single.style?.color, accent); + }); + } + testWidgets('keeps the halo and wings at full opacity when the ' 'supporter view closes', (tester) async { await tester.pumpWidget( From 9e2cc449daf8d9a3723cec2a7bb15bc30cd30724 Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 17:59:45 +0500 Subject: [PATCH 17/32] fix(profile): stop leaking curve listeners from the supporter view The supporter view built a CurvedAnimation in build, and its Hero shuttle built another per flight. Each one adds a status listener to the route animation and is never disposed, so every rebuild added another. Driving the route animation through a CurveTween gives the same values without registering anything of its own. With leak tracking enabled for CurvedAnimation, opening the supporter view reported one undisposed CurvedAnimation before this change and none after. --- .../profile/profile_header_supporter_lightbox.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index f4a6e6f137..9282c6cbe4 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -76,10 +76,9 @@ HeroFlightShuttleBuilder _supporterShuttle(String? imageUrl, String userIdHex) { return (context, animation, _, _, _) => FittedBox( child: SupporterHaloWings( avatarSize: _supporterLightboxAvatarSize, - sparkles: CurvedAnimation( - parent: animation, - curve: _supporterSparkleReveal, - ), + // A driven tween, unlike a CurvedAnimation, adds no listener to the + // route animation that would outlive this flight. + sparkles: animation.drive(CurveTween(curve: _supporterSparkleReveal)), avatar: UserAvatar( imageUrl: imageUrl, placeholderSeed: userIdHex, @@ -131,9 +130,11 @@ class _SupporterLightbox extends StatelessWidget { final padding = MediaQuery.paddingOf(context); final colors = context.vineColors; final route = ModalRoute.of(context)!.animation!; + // Driven rather than a CurvedAnimation, which would add a listener to the + // route animation on every rebuild and never remove it. final reveal = context.reduceMotion ? route - : CurvedAnimation(parent: route, curve: _supporterContentReveal); + : route.drive(CurveTween(curve: _supporterContentReveal)); return BackdropFilter( filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), // Text here needs a Material ancestor for its default style; the route From 3aeeefc3f20646ab1564b12b97c1a72fb4100b46 Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:02:08 +0500 Subject: [PATCH 18/32] fix(profile): skip the membership read for signed-out viewers The supporter view's call to action read the account-scoped supporter repository for every viewer. For a guest that builds a repository for an 'unauthenticated' account and the kept-alive store validator, which starts listening to in-app purchases, only to show the invitation guests always see. Guests now get the invitation without that read, as the settings entry already does. --- .../profile_header_supporter_lightbox.dart | 6 +++ .../profile/profile_header_widget_test.dart | 37 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index 9282c6cbe4..16d634a7e7 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -464,6 +464,12 @@ class _SupporterLightboxActions extends ConsumerWidget { if (!ref.watch(supporterApiConfiguredProvider)) { return const _SupporterLightboxButtons(); } + // A guest has no membership to read, and reading one would start the + // store connection behind it. + ref.watch(currentAuthStateProvider); + if (!ref.watch(authServiceProvider).isAuthenticated) { + return const _SupporterLightboxButtons(); + } final repository = ref.watch(supporterRepositoryProvider); return BlocProvider( key: ValueKey(repository), diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index ca2885c7dd..dd778768c4 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -213,12 +213,14 @@ class _FakeCacheDao implements CacheDao { class MockAuthService extends Mock implements AuthService { MockAuthService({ this.isAnonymousValue = false, + this.isAuthenticatedValue = true, bool hasExpiredOAuthSessionValue = false, this.isRpcUpgradeInProgressValue = false, this.tryRefreshResult = false, }) : expiredOAuthSession = ValueNotifier(hasExpiredOAuthSessionValue); final bool isAnonymousValue; + final bool isAuthenticatedValue; /// Set its value to flip the flag while the header is mounted, as a refused /// mid-session refresh does (#9943). @@ -234,7 +236,7 @@ class MockAuthService extends Mock implements AuthService { bool get isAnonymous => isAnonymousValue; @override - bool get isAuthenticated => true; + bool get isAuthenticated => isAuthenticatedValue; @override String? get currentPublicKeyHex => testUserHex; @@ -433,6 +435,7 @@ void main() { Nip05VerificationStatus.verified, SupporterApiClient? supporterClient, SupporterRepository? supporterRepository, + VoidCallback? onSupporterRepositoryRead, }) { // Pass authService when the test needs the same instance across pumps — // e.g. to read tryRefreshCallCount after the header has been unmounted. @@ -545,7 +548,10 @@ void main() { supporterApiClient: supporterClient, ), if (supporterRepository != null) - supporterRepositoryProvider.overrideWithValue(supporterRepository), + supporterRepositoryProvider.overrideWith((ref) { + onSupporterRepositoryRead?.call(); + return supporterRepository; + }), profileVanishedProvider(userIdHex).overrideWith((ref) => isVanished), if ((profile ?? suppliedProfile)?.nip05 case final String claim) nip05ClaimVerificationProvider( @@ -1166,6 +1172,33 @@ void main() { expect(find.text(l10n.supporterJoinLabel), findsOneWidget); }); + testWidgets('invites a signed-out viewer without reading a ' + 'membership', (tester) async { + var membershipReads = 0; + final repository = await membership(tester, isSupporter: false); + await tester.pumpWidget( + buildTestWidget( + userIdHex: testUserHex, + isOwnProfile: false, + suppliedProfile: createTestProfile( + displayName: 'Ivy', + picture: supporterPicture, + ), + supporterClient: supporterClient(isPublicSupporter: true), + supporterRepository: repository, + onSupporterRepositoryRead: () => membershipReads++, + authService: MockAuthService(isAuthenticatedValue: false), + disableAnimations: true, + ), + ); + await tester.pumpAndSettle(); + + await openSupporterView(tester); + + expect(find.text(l10n.supporterJoinLabel), findsOneWidget); + expect(membershipReads, isZero); + }); + testWidgets('does not invite a viewer who already supports Divine', ( tester, ) async { From 1e5f0e4397a364cf0a6e4bef4eed2fb1ff000847 Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:03:51 +0500 Subject: [PATCH 19/32] fix(profile): scale the supporter view's avatar group on narrow screens The group claims the avatar's width plus both wings, about 307pt in the supporter view. Below roughly 355pt of screen width the column clamps that box while the avatar keeps its offset, so the right wing slid about 30pt over the avatar and the Hero box stopped matching the header's shape. The group now scales down as one piece when it does not fit. --- .../profile_header_supporter_lightbox.dart | 31 ++++++++++------- .../profile/profile_header_widget_test.dart | 34 +++++++++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index 16d634a7e7..b0a43075b8 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -307,19 +307,24 @@ class _SupporterLightboxAvatar extends StatelessWidget { @override Widget build(BuildContext context) { const size = _supporterLightboxAvatarSize; - return Hero( - tag: _avatarHeroTag(userIdHex), - createRectTween: _supporterRectTween, - flightShuttleBuilder: _supporterShuttle(imageUrl, userIdHex), - child: SupporterHaloWings( - avatarSize: size, - sparkles: kAlwaysCompleteAnimation, - avatar: UserAvatar( - imageUrl: imageUrl, - placeholderSeed: userIdHex, - size: size, - cornerRadius: size * _avatarHeroCornerRatio, - previewDecodeSize: _headerAvatarSize, + // The wings make the group wider than some phones allow; scale it as one + // piece there rather than let the wings slide over the avatar. + return FittedBox( + fit: BoxFit.scaleDown, + child: Hero( + tag: _avatarHeroTag(userIdHex), + createRectTween: _supporterRectTween, + flightShuttleBuilder: _supporterShuttle(imageUrl, userIdHex), + child: SupporterHaloWings( + avatarSize: size, + sparkles: kAlwaysCompleteAnimation, + avatar: UserAvatar( + imageUrl: imageUrl, + placeholderSeed: userIdHex, + size: size, + cornerRadius: size * _avatarHeroCornerRatio, + previewDecodeSize: _headerAvatarSize, + ), ), ), ); diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index dd778768c4..6ddf482bf5 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -1095,6 +1095,40 @@ void main() { }); } + testWidgets('keeps the wings clear of the avatar in the supporter ' + 'view on a narrow screen', (tester) async { + tester.view + ..physicalSize = const Size(320, 800) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + ); + await openSupporterView(tester); + + // The supporter view's group, not the header's behind it. + final group = find.byWidgetPredicate( + (widget) => + widget is SupporterHaloWings && widget.avatarSize == 192, + ); + final avatar = tester.getRect( + find.descendant(of: group, matching: find.byType(UserAvatar)), + ); + final rightWing = tester.getRect( + find.descendant( + of: group, + matching: find.image( + const AssetImage('assets/supporter/wing_right.png'), + ), + ), + ); + // The wing tucks a few points behind the avatar by design. + expect(avatar.right - rightWing.left, lessThan(10)); + expect(tester.takeException(), isNull); + }); + testWidgets('keeps the halo and wings at full opacity when the ' 'supporter view closes', (tester) async { await tester.pumpWidget( From 763e2423957b2e7addb3d3add1d089f2ef3d3560 Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:05:59 +0500 Subject: [PATCH 20/32] fix(profile): hold the supporter halo level when motion is off With reduced motion the loop stopped at its first frame, which is not a rest pose: the halo sat at its full 2.5 degree tilt, the wings at 5.5 and 3.3 degrees, and the avatar itself turned and shifted slightly. Without motion the halo and the group now stay untransformed and both wings rest at the middle of their swing, so they stay tucked behind the avatar. --- .../widgets/profile/supporter_halo_wings.dart | 30 +++++++++++++----- .../profile/profile_header_widget_test.dart | 31 +++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/mobile/lib/widgets/profile/supporter_halo_wings.dart b/mobile/lib/widgets/profile/supporter_halo_wings.dart index 660dc00b0c..23b0443e70 100644 --- a/mobile/lib/widgets/profile/supporter_halo_wings.dart +++ b/mobile/lib/widgets/profile/supporter_halo_wings.dart @@ -214,6 +214,8 @@ class _SupporterHaloWingsState extends State final wingTop = size * _Geometry.wingTop; final haloWidth = size * _Geometry.haloWidth; final haloHeight = size * _Geometry.haloHeight; + // The loop's first frame is mid-swing; without motion, hold a level pose. + final still = context.reduceMotion; // The box claims the wings' width so nothing beside it can crowd them. // The halo is left to overflow upward into the empty middle of the nav @@ -223,7 +225,7 @@ class _SupporterHaloWingsState extends State onVisibilityChanged: _onVisibilityChanged, child: _AnimatedPart( animation: _controller, - builder: (t) => _Group(t: t, avatarSize: size), + builder: (t) => _Group(t: t, avatarSize: size, still: still), // Its own boundary keeps the static avatar from repainting as the // group moves. child: RepaintBoundary( @@ -259,7 +261,7 @@ class _SupporterHaloWingsState extends State opacity: widget.reveal ?? kAlwaysCompleteAnimation, child: _AnimatedPart( animation: _controller, - builder: (t) => _Wing(t: t, isLeft: true), + builder: (t) => _Wing(t: t, isLeft: true, still: still), child: const _PartImage('assets/supporter/wing_left.png'), ), ), @@ -273,7 +275,7 @@ class _SupporterHaloWingsState extends State opacity: widget.reveal ?? kAlwaysCompleteAnimation, child: _AnimatedPart( animation: _controller, - builder: (t) => _Wing(t: t, isLeft: false), + builder: (t) => _Wing(t: t, isLeft: false, still: still), child: const _PartImage( 'assets/supporter/wing_right.png', ), @@ -296,7 +298,8 @@ class _SupporterHaloWingsState extends State opacity: widget.reveal ?? kAlwaysCompleteAnimation, child: _AnimatedPart( animation: _controller, - builder: (t) => _Halo(t: t, avatarSize: size), + builder: (t) => + _Halo(t: t, avatarSize: size, still: still), child: const _PartImage('assets/supporter/halo.png'), ), ), @@ -317,17 +320,20 @@ abstract class _PartTransform { } class _Wing implements _PartTransform { - _Wing({required this.t, required this.isLeft}); + _Wing({required this.t, required this.isLeft, required this.still}); final double t; final bool isLeft; + /// Holds both wings at the middle of their swing. + final bool still; + @override Matrix4 get matrix { final phase = isLeft ? t : t - _Motion.wingPhaseLag; const mid = (_Motion.wingLowRadians + _Motion.wingHighRadians) / 2; const amplitude = (_Motion.wingLowRadians - _Motion.wingHighRadians) / 2; - final drop = mid + math.sin(phase * 2 * math.pi) * amplitude; + final drop = still ? mid : mid + math.sin(phase * 2 * math.pi) * amplitude; // Tipping down swings the inner edge below the pivot into the avatar. return Matrix4.rotationZ(isLeft ? -drop : drop); } @@ -339,13 +345,17 @@ class _Wing implements _PartTransform { } class _Halo implements _PartTransform { - _Halo({required this.t, required this.avatarSize}); + _Halo({required this.t, required this.avatarSize, required this.still}); final double t; final double avatarSize; + /// Holds the halo level at its resting height. + final bool still; + @override Matrix4 get matrix { + if (still) return Matrix4.identity(); final angle = t * 2 * math.pi; final bob = -math.sin(angle) * avatarSize * _Motion.haloBobFraction; final tilt = math.sin(angle + math.pi / 2) * _Motion.haloTiltRadians; @@ -376,13 +386,17 @@ class _Sparkle implements _PartTransform { } class _Group implements _PartTransform { - _Group({required this.t, required this.avatarSize}); + _Group({required this.t, required this.avatarSize, required this.still}); final double t; final double avatarSize; + /// Leaves the group, and so the avatar, untransformed. + final bool still; + @override Matrix4 get matrix { + if (still) return Matrix4.identity(); final angle = (t - _Motion.groupPhaseLag) * 2 * math.pi; final bob = -math.sin(angle) * avatarSize * _Motion.groupBobFraction; final tilt = math.sin(angle + math.pi / 2) * _Motion.groupTiltRadians; diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 6ddf482bf5..f242f10ee2 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'package:badge_repository/badge_repository.dart'; import 'package:bloc_test/bloc_test.dart'; @@ -906,6 +907,36 @@ void main() { expect(find.byType(SupporterHaloWings), findsOneWidget); }); + testWidgets('holds the halo level and the wings even under reduced ' + 'motion', (tester) async { + // pumpHeader turns animations off. + await pumpHeader( + tester, + isOwnProfile: false, + isPublicSupporter: true, + ); + + List transformsAbove(String asset) => tester + .widgetList( + find.ancestor( + of: find.image(AssetImage(asset)), + matching: find.byType(Transform), + ), + ) + .map((transform) => transform.transform) + .toList(); + double tilt(Matrix4 matrix) => + math.atan2(matrix.entry(1, 0), matrix.entry(0, 0)); + + final halo = transformsAbove('assets/supporter/halo.png'); + expect(halo, isNotEmpty); + expect(halo, everyElement(Matrix4.identity())); + final leftWing = transformsAbove('assets/supporter/wing_left.png'); + final rightWing = transformsAbove('assets/supporter/wing_right.png'); + expect(tilt(leftWing.first), isNot(0)); + expect(tilt(leftWing.first), closeTo(-tilt(rightWing.first), 1e-9)); + }); + testWidgets("shows no halo on someone else's profile without public " 'supporter status', (tester) async { await pumpHeader(tester, isOwnProfile: false); From f7f5a8aed4e92fa419008aafc0efb7b7c8b16e90 Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:06:52 +0500 Subject: [PATCH 21/32] test(supporter): read membership labels from l10n instead of English The membership tests asserted on literal English copy, which breaks on a copy change and cannot show the widgets still read their labels from localizations. They now resolve the same keys the widgets render. --- .../widgets/supporter_membership_test.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/mobile/test/widgets/supporter_membership_test.dart b/mobile/test/widgets/supporter_membership_test.dart index ad1c14f605..c926b5d5f8 100644 --- a/mobile/test/widgets/supporter_membership_test.dart +++ b/mobile/test/widgets/supporter_membership_test.dart @@ -29,6 +29,7 @@ class _MockAuth extends Mock implements AuthService {} void main() { const pubkey = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final l10n = lookupAppLocalizations(const Locale('en')); group('own account membership', () { testWidgets('signed-out compact entry supplies its own Material', ( @@ -75,7 +76,6 @@ void main() { ), ), ); - final l10n = lookupAppLocalizations(const Locale('en')); expect(find.text(l10n.supporterTileSubtitle), findsOneWidget); }); @@ -183,8 +183,8 @@ void main() { ), ); await tester.pump(); - expect(find.text('Divine Supporters'), findsOneWidget); - expect(find.text('Become a supporter'), findsNothing); + expect(find.text(l10n.supporterTitle), findsOneWidget); + expect(find.text(l10n.supporterJoinLabel), findsNothing); current = second; container.invalidate(supporterRepositoryProvider); await tester.pumpAndSettle(); @@ -197,8 +197,8 @@ void main() { await tester.pumpAndSettle(); expect(first.isSupporter, isTrue); expect(second.isSupporter, isFalse); - expect(find.text('Supporter'), findsNothing); - expect(find.text('Become a supporter'), findsOneWidget); + expect(find.text(l10n.supporterBadgeLabel), findsNothing); + expect(find.text(l10n.supporterJoinLabel), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); }); @@ -264,11 +264,11 @@ void main() { await tester.pumpAndSettle(); if (source == AuthenticationSource.amber) { expect(requests, 0); - expect(find.text('Divine Supporters'), findsOneWidget); + expect(find.text(l10n.supporterTitle), findsOneWidget); } else if (!succeeds) { expect(requests, 1); - expect(find.text('Divine Supporters'), findsOneWidget); - expect(find.text('Become a supporter'), findsNothing); + expect(find.text(l10n.supporterTitle), findsOneWidget); + expect(find.text(l10n.supporterJoinLabel), findsNothing); } else { expect(requests, 1); expect(repo.isSupporter, isTrue); @@ -295,7 +295,7 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.text('Supporter'), findsOneWidget); + expect(find.text(l10n.supporterBadgeLabel), findsOneWidget); } await tester.pumpWidget(const SizedBox.shrink()); }, From 335bef297fbf6b0985f2170ec2d42f0bcfcbaa32 Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:09:02 +0500 Subject: [PATCH 22/32] refactor(supporter): look up optional supporter cubits by nullable type Four places found an optional SupporterCubit or PublicSupporterCubit by catching ProviderNotFoundException. A nullable lookup answers the same question with null and no exception, so each becomes a plain null check. --- .../supporter_recognition_section.dart | 8 ++----- .../profile_header_supporter_lightbox.dart | 22 +++++++------------ mobile/lib/widgets/supporter_membership.dart | 11 +--------- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart index 50bc2f39e5..4d85425c47 100644 --- a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart +++ b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart @@ -26,12 +26,8 @@ class _SupporterRecognition extends StatelessWidget { @override Widget build(BuildContext context) { - final SupporterCubit cubit; - try { - cubit = context.read(); - } on ProviderNotFoundException { - return const SizedBox.shrink(); - } + final cubit = context.read(); + if (cubit == null) return const SizedBox.shrink(); final l10n = context.l10n; return BlocConsumer( bloc: cubit, diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index b0a43075b8..7f9e79ef96 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -599,23 +599,17 @@ bool _showsSupporterTreatment( final own = _ownPublicRecognition(context); if (own != null) return own; } - try { - return context.select((cubit) => cubit.state); - } on ProviderNotFoundException { - return false; - } + return context.select( + (cubit) => cubit?.state ?? false, + ); } /// The owner's published supporter status, or null until their account has /// been read. bool? _ownPublicRecognition(BuildContext context) { - try { - return context.select((cubit) { - final state = cubit.state; - if (state.snapshot == null) return null; - return state.isSupporter && state.haloVisible; - }); - } on ProviderNotFoundException { - return null; - } + return context.select((cubit) { + final state = cubit?.state; + if (state == null || state.snapshot == null) return null; + return state.isSupporter && state.haloVisible; + }); } diff --git a/mobile/lib/widgets/supporter_membership.dart b/mobile/lib/widgets/supporter_membership.dart index 90be1046ae..fcd0b17c49 100644 --- a/mobile/lib/widgets/supporter_membership.dart +++ b/mobile/lib/widgets/supporter_membership.dart @@ -40,7 +40,7 @@ class SupporterMembership extends ConsumerWidget { ); } final status = _MembershipStatus(compact: compact); - return _hasSupporterCubit(context) + return context.read() != null ? status : SupporterMembershipScope(child: status); } @@ -90,15 +90,6 @@ class SupporterMembershipScope extends ConsumerWidget { } } -bool _hasSupporterCubit(BuildContext context) { - try { - context.read(); - return true; - } on ProviderNotFoundException { - return false; - } -} - class _MembershipStatus extends StatelessWidget { const _MembershipStatus({required this.compact}); From 9cf21b364549f73781a8e597aadd721ed31c01e4 Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:10:13 +0500 Subject: [PATCH 23/32] refactor(supporter): keep the failure wording beside the other l10n maps The SupporterFailure-to-text extension lived in the Settings screen file, so the Edit Profile section imported that whole screen to word an error. It now sits in lib/l10n with the app's other code-to-string maps, and both screens import it from there. The extension itself is unchanged. --- mobile/lib/l10n/supporter_failure_l10n.dart | 19 +++++++++++++++++++ .../supporter_recognition_section.dart | 2 +- .../screens/settings/supporter_screen.dart | 15 +-------------- 3 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 mobile/lib/l10n/supporter_failure_l10n.dart diff --git a/mobile/lib/l10n/supporter_failure_l10n.dart b/mobile/lib/l10n/supporter_failure_l10n.dart new file mode 100644 index 0000000000..04a7921a3f --- /dev/null +++ b/mobile/lib/l10n/supporter_failure_l10n.dart @@ -0,0 +1,19 @@ +// ABOUTME: Maps SupporterFailure codes to localized strings. +// ABOUTME: Supporter state stores the failure; the UI layer localizes it. + +import 'package:openvine/blocs/supporter/supporter_state.dart'; +import 'package:openvine/l10n/l10n.dart'; + +/// User-facing wording for a [SupporterFailure]. +extension SupporterFailureMessage on SupporterFailure { + String message(AppLocalizations l10n) => switch (this) { + SupporterFailure.storeUnavailable => l10n.supporterErrorStoreUnavailable, + SupporterFailure.purchaseFailed => l10n.supporterErrorPurchaseFailed, + SupporterFailure.purchasePending => l10n.supporterErrorPurchasePending, + SupporterFailure.restoreFailed => l10n.supporterErrorRestoreFailed, + SupporterFailure.ownershipConflict => l10n.supporterErrorOwnershipConflict, + SupporterFailure.verificationUnavailable => + l10n.supporterErrorVerificationUnavailable, + SupporterFailure.unknown => l10n.supporterErrorUnknown, + }; +} diff --git a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart index 4d85425c47..13eb2daa7a 100644 --- a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart +++ b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart @@ -4,8 +4,8 @@ import 'package:material_ui/material_ui.dart'; import 'package:openvine/blocs/supporter/supporter_cubit.dart'; import 'package:openvine/blocs/supporter/supporter_state.dart'; import 'package:openvine/l10n/l10n.dart'; +import 'package:openvine/l10n/supporter_failure_l10n.dart'; import 'package:openvine/screens/profile_setup/widgets/profile_setup_rows.dart'; -import 'package:openvine/screens/settings/supporter_screen.dart'; import 'package:openvine/widgets/supporter_membership.dart'; /// Whether a supporter's halo and wings show on their profile to others. diff --git a/mobile/lib/screens/settings/supporter_screen.dart b/mobile/lib/screens/settings/supporter_screen.dart index e770759798..559c9456ac 100644 --- a/mobile/lib/screens/settings/supporter_screen.dart +++ b/mobile/lib/screens/settings/supporter_screen.dart @@ -11,6 +11,7 @@ import 'package:openvine/blocs/supporter/supporter_cubit.dart'; import 'package:openvine/blocs/supporter/supporter_state.dart'; import 'package:openvine/extensions/safe_pop_extension.dart'; import 'package:openvine/l10n/l10n.dart'; +import 'package:openvine/l10n/supporter_failure_l10n.dart'; import 'package:openvine/providers/analytics_providers.dart'; import 'package:openvine/providers/supporter_providers.dart'; import 'package:openvine/screens/settings/settings_screen.dart'; @@ -449,20 +450,6 @@ class _FailureBanner extends StatelessWidget { } } -/// User-facing wording for a [SupporterFailure]. -extension SupporterFailureMessage on SupporterFailure { - String message(AppLocalizations l10n) => switch (this) { - SupporterFailure.storeUnavailable => l10n.supporterErrorStoreUnavailable, - SupporterFailure.purchaseFailed => l10n.supporterErrorPurchaseFailed, - SupporterFailure.purchasePending => l10n.supporterErrorPurchasePending, - SupporterFailure.restoreFailed => l10n.supporterErrorRestoreFailed, - SupporterFailure.ownershipConflict => l10n.supporterErrorOwnershipConflict, - SupporterFailure.verificationUnavailable => - l10n.supporterErrorVerificationUnavailable, - SupporterFailure.unknown => l10n.supporterErrorUnknown, - }; -} - class _Disclaimer extends StatelessWidget { @override Widget build(BuildContext context) { From 420d81e045557fff941b7c8ad6f07cafc774cfd8 Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:10:51 +0500 Subject: [PATCH 24/32] refactor(profile): share the supporter view's icon badge The owner's membership row and each benefit drew the same filled circle around an accent icon from two copies of the decoration. One small widget now draws it for both, so the two cannot drift apart. --- .../profile_header_supporter_lightbox.dart | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index 7f9e79ef96..46e9c71c2a 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -271,19 +271,7 @@ class _OwnSupporterDetails extends StatelessWidget { child: Row( spacing: 12, children: [ - DecoratedBox( - decoration: BoxDecoration( - color: colors.iconButton, - shape: BoxShape.circle, - ), - child: Padding( - padding: const EdgeInsets.all(14), - child: DivineIcon( - icon: DivineIconName.heart, - color: colors.accentBrand, - ), - ), - ), + const _SupporterIconBadge(DivineIconName.heart), Expanded( child: Text( l10n.supporterActiveBadge, @@ -435,19 +423,7 @@ class _SupporterBenefit extends StatelessWidget { child: Column( spacing: 12, children: [ - DecoratedBox( - decoration: BoxDecoration( - color: colors.iconButton, - shape: BoxShape.circle, - ), - child: Padding( - padding: const EdgeInsets.all(14), - child: DivineIcon( - icon: icon, - color: colors.accentBrand, - ), - ), - ), + _SupporterIconBadge(icon), Text( label, textAlign: TextAlign.center, @@ -460,6 +436,28 @@ class _SupporterBenefit extends StatelessWidget { } } +/// An icon in the filled circle the supporter view's benefits share. +class _SupporterIconBadge extends StatelessWidget { + const _SupporterIconBadge(this.icon); + + final DivineIconName icon; + + @override + Widget build(BuildContext context) { + final colors = context.vineColors; + return DecoratedBox( + decoration: BoxDecoration( + color: colors.iconButton, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: DivineIcon(icon: icon, color: colors.accentBrand), + ), + ); + } +} + /// The call to action, hidden when the viewer already supports Divine. class _SupporterLightboxActions extends ConsumerWidget { const _SupporterLightboxActions(); From a6c0ecd2e865e8c96e8c8bffd4dc83bf8245b2ed Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:14:32 +0500 Subject: [PATCH 25/32] fix(profile): drop the empty gap the hidden supporter setting left The Edit Profile form spaces its fields 16pt apart, and a Column spaces its children even when one of them is empty. The supporter setting is empty for everyone who is not a supporter, so most accounts got 16pt of blank space at the end of the form. The setting now sits outside the spaced fields and adds the same gap above itself only when it shows. --- .../view/profile_setup_view.dart | 52 +++++++++++------- .../supporter_recognition_section.dart | 55 ++++++++++--------- .../supporter_recognition_section_test.dart | 10 ++++ .../screens/profile_setup_screen_test.dart | 25 +++++++++ 4 files changed, 96 insertions(+), 46 deletions(-) diff --git a/mobile/lib/screens/profile_setup/view/profile_setup_view.dart b/mobile/lib/screens/profile_setup/view/profile_setup_view.dart index 5d17a860a6..cd9e17c358 100644 --- a/mobile/lib/screens/profile_setup/view/profile_setup_view.dart +++ b/mobile/lib/screens/profile_setup/view/profile_setup_view.dart @@ -178,30 +178,40 @@ class _ProfileSetupScreenViewState child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, children: [ - DisplayNameField( - controller: _nameController, - focusNode: _nameFocusNode, - ), - UsernameField( - controller: _nip05Controller, - nextFocusNode: _bioFocusNode, - ), - const Nip05Row(), - BioField( - controller: _bioController, - focusNode: _bioFocusNode, - ), - const PublicKeyRow(), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + spacing: 16, + children: [ + DisplayNameField( + controller: _nameController, + focusNode: _nameFocusNode, + ), + UsernameField( + controller: _nip05Controller, + nextFocusNode: _bioFocusNode, + ), + const Nip05Row(), + BioField( + controller: _bioController, + focusNode: _bioFocusNode, + ), + const PublicKeyRow(), - // Below here: still shipped, but absent from - // the current design for this screen. Kept - // in the same style rather than dropped. - WebsiteField( - controller: _websiteController, + // Below here: still shipped, but + // absent from the current design + // for this screen. Kept in the + // same style rather than dropped. + WebsiteField( + controller: _websiteController, + ), + const VerifiedAccountsSection(), + ], ), - const VerifiedAccountsSection(), + // Outside the spaced column: it is + // usually absent, so it brings its + // own gap when it shows. if (!widget.isNewUser) const SupporterRecognitionSection(), ], diff --git a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart index 13eb2daa7a..78d0e6e921 100644 --- a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart +++ b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart @@ -44,32 +44,37 @@ class _SupporterRecognition extends StatelessWidget { }, builder: (context, state) { if (!state.isSupporter) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text( - l10n.supporterTitle, - style: VineTheme.labelMediumFont( - color: context.vineColors.mutedText, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ProfileSwitchRow( - label: l10n.supporterPublicRecognition, - value: state.haloVisible, - onChanged: state.snapshot == null || state.savingRecognition - ? null - : cubit.setPublicRecognition, - ), - ProfileFieldSupportingText( - l10n.supporterPublicRecognitionBody, + // The form's 16pt gap, carried here because the form leaves this + // section out of its spacing: most accounts never see it. + return Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + l10n.supporterTitle, + style: VineTheme.labelMediumFont( + color: context.vineColors.mutedText, ), - ], - ), - ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ProfileSwitchRow( + label: l10n.supporterPublicRecognition, + value: state.haloVisible, + onChanged: state.snapshot == null || state.savingRecognition + ? null + : cubit.setPublicRecognition, + ), + ProfileFieldSupportingText( + l10n.supporterPublicRecognitionBody, + ), + ], + ), + ], + ), ); }, ); diff --git a/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart b/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart index db2f5e5d57..7c5503776f 100644 --- a/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart +++ b/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart @@ -107,6 +107,16 @@ void main() { expect(switchValue(tester), isFalse); }); + testWidgets('keeps the form gap above the setting', (tester) async { + await pumpSection(tester, isSupporter: true); + + expect( + tester.getTopLeft(find.text(l10n.supporterTitle)).dy - + tester.getTopLeft(find.byType(SupporterRecognitionSection)).dy, + 16, + ); + }); + testWidgets('shows nothing to anyone else', (tester) async { await pumpSection(tester, isSupporter: false); diff --git a/mobile/test/screens/profile_setup_screen_test.dart b/mobile/test/screens/profile_setup_screen_test.dart index 784f653c4c..259e570fc1 100644 --- a/mobile/test/screens/profile_setup_screen_test.dart +++ b/mobile/test/screens/profile_setup_screen_test.dart @@ -884,6 +884,31 @@ void main() { }); }); + group('renders', () { + testWidgets('ends the form at the last field when the supporter ' + 'setting is hidden', (tester) async { + await pumpScreen(tester); + await tester.pumpAndSettle(); + expect(find.byType(SupporterRecognitionSection), findsOneWidget); + expect(find.byType(DivineSwitch), findsNothing); + + final form = find.ancestor( + of: find.byType(VerifiedAccountsSection), + matching: find.byWidgetPredicate( + (widget) => + widget is Padding && + widget.padding == const EdgeInsets.fromLTRB(16, 16, 16, 24), + ), + ); + final inset = tester.widget(form).padding as EdgeInsets; + expect( + tester.getRect(form).bottom - + tester.getRect(find.byType(VerifiedAccountsSection)).bottom, + inset.bottom, + ); + }); + }); + group('seeding the form', () { testWidgets('fills from the cached profile before the relay answers', ( tester, From eca651da2f8790b6ffb70bfdf9cbce804edf8162 Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:17:33 +0500 Subject: [PATCH 26/32] perf(profile): fetch supporter status alongside the profile, once The public supporter status lived inside the identity skeleton and was created lazily, so its request only started after the profile had loaded, and the halo always arrived a round trip late. The skeleton also remounts its child when it switches to content, which would have dropped an earlier answer. The status now sits above the skeleton and starts with the header, so both requests run together and the answer survives the switch. --- .../profile_header_supporter_lightbox.dart | 3 ++ .../profile/profile_header_widget.dart | 19 ++++++----- .../profile/profile_header_widget_test.dart | 33 ++++++++++++++++++- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index 46e9c71c2a..aff8fb9b05 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -575,6 +575,9 @@ class _SupporterStatusScope extends ConsumerWidget { if (client == null) return scoped; return BlocProvider( key: ValueKey((client, pubkey)), + // Eager, so the request runs while the profile itself still loads; + // the header reads the result only once that load has finished. + lazy: false, create: (_) { final cubit = PublicSupporterCubit(client: client, pubkey: pubkey); unawaited(cubit.load()); diff --git a/mobile/lib/widgets/profile/profile_header_widget.dart b/mobile/lib/widgets/profile/profile_header_widget.dart index 66f03de953..98d1f78f0c 100644 --- a/mobile/lib/widgets/profile/profile_header_widget.dart +++ b/mobile/lib/widgets/profile/profile_header_widget.dart @@ -408,14 +408,17 @@ class _ProfileHeaderWidgetState extends ConsumerState { // people-list pill, stats row, and action buttons sit as // siblings outside the skeleton so they remain tappable // during the loading window (#4183 review). - Skeletonizer( - enabled: showIdentitySkeleton, - enableSwitchAnimation: true, - effect: vineSkeletonEffectOf(context), - child: _SupporterStatusScope( - pubkey: widget.userIdHex, - isOwnProfile: widget.isOwnProfile, - enabled: !isVanished, + // The supporter status sits above the skeleton, whose switch to + // content remounts its child, so it loads alongside the profile + // and survives that switch. + _SupporterStatusScope( + pubkey: widget.userIdHex, + isOwnProfile: widget.isOwnProfile, + enabled: !isVanished, + child: Skeletonizer( + enabled: showIdentitySkeleton, + enableSwitchAnimation: true, + effect: vineSkeletonEffectOf(context), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index f242f10ee2..03367e4c8b 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -402,6 +402,8 @@ void main() { UserProfile? suppliedProfile, ProfileStats? profileStats, bool profileIsLoading = false, + // Resolves the fetched profile when the test chooses to. + Future? profileLoad, bool isAnonymous = false, bool isAccountEnforced = false, VoidCallback? onEnforcementRead, @@ -564,7 +566,9 @@ void main() { : (ref, pubkey) async => isOgDiviner && pubkey == userIdHex, ), fetchUserProfileProvider(userIdHex).overrideWith( - profileIsLoading + profileLoad != null + ? (ref) => profileLoad + : profileIsLoading ? (ref) => Completer().future : (ref) async => profile, ), @@ -937,6 +941,33 @@ void main() { expect(tilt(leftWing.first), closeTo(-tilt(rightWing.first), 1e-9)); }); + testWidgets("asks for someone else's public status once, while " + 'their profile is still loading', (tester) async { + final client = supporterClient(isPublicSupporter: true); + final profileLoad = Completer(); + await tester.pumpWidget( + buildTestWidget( + userIdHex: testUserHex, + isOwnProfile: false, + profileLoad: profileLoad.future, + supporterClient: client, + disableAnimations: true, + ), + ); + await tester.pump(); + + expect(find.byType(SupporterHaloWings), findsNothing); + verify(() => client.fetchPublicRecognition(testUserHex)).called(1); + + profileLoad.complete( + createTestProfile(displayName: 'Ivy', picture: supporterPicture), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SupporterHaloWings), findsOneWidget); + verifyNever(() => client.fetchPublicRecognition(any())); + }); + testWidgets("shows no halo on someone else's profile without public " 'supporter status', (tester) async { await pumpHeader(tester, isOwnProfile: false); From e8191018fe83e0625576c522910b1f4c4a76d29d Mon Sep 17 00:00:00 2001 From: Meylis Date: Thu, 22 Oct 2026 18:22:28 +0500 Subject: [PATCH 27/32] fix(supporter): keep the profile header when a remote signer gets ready The membership scope keyed its cubit on whether it could refresh, so the cubit was replaced when a Keycast signer became usable after sign-in. On your own profile the scope now wraps the avatar and bio, so that swap remounted the whole identity block on cold start, collapsing an expanded bio among other state. The cubit is now keyed on the account's repository alone, and the refresh it waited for runs from a listener on the signer becoming ready. --- mobile/lib/widgets/supporter_membership.dart | 38 +++++--- .../widgets/supporter_membership_test.dart | 96 +++++++++++++++++++ 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/mobile/lib/widgets/supporter_membership.dart b/mobile/lib/widgets/supporter_membership.dart index fcd0b17c49..30944a0f70 100644 --- a/mobile/lib/widgets/supporter_membership.dart +++ b/mobile/lib/widgets/supporter_membership.dart @@ -64,21 +64,24 @@ class SupporterMembershipScope extends ConsumerWidget { if (!auth.isAuthenticated) return child; ref.watch(currentAuthRpcCapabilityProvider); final repository = ref.watch(supporterRepositoryProvider); - // External signers may prompt. Passive entry must not interrupt browsing; - // opening Supporter settings explicitly still requests fresh private state. final canRefresh = auth.canPublishNostrWritesNow && - switch (auth.authenticationSource) { - AuthenticationSource.divineOAuth || - AuthenticationSource.importedKeys || - AuthenticationSource.automatic => true, - _ => false, - }; - ref.listen(appForegroundProvider, (_, foreground) { - if (foreground && canRefresh) unawaited(repository.refreshIfStale()); - }); + _signsWithoutPrompting(auth.authenticationSource); + ref + ..listen(appForegroundProvider, (_, foreground) { + if (foreground && canRefresh) unawaited(repository.refreshIfStale()); + }) + // A remote signer becomes usable after sign-in. Refresh then, rather + // than re-keying the cubit and remounting everything it wraps. + ..listen(currentAuthRpcCapabilityProvider, (_, _) { + final current = ref.read(authServiceProvider); + if (current.canPublishNostrWritesNow && + _signsWithoutPrompting(current.authenticationSource)) { + unawaited(repository.refreshIfStale()); + } + }); return BlocProvider( - key: ValueKey((repository, canRefresh)), + key: ValueKey(repository), create: (_) { final cubit = SupporterCubit(repository: repository) ..start(loadStore: false); @@ -90,6 +93,17 @@ class SupporterMembershipScope extends ConsumerWidget { } } +/// Whether [source] signs without a prompt, so the membership may refresh +/// without being asked. External signers may prompt, and passive entry must +/// not interrupt browsing; opening Supporter settings explicitly still +/// requests fresh private state. +bool _signsWithoutPrompting(AuthenticationSource source) => switch (source) { + AuthenticationSource.divineOAuth || + AuthenticationSource.importedKeys || + AuthenticationSource.automatic => true, + _ => false, +}; + class _MembershipStatus extends StatelessWidget { const _MembershipStatus({required this.compact}); diff --git a/mobile/test/widgets/supporter_membership_test.dart b/mobile/test/widgets/supporter_membership_test.dart index c926b5d5f8..305ba1b38e 100644 --- a/mobile/test/widgets/supporter_membership_test.dart +++ b/mobile/test/widgets/supporter_membership_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:divine_ui/divine_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -12,6 +13,7 @@ import 'package:http/testing.dart'; import 'package:iap_repository/iap_repository.dart'; import 'package:material_ui/material_ui.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:openvine/blocs/supporter/supporter_cubit.dart'; import 'package:openvine/l10n/l10n.dart'; import 'package:openvine/models/auth_rpc_capability.dart'; import 'package:openvine/providers/auth_providers.dart'; @@ -26,6 +28,26 @@ import '../helpers/l10n.dart'; class _MockAuth extends Mock implements AuthService {} +/// Stands in for whatever the membership scope wraps, reading its cubit as +/// the profile does, so a test can tell whether that subtree was kept or +/// rebuilt from scratch. +class _ScopedContent extends StatefulWidget { + const _ScopedContent(); + + @override + State<_ScopedContent> createState() => _ScopedContentState(); +} + +class _ScopedContentState extends State<_ScopedContent> { + @override + Widget build(BuildContext context) { + context.select( + (cubit) => cubit?.state.isSupporter ?? false, + ); + return const SizedBox.shrink(); + } +} + void main() { const pubkey = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -301,5 +323,79 @@ void main() { }, ); } + + testWidgets('refreshes when a remote signer becomes ready without ' + 'rebuilding what it wraps', (tester) async { + SharedPreferences.setMockInitialValues({}); + var signerReady = false; + final capability = StreamController(); + addTearDown(capability.close); + final auth = _MockAuth(); + when(() => auth.isAuthenticated).thenReturn(true); + when( + () => auth.authenticationSource, + ).thenReturn(AuthenticationSource.divineOAuth); + when(() => auth.canPublishNostrWritesNow).thenAnswer((_) => signerReady); + when(() => auth.authRpcCapability).thenAnswer( + (_) => signerReady + ? AuthRpcCapability.rpcReady + : AuthRpcCapability.unavailable, + ); + when( + () => auth.authRpcCapabilityStream, + ).thenAnswer((_) => capability.stream); + var requests = 0; + final client = SupporterApiClient( + baseUri: Uri.parse('https://supporters.test'), + authHeaderProvider: ({required url, required method, payload}) async => + (authorizationHeader: 'Nostr fixture', pubkey: pubkey), + httpClient: MockClient((_) async { + requests++; + return http.Response( + jsonEncode({ + 'status': 'active', + 'entitlement': {'source': 'server', 'isActive': true}, + 'recognition': {'haloVisible': true}, + }), + 200, + ); + }), + ); + addTearDown(client.dispose); + final repo = SupporterRepository( + pubkey: pubkey, + validator: StubEntitlementValidator(), + prefs: await SharedPreferences.getInstance(), + apiClient: client, + ); + addTearDown(repo.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [ + authServiceProvider.overrideWithValue(auth), + currentAuthStateProvider.overrideWithValue( + AuthState.authenticated, + ), + supporterRepositoryProvider.overrideWithValue(repo), + ], + child: buildLocalizedWidget( + const Scaffold( + body: SupporterMembershipScope(child: _ScopedContent()), + ), + ), + ), + ); + await tester.pump(); + final content = tester.state(find.byType(_ScopedContent)); + expect(requests, isZero); + + signerReady = true; + capability.add(AuthRpcCapability.rpcReady); + await tester.pumpAndSettle(); + + expect(requests, 1); + expect(tester.state(find.byType(_ScopedContent)), same(content)); + await tester.pumpWidget(const SizedBox.shrink()); + }); }); } From 04e8dc558ecd641730049ce562aeb9daac983c28 Mon Sep 17 00:00:00 2001 From: Daniel Cadenas Date: Thu, 22 Oct 2026 11:30:28 -0300 Subject: [PATCH 28/32] fix(profile): trust public support after cached membership expiry --- .../profile/profile_header_identity.dart | 4 +- .../profile_header_supporter_lightbox.dart | 5 +- .../profile/profile_header_widget_test.dart | 66 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_identity.dart b/mobile/lib/widgets/profile/profile_header_identity.dart index 77abcafc2e..0f504465e6 100644 --- a/mobile/lib/widgets/profile/profile_header_identity.dart +++ b/mobile/lib/widgets/profile/profile_header_identity.dart @@ -66,7 +66,9 @@ class _ProfileNameAndBio extends StatelessWidget { ), // Supporter status shows as the halo and wings around the avatar; // this entry invites the owner to join and hides once they have. - if (!isVanished && isOwnProfile) + if (!isVanished && + isOwnProfile && + !_showsSupporterTreatment(context, isOwnProfile: true)) const SupporterMembership(compact: true), Skeleton.keep(child: _ProfileBadgesBlock(userIdHex: userIdHex)), Padding( diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index aff8fb9b05..a7847f4830 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -606,11 +606,14 @@ bool _showsSupporterTreatment( } /// The owner's published supporter status, or null until their account has -/// been read. +/// been read or when only the device clock has expired a cached membership. bool? _ownPublicRecognition(BuildContext context) { return context.select((cubit) { final state = cubit?.state; if (state == null || state.snapshot == null) return null; + // A cached renewal date is not evidence that the server ended support. + // Use the public answer until an inactive result is server-confirmed. + if (!state.isSupporter && !state.isConfirmedInactive) return null; return state.isSupporter && state.haloVisible; }); } diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 03367e4c8b..3e71e0c97e 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -839,6 +839,40 @@ void main() { return repository; } + Future expiredCachedMembership( + SupporterServerStatus status, + ) async { + final prefs = await SharedPreferences.getInstance(); + const key = 'divine_supporter_entitlement:$testUserHex'; + final previous = prefs.getString(key); + addTearDown(() async { + if (previous == null) { + await prefs.remove(key); + } else { + await prefs.setString(key, previous); + } + }); + await prefs.setString( + key, + jsonEncode({ + ...SupporterEntitlement( + productId: 'divine.supporter.monthly', + source: EntitlementSource.server, + expirationDate: DateTime.utc(2000), + ).toJson(), + 'status': status.name, + 'recognition': {'haloVisible': true}, + }), + ); + final repository = SupporterRepository( + pubkey: testUserHex, + validator: StubEntitlementValidator(), + prefs: prefs, + ); + addTearDown(repository.dispose); + return repository; + } + Future pumpHeader( WidgetTester tester, { required bool isOwnProfile, @@ -880,6 +914,38 @@ void main() { } group('renders', () { + for (final confirmedExpired in [false, true]) { + testWidgets( + '${confirmedExpired ? 'keeps server-confirmed expiry' : 'uses public recognition after local cache expiry'} ' + 'on your own profile', + (tester) async { + final status = confirmedExpired + ? SupporterServerStatus.expired + : SupporterServerStatus.active; + final repository = await expiredCachedMembership(status); + expect(repository.isSupporter, isFalse); + expect(repository.snapshot?.status, status); + expect(repository.snapshot?.haloVisible, isTrue); + + await pumpHeader( + tester, + isOwnProfile: true, + isPublicSupporter: true, + ownMembership: repository, + ); + + expect( + find.byType(SupporterHaloWings), + confirmedExpired ? findsNothing : findsOneWidget, + ); + expect( + find.byType(ActionChip), + confirmedExpired ? findsOneWidget : findsNothing, + ); + }, + ); + } + testWidgets('labels the supporter avatar with the standalone title', ( tester, ) async { From d73c6dddc58068a1c089a2380dd84e7bf45cc403 Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 22 Oct 2026 16:50:15 +0200 Subject: [PATCH 29/32] Revert "fix(profile): skip the membership read for signed-out viewers" This reverts commit 3aeeefc3f2. A signed-out viewer never reaches a profile. The router sends every unauthenticated location outside the auth, public-support and recorder routes to /welcome (app_router_redirect.dart), so the supporter view's call to action never builds for a guest. The guard and its test covered a path that cannot run. --- .../profile_header_supporter_lightbox.dart | 6 --- .../profile/profile_header_widget_test.dart | 37 +------------------ 2 files changed, 2 insertions(+), 41 deletions(-) diff --git a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart index a7847f4830..618a6f6258 100644 --- a/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart +++ b/mobile/lib/widgets/profile/profile_header_supporter_lightbox.dart @@ -467,12 +467,6 @@ class _SupporterLightboxActions extends ConsumerWidget { if (!ref.watch(supporterApiConfiguredProvider)) { return const _SupporterLightboxButtons(); } - // A guest has no membership to read, and reading one would start the - // store connection behind it. - ref.watch(currentAuthStateProvider); - if (!ref.watch(authServiceProvider).isAuthenticated) { - return const _SupporterLightboxButtons(); - } final repository = ref.watch(supporterRepositoryProvider); return BlocProvider( key: ValueKey(repository), diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 3e71e0c97e..9c0d672043 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -214,14 +214,12 @@ class _FakeCacheDao implements CacheDao { class MockAuthService extends Mock implements AuthService { MockAuthService({ this.isAnonymousValue = false, - this.isAuthenticatedValue = true, bool hasExpiredOAuthSessionValue = false, this.isRpcUpgradeInProgressValue = false, this.tryRefreshResult = false, }) : expiredOAuthSession = ValueNotifier(hasExpiredOAuthSessionValue); final bool isAnonymousValue; - final bool isAuthenticatedValue; /// Set its value to flip the flag while the header is mounted, as a refused /// mid-session refresh does (#9943). @@ -237,7 +235,7 @@ class MockAuthService extends Mock implements AuthService { bool get isAnonymous => isAnonymousValue; @override - bool get isAuthenticated => isAuthenticatedValue; + bool get isAuthenticated => true; @override String? get currentPublicKeyHex => testUserHex; @@ -438,7 +436,6 @@ void main() { Nip05VerificationStatus.verified, SupporterApiClient? supporterClient, SupporterRepository? supporterRepository, - VoidCallback? onSupporterRepositoryRead, }) { // Pass authService when the test needs the same instance across pumps — // e.g. to read tryRefreshCallCount after the header has been unmounted. @@ -551,10 +548,7 @@ void main() { supporterApiClient: supporterClient, ), if (supporterRepository != null) - supporterRepositoryProvider.overrideWith((ref) { - onSupporterRepositoryRead?.call(); - return supporterRepository; - }), + supporterRepositoryProvider.overrideWithValue(supporterRepository), profileVanishedProvider(userIdHex).overrideWith((ref) => isVanished), if ((profile ?? suppliedProfile)?.nip05 case final String claim) nip05ClaimVerificationProvider( @@ -1334,33 +1328,6 @@ void main() { expect(find.text(l10n.supporterJoinLabel), findsOneWidget); }); - testWidgets('invites a signed-out viewer without reading a ' - 'membership', (tester) async { - var membershipReads = 0; - final repository = await membership(tester, isSupporter: false); - await tester.pumpWidget( - buildTestWidget( - userIdHex: testUserHex, - isOwnProfile: false, - suppliedProfile: createTestProfile( - displayName: 'Ivy', - picture: supporterPicture, - ), - supporterClient: supporterClient(isPublicSupporter: true), - supporterRepository: repository, - onSupporterRepositoryRead: () => membershipReads++, - authService: MockAuthService(isAuthenticatedValue: false), - disableAnimations: true, - ), - ); - await tester.pumpAndSettle(); - - await openSupporterView(tester); - - expect(find.text(l10n.supporterJoinLabel), findsOneWidget); - expect(membershipReads, isZero); - }); - testWidgets('does not invite a viewer who already supports Divine', ( tester, ) async { From e03393eb39dfff078319b31ae9dee119e8c3194d Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 22 Oct 2026 16:50:16 +0200 Subject: [PATCH 30/32] Revert "fix(profile): drop the empty gap the hidden supporter setting left" This reverts commit a6c0ecd2e8. The gap is 16pt of extra space below the last field of a scrolled form that already ends in 24pt of padding. Closing it nested the form's main Column and made the supporter section carry the form's spacing, with two tests that pin padding values. That coupling costs more than the space. --- .../view/profile_setup_view.dart | 52 +++++++----------- .../supporter_recognition_section.dart | 55 +++++++++---------- .../supporter_recognition_section_test.dart | 10 ---- .../screens/profile_setup_screen_test.dart | 25 --------- 4 files changed, 46 insertions(+), 96 deletions(-) diff --git a/mobile/lib/screens/profile_setup/view/profile_setup_view.dart b/mobile/lib/screens/profile_setup/view/profile_setup_view.dart index cd9e17c358..5d17a860a6 100644 --- a/mobile/lib/screens/profile_setup/view/profile_setup_view.dart +++ b/mobile/lib/screens/profile_setup/view/profile_setup_view.dart @@ -178,40 +178,30 @@ class _ProfileSetupScreenViewState child: Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, children: [ - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - spacing: 16, - children: [ - DisplayNameField( - controller: _nameController, - focusNode: _nameFocusNode, - ), - UsernameField( - controller: _nip05Controller, - nextFocusNode: _bioFocusNode, - ), - const Nip05Row(), - BioField( - controller: _bioController, - focusNode: _bioFocusNode, - ), - const PublicKeyRow(), + DisplayNameField( + controller: _nameController, + focusNode: _nameFocusNode, + ), + UsernameField( + controller: _nip05Controller, + nextFocusNode: _bioFocusNode, + ), + const Nip05Row(), + BioField( + controller: _bioController, + focusNode: _bioFocusNode, + ), + const PublicKeyRow(), - // Below here: still shipped, but - // absent from the current design - // for this screen. Kept in the - // same style rather than dropped. - WebsiteField( - controller: _websiteController, - ), - const VerifiedAccountsSection(), - ], + // Below here: still shipped, but absent from + // the current design for this screen. Kept + // in the same style rather than dropped. + WebsiteField( + controller: _websiteController, ), - // Outside the spaced column: it is - // usually absent, so it brings its - // own gap when it shows. + const VerifiedAccountsSection(), if (!widget.isNewUser) const SupporterRecognitionSection(), ], diff --git a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart index 78d0e6e921..13eb2daa7a 100644 --- a/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart +++ b/mobile/lib/screens/profile_setup/widgets/supporter_recognition_section.dart @@ -44,37 +44,32 @@ class _SupporterRecognition extends StatelessWidget { }, builder: (context, state) { if (!state.isSupporter) return const SizedBox.shrink(); - // The form's 16pt gap, carried here because the form leaves this - // section out of its spacing: most accounts never see it. - return Padding( - padding: const EdgeInsets.only(top: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text( - l10n.supporterTitle, - style: VineTheme.labelMediumFont( - color: context.vineColors.mutedText, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ProfileSwitchRow( - label: l10n.supporterPublicRecognition, - value: state.haloVisible, - onChanged: state.snapshot == null || state.savingRecognition - ? null - : cubit.setPublicRecognition, - ), - ProfileFieldSupportingText( - l10n.supporterPublicRecognitionBody, - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + l10n.supporterTitle, + style: VineTheme.labelMediumFont( + color: context.vineColors.mutedText, ), - ], - ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ProfileSwitchRow( + label: l10n.supporterPublicRecognition, + value: state.haloVisible, + onChanged: state.snapshot == null || state.savingRecognition + ? null + : cubit.setPublicRecognition, + ), + ProfileFieldSupportingText( + l10n.supporterPublicRecognitionBody, + ), + ], + ), + ], ); }, ); diff --git a/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart b/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart index 7c5503776f..db2f5e5d57 100644 --- a/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart +++ b/mobile/test/screens/profile_setup/widgets/supporter_recognition_section_test.dart @@ -107,16 +107,6 @@ void main() { expect(switchValue(tester), isFalse); }); - testWidgets('keeps the form gap above the setting', (tester) async { - await pumpSection(tester, isSupporter: true); - - expect( - tester.getTopLeft(find.text(l10n.supporterTitle)).dy - - tester.getTopLeft(find.byType(SupporterRecognitionSection)).dy, - 16, - ); - }); - testWidgets('shows nothing to anyone else', (tester) async { await pumpSection(tester, isSupporter: false); diff --git a/mobile/test/screens/profile_setup_screen_test.dart b/mobile/test/screens/profile_setup_screen_test.dart index 259e570fc1..784f653c4c 100644 --- a/mobile/test/screens/profile_setup_screen_test.dart +++ b/mobile/test/screens/profile_setup_screen_test.dart @@ -884,31 +884,6 @@ void main() { }); }); - group('renders', () { - testWidgets('ends the form at the last field when the supporter ' - 'setting is hidden', (tester) async { - await pumpScreen(tester); - await tester.pumpAndSettle(); - expect(find.byType(SupporterRecognitionSection), findsOneWidget); - expect(find.byType(DivineSwitch), findsNothing); - - final form = find.ancestor( - of: find.byType(VerifiedAccountsSection), - matching: find.byWidgetPredicate( - (widget) => - widget is Padding && - widget.padding == const EdgeInsets.fromLTRB(16, 16, 16, 24), - ), - ); - final inset = tester.widget(form).padding as EdgeInsets; - expect( - tester.getRect(form).bottom - - tester.getRect(find.byType(VerifiedAccountsSection)).bottom, - inset.bottom, - ); - }); - }); - group('seeding the form', () { testWidgets('fills from the cached profile before the relay answers', ( tester, From 606bab467afa76e9e3050304dad126d693246337 Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 22 Oct 2026 16:50:51 +0200 Subject: [PATCH 31/32] test(l10n): drop the test that pins one Telugu string The test fixed supporterTitle in app_te.arb to its current wording, so any correction from the fluent-speaker pass this PR still needs would fail it. The ARB fix from 28818df stays. --- mobile/test/l10n/arb_consistency_test.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mobile/test/l10n/arb_consistency_test.dart b/mobile/test/l10n/arb_consistency_test.dart index 3a11ead1f9..2ac1b78531 100644 --- a/mobile/test/l10n/arb_consistency_test.dart +++ b/mobile/test/l10n/arb_consistency_test.dart @@ -8,12 +8,6 @@ import 'package:test/test.dart'; void main() { group('ARB consistency', () { - test('Telugu supporter title does not include a subtitle fragment', () { - final telugu = _readArb(File('lib/l10n/app_te.arb')); - - expect(telugu['supporterTitle'], 'Divine మద్దతుదారులు'); - }); - test('conversation dismissal and removal have distinct action labels', () { final arbFiles = Directory('lib/l10n').listSync().whereType().where( (file) => file.path.endsWith('.arb'), From c4359ecc5cc7f5a08c7e4adace326628bdf1c963 Mon Sep 17 00:00:00 2001 From: Daniel Cadenas Date: Fri, 23 Oct 2026 13:06:30 -0300 Subject: [PATCH 32/32] test(profile): cover vanished profiles with active supporter status --- .../profile/profile_header_widget_test.dart | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/mobile/test/widgets/profile/profile_header_widget_test.dart b/mobile/test/widgets/profile/profile_header_widget_test.dart index 9c0d672043..117566522c 100644 --- a/mobile/test/widgets/profile/profile_header_widget_test.dart +++ b/mobile/test/widgets/profile/profile_header_widget_test.dart @@ -755,7 +755,6 @@ void main() { await tester.pump(); expect(find.byType(SupporterMembership), findsNothing); - expect(find.byType(SupporterHaloWings), findsNothing); }, ); } @@ -870,6 +869,7 @@ void main() { Future pumpHeader( WidgetTester tester, { required bool isOwnProfile, + bool isVanished = false, bool isPublicSupporter = false, SupporterRepository? ownMembership, Locale locale = const Locale('en'), @@ -880,6 +880,7 @@ void main() { buildTestWidget( userIdHex: testUserHex, isOwnProfile: isOwnProfile, + isVanished: isVanished, suppliedProfile: createTestProfile( displayName: 'Ivy', picture: supporterPicture, @@ -908,6 +909,51 @@ void main() { } group('renders', () { + for (final ownProfile in [false, true]) { + testWidgets( + 'keeps supporter treatment off ' + '${ownProfile ? 'your own' : "someone else's"} vanished profile', + (tester) async { + final ownMembership = ownProfile + ? await membership( + tester, + isSupporter: true, + haloVisible: true, + ) + : null; + await pumpHeader( + tester, + isOwnProfile: ownProfile, + isPublicSupporter: true, + ownMembership: ownMembership, + ); + expect(find.byType(SupporterHaloWings), findsOneWidget); + + // Override values belong to the provider container. Compare a + // fresh vanished-profile fixture, not the first cached value. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await pumpHeader( + tester, + isOwnProfile: ownProfile, + isVanished: true, + isPublicSupporter: true, + ownMembership: ownMembership, + ); + + expect( + ProviderScope.containerOf( + tester.element(find.byType(ProfileHeaderWidget)), + listen: false, + ).read(profileVanishedProvider(testUserHex)), + isTrue, + ); + expect(find.byType(SupporterHaloWings), findsNothing); + expect(find.byType(SupporterMembership), findsNothing); + }, + ); + } + for (final confirmedExpired in [false, true]) { testWidgets( '${confirmedExpired ? 'keeps server-confirmed expiry' : 'uses public recognition after local cache expiry'} '