diff --git a/lib/screens/home/profile_screen.dart b/lib/screens/home/profile_screen.dart index 7b3e4b9..d6f6b70 100644 --- a/lib/screens/home/profile_screen.dart +++ b/lib/screens/home/profile_screen.dart @@ -257,6 +257,11 @@ class _ProfileScreenState extends State { ); } + // Header height derived from the toolbar, banner overhang, and text + // scale so the banner/avatar/DID land identically on all screens. + // SliverAppBar adds the status-bar inset to this itself. + final expandedHeight = ProfileHeader.expandedHeightFor(context); + return Scaffold( backgroundColor: AppColors.background, body: RefreshIndicator( @@ -280,7 +285,7 @@ class _ProfileScreenState extends State { SliverAppBar( backgroundColor: Colors.transparent, foregroundColor: AppColors.textPrimary, - expandedHeight: 220, + expandedHeight: expandedHeight, pinned: true, stretch: true, leading: @@ -316,14 +321,17 @@ class _ProfileScreenState extends State { : null, flexibleSpace: LayoutBuilder( builder: (context, constraints) { - // Calculate collapse progress (0 = expanded, 1 = collapsed) - const expandedHeight = 220.0; - final collapsedHeight = kToolbarHeight + - MediaQuery.of(context).padding.top; + // Calculate collapse progress (0 = expanded, 1 = collapsed). + // The upper bound is the sliver's real max extent, which + // includes the status-bar inset SliverAppBar adds on top + // of expandedHeight. + final collapsedHeight = + ProfileHeader.collapsedExtentFor(context); + final maxExtent = ProfileHeader.maxExtentFor(context); final currentHeight = constraints.maxHeight; final collapseProgress = 1 - ((currentHeight - collapsedHeight) / - (expandedHeight - collapsedHeight)) + (maxExtent - collapsedHeight)) .clamp(0.0, 1.0); return Stack( @@ -364,6 +372,11 @@ class _ProfileScreenState extends State { }, ), ), + // Bio, stats, and join date as normal scroll content so they + // are never clipped by the collapsing header + SliverToBoxAdapter( + child: ProfileDetails(profile: profileProvider.profile), + ), // Tab bar header SliverPersistentHeader( pinned: true, diff --git a/lib/widgets/profile_header.dart b/lib/widgets/profile_header.dart index 6e95b74..839e163 100644 --- a/lib/widgets/profile_header.dart +++ b/lib/widgets/profile_header.dart @@ -1,17 +1,22 @@ +import 'dart:math' as math; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../constants/app_colors.dart'; import '../models/user_profile.dart'; import '../utils/date_time_utils.dart'; -/// Profile header widget displaying banner, avatar, and user info +/// Collapsing profile header displaying the banner with the avatar and +/// identity row (handle + DID) anchored to the banner's bottom edge. /// -/// Layout matches Bluesky profile design: -/// - Full-width banner image (~150px height) -/// - Circular avatar (80px) overlapping banner at bottom-left -/// - Display name, handle, and bio below -/// - Stats row showing post/comment/community counts +/// All geometry is deterministic: the banner's bottom edge always sits +/// [_bannerOverhang] below the collapsed app bar, and the avatar and +/// identity block are positioned from that edge, so they land in the same +/// place relative to the banner on every screen size and text scale. Bio, +/// stats, and join date live in [ProfileDetails], rendered as normal +/// scroll content below the app bar. class ProfileHeader extends StatelessWidget { const ProfileHeader({ required this.profile, @@ -20,122 +25,126 @@ class ProfileHeader extends StatelessWidget { final UserProfile? profile; - static const double bannerHeight = 150; + static const double avatarSize = 80; + + /// How far the banner extends below the toolbar when fully expanded. + /// + /// Must be >= [avatarSize] / 2 so the avatar — which straddles the + /// banner's bottom edge — never rises above the collapsed app bar and + /// shows through the frosted overlay when the header is collapsed. + static const double _bannerOverhang = avatarSize / 2; + static const double _horizontalPadding = 16; + static const double _bottomPadding = 12; + static const double _identityTopGap = 6; + + /// Sliver extent when fully collapsed — mirrors `SliverAppBar.minExtent` + /// for a primary app bar with no bottom. + static double collapsedExtentFor(BuildContext context) => + MediaQuery.paddingOf(context).top + kToolbarHeight; + + /// Y position of the banner's bottom edge when fully expanded. + static double bannerBottomFor(BuildContext context) => + collapsedExtentFor(context) + _bannerOverhang; + + /// Height of the identity block that hangs below the banner edge: + /// the avatar's lower half, or the handle + DID column if taller + /// (e.g. with large accessibility text). + static double _infoHeightFor(BuildContext context) { + final scaler = MediaQuery.textScalerOf(context); + // Handle line (fontSize 20) + gap + DID line (fontSize 12 + icon). + final identityBlock = + _identityTopGap + scaler.scale(26) + 4 + scaler.scale(18); + return math.max(avatarSize / 2, identityBlock) + _bottomPadding; + } + + /// Value to pass to [SliverAppBar.expandedHeight]. + /// + /// Deliberately excludes the status-bar inset: a primary [SliverAppBar] + /// computes `maxExtent = padding.top + expandedHeight`, so including the + /// inset here would count it twice and push the banner (and with it the + /// avatar and DID) further down on devices with taller insets — exactly + /// the per-device drift this header exists to eliminate. + static double expandedHeightFor(BuildContext context) => + kToolbarHeight + _bannerOverhang + _infoHeightFor(context); + + /// Total sliver extent when fully expanded, including the inset that + /// [SliverAppBar] adds on top of [expandedHeightFor]. Use this — not + /// [expandedHeightFor] — as the upper bound when mapping scroll offset + /// to collapse progress. + static double maxExtentFor(BuildContext context) => + MediaQuery.paddingOf(context).top + expandedHeightFor(context); @override Widget build(BuildContext context) { - final isIOS = Theme.of(context).platform == TargetPlatform.iOS; + final minBannerBottom = bannerBottomFor(context); + final scrimHeight = + MediaQuery.paddingOf(context).top + kToolbarHeight; + final infoHeight = _infoHeightFor(context); - // Stack-based layout with banner image behind profile content - return Stack( - children: [ - // Banner image (or gradient fallback) - _buildBannerImage(), - // Gradient overlay for text readability - // iOS needs more aggressive gradient due to larger SafeArea (Dynamic Island/notch) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - AppColors.background.withValues(alpha: isIOS ? 0.6 : 0.3), - AppColors.background, - ], - stops: isIOS - ? const [0.0, 0.25, 0.55] - : const [0.0, 0.5, 1.0], - ), + return LayoutBuilder( + builder: (context, constraints) { + // Let the banner absorb overscroll stretch, but never shrink + // below the toolbar area while the app bar collapses. + final bannerBottom = math.max( + minBannerBottom, + constraints.maxHeight - infoHeight, + ); + + return Stack( + children: [ + // Banner image (or gradient fallback) — always the bottom layer + Positioned( + top: 0, + left: 0, + right: 0, + height: bannerBottom, + child: _buildBannerImage(), ), - ), - ), - // Profile content - UnconstrainedBox allows content to be natural size - // and clips overflow when SliverAppBar collapses - SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.only(top: kToolbarHeight), - child: UnconstrainedBox( - clipBehavior: Clip.hardEdge, - alignment: Alignment.topLeft, - constrainedAxis: Axis.horizontal, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Avatar and name row (side by side) - _buildAvatarAndNameRow(), - // Bio - if (profile?.bio != null && profile!.bio!.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - profile!.bio!, - style: const TextStyle( - fontSize: 14, - color: AppColors.textPrimary, - height: 1.4, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + // Scrim so app bar icons stay legible over any banner + Positioned( + top: 0, + left: 0, + right: 0, + height: scrimHeight, + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black45, Colors.transparent], ), - ], - // Stats row - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _buildStatsRow(), ), - // Member since date - if (profile?.createdAt != null) ...[ - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - const Icon( - Icons.calendar_today_outlined, - size: 14, - color: AppColors.textSecondary, - ), - const SizedBox(width: 6), - Text( - DateTimeUtils.formatJoinedDate(profile!.createdAt!), - style: const TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ], - ], ), ), - ), - ), - ], + // Avatar straddling the banner's bottom edge, above the banner + Positioned( + top: bannerBottom - avatarSize / 2, + left: _horizontalPadding, + child: _buildAvatarCircle(), + ), + // Handle and DID beside the avatar, below the banner + Positioned( + top: bannerBottom + _identityTopGap, + left: _horizontalPadding + avatarSize + 12, + right: _horizontalPadding, + child: _buildIdentityColumn(context), + ), + ], + ); + }, ); } Widget _buildBannerImage() { if (profile?.banner != null && profile!.banner!.isNotEmpty) { - return SizedBox( - height: bannerHeight, - width: double.infinity, - child: CachedNetworkImage( - imageUrl: profile!.banner!, - fit: BoxFit.cover, - // Disable fade animation to prevent scroll jitter - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => _buildDefaultBanner(), - errorWidget: (context, url, error) => _buildDefaultBanner(), - ), + return CachedNetworkImage( + imageUrl: profile!.banner!, + fit: BoxFit.cover, + // Disable fade animation to prevent scroll jitter + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (context, url) => _buildDefaultBanner(), + errorWidget: (context, url, error) => _buildDefaultBanner(), ); } return _buildDefaultBanner(); @@ -145,8 +154,6 @@ class ProfileHeader extends StatelessWidget { // TODO: Replace with Image.asset('assets/images/default_banner.png') // when the user provides the default banner asset return Container( - height: bannerHeight, - width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, @@ -160,86 +167,84 @@ class ProfileHeader extends StatelessWidget { ); } - Widget _buildAvatarAndNameRow() { - const avatarSize = 80.0; + Widget _buildAvatarCircle() { + return Container( + width: avatarSize, + height: avatarSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppColors.background, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + spreadRadius: 1, + ), + ], + ), + child: ClipOval( + child: _buildAvatar(avatarSize - 6), + ), + ); + } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Avatar with drop shadow - Container( - width: avatarSize, - height: avatarSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: AppColors.background, - width: 3, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 8, - offset: const Offset(0, 2), - spreadRadius: 1, - ), - ], - ), - child: ClipOval( - child: _buildAvatar(avatarSize - 6), - ), + Widget _buildIdentityColumn(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile?.handle != null ? '@${profile!.handle}' : 'Loading...', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, ), - const SizedBox(width: 12), - // Handle and DID column - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (profile?.did != null) ...[ + const SizedBox(height: 4), + GestureDetector( + onTap: () => _copyDid(context), + child: Row( children: [ - const SizedBox(height: 8), - // Handle - Text( - profile?.handle != null - ? '@${profile!.handle}' - : 'Loading...', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + const Icon( + Icons.qr_code_2, + size: 14, + color: AppColors.textSecondary, ), - // DID with icon - if (profile?.did != null) ...[ - const SizedBox(height: 4), - Row( - children: [ - const Icon( - Icons.qr_code_2, - size: 14, - color: AppColors.textSecondary, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - profile!.did, - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondary, - fontFamily: 'monospace', - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], + const SizedBox(width: 4), + Expanded( + child: Text( + profile!.did, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontFamily: 'monospace', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], + ), ], ), ), ], + ], + ); + } + + void _copyDid(BuildContext context) { + Clipboard.setData(ClipboardData(text: profile!.did)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('DID copied to clipboard'), + duration: Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, ), ); } @@ -254,7 +259,8 @@ class ProfileHeader extends StatelessWidget { // Disable fade animation to prevent scroll jitter fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, - // Static placeholder instead of animated spinner to prevent scroll jitter + // Static placeholder instead of animated spinner to prevent + // scroll jitter placeholder: (context, url) => _buildAvatarLoading(size), errorWidget: (context, url, error) => _buildFallbackAvatar(size), ); @@ -279,6 +285,64 @@ class ProfileHeader extends StatelessWidget { child: Icon(Icons.person, size: size * 0.5, color: Colors.white), ); } +} + +/// Bio, stats, and join date shown as normal scroll content below the +/// collapsing [ProfileHeader], so they are never clipped regardless of +/// bio length or device inset. +class ProfileDetails extends StatelessWidget { + const ProfileDetails({ + required this.profile, + super.key, + }); + + final UserProfile? profile; + + @override + Widget build(BuildContext context) { + final bio = profile?.bio; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (bio != null && bio.isNotEmpty) ...[ + Text( + bio, + style: const TextStyle( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + const SizedBox(height: 12), + ], + _buildStatsRow(), + if (profile?.createdAt != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.calendar_today_outlined, + size: 14, + color: AppColors.textSecondary, + ), + const SizedBox(width: 6), + Text( + DateTimeUtils.formatJoinedDate(profile!.createdAt!), + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ], + ), + ); + } Widget _buildStatsRow() { final stats = profile?.stats; diff --git a/test/widgets/profile_header_test.dart b/test/widgets/profile_header_test.dart new file mode 100644 index 0000000..aaa7c3e --- /dev/null +++ b/test/widgets/profile_header_test.dart @@ -0,0 +1,152 @@ +import 'package:coves_flutter/models/user_profile.dart'; +import 'package:coves_flutter/widgets/profile_header.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Geometry regression tests for [ProfileHeader]. +/// +/// The header previously drifted across devices because its height was +/// hardcoded while its content started below a SafeArea whose top inset +/// varies per device. These tests pin the relationship between the +/// status-bar inset and where the banner, avatar, and identity block land, +/// at insets spanning a plain phone (0), a notch (44), and a Dynamic +/// Island (59). +void main() { + const insets = [0, 20, 44, 59]; + + UserProfile buildProfile() => UserProfile( + did: 'did:plc:abcdefghijklmnopqrstuvwx', + handle: 'someone.example.com', + bio: 'A bio that is long enough to wrap onto more than one line.', + ); + + /// Pumps a profile screen skeleton at [topInset] and returns the tester. + Future pumpAt(WidgetTester tester, double topInset) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData(padding: EdgeInsets.only(top: topInset)), + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + expandedHeight: ProfileHeader.expandedHeightFor(context), + flexibleSpace: ProfileHeader(profile: buildProfile()), + ), + SliverToBoxAdapter( + child: ProfileDetails(profile: buildProfile()), + ), + const SliverToBoxAdapter(child: SizedBox(height: 2000)), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + group('ProfileHeader geometry', () { + testWidgets( + 'expandedHeightFor excludes the status-bar inset so SliverAppBar ' + 'does not count it twice', + (tester) async { + final heights = []; + for (final inset in insets) { + await pumpAt(tester, inset); + final context = tester.element(find.byType(ProfileHeader)); + heights.add(ProfileHeader.expandedHeightFor(context)); + } + // The value handed to SliverAppBar must not vary with the inset — + // SliverAppBar adds padding.top to it internally. + expect(heights.toSet(), hasLength(1)); + }, + ); + + testWidgets('maxExtentFor equals inset + expandedHeightFor', ( + tester, + ) async { + for (final inset in insets) { + await pumpAt(tester, inset); + final context = tester.element(find.byType(ProfileHeader)); + expect( + ProfileHeader.maxExtentFor(context), + ProfileHeader.expandedHeightFor(context) + inset, + reason: 'maxExtent must mirror SliverAppBar for inset $inset', + ); + } + }); + + testWidgets( + 'avatar sits at a constant offset below the collapsed app bar on ' + 'every inset', + (tester) async { + final offsets = []; + for (final inset in insets) { + await pumpAt(tester, inset); + final context = tester.element(find.byType(ProfileHeader)); + final avatarTop = tester + .getTopLeft( + find.descendant( + of: find.byType(ProfileHeader), + matching: find.byType(ClipOval), + ), + ) + .dy; + offsets.add(avatarTop - ProfileHeader.collapsedExtentFor(context)); + } + // Same distance below the app bar regardless of device inset — + // this is the cross-device consistency the header guarantees. + expect(offsets.toSet(), hasLength(1)); + }, + ); + + testWidgets( + 'avatar never rises above the collapsed app bar, so it cannot show ' + 'through the frosted overlay', + (tester) async { + for (final inset in insets) { + await pumpAt(tester, inset); + final context = tester.element(find.byType(ProfileHeader)); + final avatarTop = tester + .getTopLeft( + find.descendant( + of: find.byType(ProfileHeader), + matching: find.byType(ClipOval), + ), + ) + .dy; + expect( + avatarTop, + greaterThanOrEqualTo(ProfileHeader.collapsedExtentFor(context)), + reason: 'avatar pokes into the collapsed bar at inset $inset', + ); + } + }, + ); + + testWidgets('DID is rendered and stays inside the viewport width', ( + tester, + ) async { + for (final inset in insets) { + await pumpAt(tester, inset); + final did = find.textContaining('did:plc:'); + expect(did, findsOneWidget, reason: 'DID missing at inset $inset'); + final rect = tester.getRect(did); + expect(rect.left, greaterThanOrEqualTo(0)); + expect( + rect.right, + lessThanOrEqualTo(tester.view.physicalSize.width / + tester.view.devicePixelRatio), + ); + } + }); + }); +}