diff --git a/lib/widgets/profile_header.dart b/lib/widgets/profile_header.dart index 839e163..d477396 100644 --- a/lib/widgets/profile_header.dart +++ b/lib/widgets/profile_header.dart @@ -37,6 +37,15 @@ class ProfileHeader extends StatelessWidget { static const double _bottomPadding = 12; static const double _identityTopGap = 6; + /// Type size for the handle when it fits the identity column outright. + static const double _handleMaxFontSize = 20; + + /// Floor the handle shrinks to before it gives up and ellipsizes. + /// + /// Below this the handle stops reading as the primary identity on the + /// screen, so a very long handle is truncated rather than shrunk further. + static const double _handleMinFontSize = 14; + /// Sliver extent when fully collapsed — mirrors `SliverAppBar.minExtent` /// for a primary app bar with no bottom. static double collapsedExtentFor(BuildContext context) => @@ -49,6 +58,10 @@ class ProfileHeader extends StatelessWidget { /// 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). + /// + /// The handle is measured at [_handleMaxFontSize]; a handle that shrinks + /// to fit only ever occupies less height than that, so this stays a safe + /// upper bound and the header's geometry is unaffected by handle length. static double _infoHeightFor(BuildContext context) { final scaler = MediaQuery.textScalerOf(context); // Handle line (fontSize 20) + gap + DID line (fontSize 12 + icon). @@ -196,15 +209,8 @@ class ProfileHeader extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - profile?.handle != null ? '@${profile!.handle}' : 'Loading...', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + _HandleText( + text: profile?.handle != null ? '@${profile!.handle}' : 'Loading...', ), if (profile?.did != null) ...[ const SizedBox(height: 4), @@ -287,6 +293,122 @@ class ProfileHeader extends StatelessWidget { } } +/// The handle line of the identity block, scaled down to fit its column. +/// +/// atProto handles are full domains, so their length varies wildly — +/// `@alice.bsky.social` fits the column beside the avatar at full size, +/// while `@somelongname.coves.social` does not. Rather than ellipsizing the +/// user's identity away, the type size steps down from +/// [ProfileHeader._handleMaxFontSize] toward +/// [ProfileHeader._handleMinFontSize] until the whole handle fits on one +/// line. Only a handle still too long at the floor is truncated. +/// +/// Stateful purely to memoise the fitted size: this widget rebuilds on every +/// frame while the header collapses, and text measurement is expensive +/// enough that Flutter's own [TextPainter.computeWidth] documents it as +/// something to avoid repeating. None of the measurement inputs change +/// during a collapse, so the cache makes scrolling free. +class _HandleText extends StatefulWidget { + const _HandleText({required this.text}); + + final String text; + + @override + State<_HandleText> createState() => _HandleTextState(); +} + +class _HandleTextState extends State<_HandleText> { + /// Everything the fitted size depends on. A record so equality is + /// structural — if any input changes, the size is recomputed. + (String, double, TextStyle, TextScaler, TextDirection)? _cacheKey; + double _fontSize = ProfileHeader._handleMaxFontSize; + + /// Largest size in [[ProfileHeader._handleMinFontSize], + /// [ProfileHeader._handleMaxFontSize]] that fits [text] on one line. + static double _fitFontSize({ + required String text, + required double available, + required TextStyle style, + required TextScaler scaler, + required TextDirection direction, + }) { + // computeWidth disposes the painter it creates; a bare TextPainter here + // would leak an engine paragraph on every measurement. + double widthAt(double fontSize) => TextPainter.computeWidth( + text: TextSpan( + text: text, + style: style.copyWith(fontSize: fontSize), + ), + maxLines: 1, + textDirection: direction, + textScaler: scaler, + ); + + final fullWidth = widthAt(ProfileHeader._handleMaxFontSize); + if (fullWidth <= available || fullWidth <= 0) { + return ProfileHeader._handleMaxFontSize; + } + + // Glyph advances scale nearly linearly with font size, so one ratio + // lands on (or just past) the right size; the loop then settles the + // remainder. Non-linear text scaling on some platforms is what makes + // that verification necessary. + var fontSize = (ProfileHeader._handleMaxFontSize * available / fullWidth) + .clamp( + ProfileHeader._handleMinFontSize, + ProfileHeader._handleMaxFontSize, + ); + while (fontSize > ProfileHeader._handleMinFontSize && + widthAt(fontSize) > available) { + fontSize = math.max(ProfileHeader._handleMinFontSize, fontSize - 0.5); + } + return fontSize; + } + + @override + Widget build(BuildContext context) { + // Inherit the ambient style so the measurement below uses the same font + // the Text will actually render with. + final style = DefaultTextStyle.of(context).style.merge( + const TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ); + final scaler = MediaQuery.textScalerOf(context); + final direction = Directionality.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final key = ( + widget.text, + constraints.maxWidth, + style, + scaler, + direction, + ); + if (key != _cacheKey) { + _fontSize = _fitFontSize( + text: widget.text, + available: constraints.maxWidth, + style: style, + scaler: scaler, + direction: direction, + ); + _cacheKey = key; + } + + return Text( + widget.text, + style: style.copyWith(fontSize: _fontSize), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, + ); + } +} + /// 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. diff --git a/test/widgets/profile_header_test.dart b/test/widgets/profile_header_test.dart index aaa7c3e..3f89889 100644 --- a/test/widgets/profile_header_test.dart +++ b/test/widgets/profile_header_test.dart @@ -1,6 +1,7 @@ import 'package:coves_flutter/models/user_profile.dart'; import 'package:coves_flutter/widgets/profile_header.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; /// Geometry regression tests for [ProfileHeader]. @@ -14,14 +15,19 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const insets = [0, 20, 44, 59]; - UserProfile buildProfile() => UserProfile( + UserProfile buildProfile({String handle = 'someone.example.com'}) => + UserProfile( did: 'did:plc:abcdefghijklmnopqrstuvwx', - handle: 'someone.example.com', + handle: handle, 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 { + Future pumpAt( + WidgetTester tester, + double topInset, { + String handle = 'someone.example.com', + }) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3; addTearDown(tester.view.reset); @@ -37,7 +43,9 @@ void main() { SliverAppBar( pinned: true, expandedHeight: ProfileHeader.expandedHeightFor(context), - flexibleSpace: ProfileHeader(profile: buildProfile()), + flexibleSpace: ProfileHeader( + profile: buildProfile(handle: handle), + ), ), SliverToBoxAdapter( child: ProfileDetails(profile: buildProfile()), @@ -149,4 +157,139 @@ void main() { } }); }); + + /// atProto handles are full domains of unbounded length, and the identity + /// column beside the avatar is only ~236pt wide here. The handle shrinks + /// to fit rather than ellipsizing the user's identity away. + /// + /// The test font renders every glyph a full em wide, so far fewer + /// characters fit than with the app's real font — these fixtures are + /// chosen for their behaviour under that font (fits outright / shrinks and + /// fits / bottoms out at the floor), not for their literal length. + group('ProfileHeader handle fitting', () { + const fitsHandle = 'a.bsky.co'; + const shrinksHandle = 'alice.bsky.io'; + const overlongHandle = 'absurdly.long.custom.domain.example.coves.social'; + const allHandles = [fitsHandle, shrinksHandle, overlongHandle]; + + double fontSizeOf(WidgetTester tester, String handle) { + final text = tester.widget(find.text('@$handle')); + return text.style!.fontSize!; + } + + bool isTruncated(WidgetTester tester, String handle) { + final paragraph = + tester.renderObject(find.text('@$handle')); + return paragraph.didExceedMaxLines; + } + + testWidgets('a handle that fits keeps the full 20pt type size', ( + tester, + ) async { + await pumpAt(tester, 44, handle: fitsHandle); + expect(fontSizeOf(tester, fitsHandle), 20); + expect(isTruncated(tester, fitsHandle), isFalse); + }); + + testWidgets('a longer handle shrinks instead of being cut off', ( + tester, + ) async { + await pumpAt(tester, 44, handle: shrinksHandle); + final size = fontSizeOf(tester, shrinksHandle); + expect(size, lessThan(20)); + expect(size, greaterThan(14)); + expect( + isTruncated(tester, shrinksHandle), + isFalse, + reason: 'the whole handle should fit once shrunk', + ); + }); + + testWidgets('shrinking stops at the 14pt floor', (tester) async { + await pumpAt(tester, 44, handle: overlongHandle); + // Past the floor the handle ellipsizes rather than shrinking into + // illegibility — it must never render below 14pt. + expect(fontSizeOf(tester, overlongHandle), 14); + expect(isTruncated(tester, overlongHandle), isTrue); + }); + + testWidgets('type size stays within bounds and never grows with length', ( + tester, + ) async { + var previous = double.infinity; + for (final handle in allHandles) { + await pumpAt(tester, 44, handle: handle); + final size = fontSizeOf(tester, handle); + expect( + size, + inInclusiveRange(14, 20), + reason: 'out of bounds: $handle', + ); + expect( + size, + lessThanOrEqualTo(previous), + reason: '$handle should not render larger than a shorter handle', + ); + previous = size; + } + }); + + testWidgets('a handle is only ever truncated at the floor', (tester) async { + for (final handle in allHandles) { + await pumpAt(tester, 44, handle: handle); + if (fontSizeOf(tester, handle) > 14) { + expect( + isTruncated(tester, handle), + isFalse, + reason: '$handle was cut off with room left to shrink', + ); + } + } + }); + + testWidgets('the handle never overflows its column', (tester) async { + for (final handle in allHandles) { + await pumpAt(tester, 44, handle: handle); + final rect = tester.getRect(find.text('@$handle')); + final screenWidth = + tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect( + rect.right, + lessThanOrEqualTo(screenWidth), + reason: '$handle spills past the screen edge', + ); + } + }); + + testWidgets('a changed handle is re-fitted, not served from cache', ( + tester, + ) async { + // The fitted size is memoised across rebuilds so scrolling does not + // re-measure. Swapping the profile in place must still invalidate it — + // the same widget position keeps its State across pumps. + await pumpAt(tester, 44, handle: overlongHandle); + expect(fontSizeOf(tester, overlongHandle), 14); + + await pumpAt(tester, 44, handle: fitsHandle); + expect( + fontSizeOf(tester, fitsHandle), + 20, + reason: 'stale fitted size survived a handle change', + ); + }); + + testWidgets('handle length does not change the header geometry', ( + tester, + ) async { + final heights = []; + for (final handle in allHandles) { + await pumpAt(tester, 44, handle: handle); + final context = tester.element(find.byType(ProfileHeader)); + heights.add(ProfileHeader.expandedHeightFor(context)); + } + // Shrinking happens within the line box reserved at 20pt, so the + // header's deterministic geometry is unaffected by handle length. + expect(heights.toSet(), hasLength(1)); + }); + }); }