Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 60 additions & 26 deletions lib/web/screens/web_home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,56 @@ class _WebHomeScreenState extends State<WebHomeScreen> {
);
}

/// Rank insignia for the header, or nothing at all.
/// Width reserved for the leading identity block (avatar above rank).
///
/// Depends on the viewport and the signed-in flag ONLY — never on the
/// rank, which arrives asynchronously. If it did, the title would jump
/// sideways the moment billing resolved.
double _leadingWidth(BuildContext context) {
if (!WebSession.instance.isSignedIn) return 56;
// Enough for four pips plus the widest tier name ("Platinum") when
// the label is shown, pips-only otherwise.
return MediaQuery.sizeOf(context).width >= 420 ? 132 : 76;
}

/// Profile avatar with the rank insignia stacked beneath it.
///
/// Fits the FIXED 56px toolbar: avatar 24 + gap 2 + badge 28 = 54.
/// That budget is why the avatar is radius 12 here rather than the
/// 14 used elsewhere, and why the badge's tap target is 28 rather
/// than the ideal 48 — the header must not grow.
///
/// The avatar and the badge are SEPARATE tap targets on purpose:
/// tapping the avatar opens the profile sheet, tapping the rank shows
/// its hint. One shared gesture region would make the hint
/// unreachable.
Widget _identityBlock(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Tooltip(
message: 'Profile',
child: InkWell(
onTap: _showProfileSheet,
customBorder: const CircleBorder(),
child: _profileAvatar(context, radius: 12),
),
),
const SizedBox(height: 2),
_rankBadge(),
],
),
),
);
}

/// Rank insignia, or nothing at all.
///
/// Renders NOTHING while signed out, while the billing fetch is in
/// flight, and on error — deliberately not a placeholder or spinner.
Expand All @@ -217,12 +266,9 @@ class _WebHomeScreenState extends State<WebHomeScreen> {
return const SizedBox.shrink();
}
final paid = snap.data!.paidStorageBytes;
return Padding(
padding: const EdgeInsets.only(left: 8),
child: WebRankBadge(
rank: rankForPaidStorageBytes(paid),
paidStorageBytes: paid,
),
return WebRankBadge(
rank: rankForPaidStorageBytes(paid),
paidStorageBytes: paid,
);
},
);
Expand All @@ -235,31 +281,19 @@ class _WebHomeScreenState extends State<WebHomeScreen> {
// avatar (left, opens the identity/sign-out sheet), plain
// "FxFiles" title (no logo), settings (right → cloud portal).
appBar: AppBar(
// Reserved width for the identity block. Derived ONLY from the
// viewport and the signed-in flag — both known synchronously —
// so the rank arriving later fills reserved space instead of
// shoving the title sideways.
leadingWidth: _leadingWidth(context),
leading: WebSession.instance.isSignedIn
? IconButton(
tooltip: 'Profile',
onPressed: _showProfileSheet,
icon: _profileAvatar(context),
)
? _identityBlock(context)
: IconButton(
tooltip: 'Sign in',
onPressed: _openLoginSheet,
icon: const Icon(Icons.account_circle_outlined),
),
// Title + rank insignia. The Row is min-width and the TITLE is
// the flexible part, so on a narrow phone "FxFiles" ellipsizes
// instead of the two texts running into each other, and the
// badge (shorter than the title's line height) can never grow
// the toolbar. See WebRankBadge's layout contract.
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Flexible(
child: Text('FxFiles', overflow: TextOverflow.ellipsis),
),
_rankBadge(),
],
),
title: const Text('FxFiles', overflow: TextOverflow.ellipsis),
actions: [
IconButton(
tooltip: 'Search',
Expand Down
2 changes: 1 addition & 1 deletion lib/web/screens/web_settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import 'package:fula_files/web/services/web_session.dart';

/// App version label shown in About + the home footer. Kept in one place
/// so the two stay in sync (the home footer imports this).
const String kWebAppVersion = 'v1.11.11.0';
const String kWebAppVersion = 'v1.11.12.0';

/// In-app web Settings page. Replaces the old behavior where the gear icon
/// opened cloud.fx.land in a new tab. Mirrors the mobile Settings screen's
Expand Down
97 changes: 69 additions & 28 deletions lib/web/widgets/web_rank_badge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ class WebRankBadge extends StatelessWidget {
/// the title and two action icons.
static const double _kLabelMinWidth = 420;

/// Tap-target height. The glyph row is only ~13px, far below a usable
/// touch target, so the hit area is padded out vertically.
///
/// 28 rather than the ideal 48: the badge is stacked UNDER the profile
/// avatar inside a fixed 56px toolbar, so the whole column (avatar 24 +
/// gap 2 + badge 28 = 54) has to fit without growing the header. The
/// header height was an explicit requirement, so it wins over the
/// larger target — but the tap area is still more than twice the
/// glyph row.
static const double _kTapTargetHeight = 28;

static Color colorFor(UserRank rank) => switch (rank) {
// Chosen to read on the app's dark surface, and to keep Silver
// and Platinum apart: Silver is neutral grey, Platinum is a
Expand All @@ -59,14 +70,17 @@ class WebRankBadge extends StatelessWidget {
UserRank.platinum => const Color(0xFF9FE3F0),
};

String _tooltip() {
/// The hint: what this rank is, and what it takes to reach the next
/// one. Phrased as an action ("add X") rather than a bare number,
/// because the whole point is telling the user what to DO.
String hintText() {
final label = userRankLabel(rank);
final remaining = bytesToNextRank(paidStorageBytes);
final next = nextRankAfter(rank);
if (remaining == null || next == null) {
return '$label — top rank';
return '$label — the top rank. Nothing left to unlock.';
}
return '$label · ${_fmtBytes(remaining)} more storage for '
return '$label — add ${_fmtBytes(remaining)} of storage to reach '
'${userRankLabel(next)}';
}

Expand All @@ -87,31 +101,58 @@ class WebRankBadge extends StatelessWidget {
final stars = userRankStars(rank);

return Tooltip(
message: _tooltip(),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: [
for (var i = 0; i < stars; i++)
Icon(Icons.star_rounded, size: _kStarSize, color: color),
if (showLabel) ...[
const SizedBox(width: 4),
Text(
userRankLabel(rank),
maxLines: 1,
overflow: TextOverflow.clip,
style: TextStyle(
fontSize: _kLabelSize,
// height 1.0 keeps the text box exactly the glyph height,
// so the label cannot be what grows the toolbar.
height: 1.0,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
color: color,
),
),
],
],
message: hintText(),
// Desktop shows this on hover for free. Touch does NOT — Tooltip's
// default touch trigger is a LONG PRESS, which nobody discovers.
// `tap` adds the tap trigger without removing hover, so the same
// hint is reachable both ways.
triggerMode: TooltipTriggerMode.tap,
// Long enough to actually read on a phone; the default (1.5s) is
// tuned for a hover the user can simply hold.
showDuration: const Duration(seconds: 5),
child: SizedBox(
// A taller TAP TARGET than the 13px glyph row, because a 13px
// target is not tappable on a phone. This does NOT grow the
// header: an AppBar is a fixed `toolbarHeight` (56) and centres
// its title inside, so a 36px title child changes no geometry —
// the widget tests assert exactly that.
height: _kTapTargetHeight,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: [
for (var i = 0; i < stars; i++)
Icon(Icons.star_rounded, size: _kStarSize, color: color),
if (showLabel) ...[
const SizedBox(width: 4),
// Flexible + ellipsis so the label can NEVER overflow the
// width it is given. The pips are the rank signal and stay
// fixed; the name is what yields. This is not theoretical:
// an OS-level text-scale setting makes "Platinum" wider
// than the reserved leading slot, and an unconstrained Row
// would paint the overflow stripes into the header.
Flexible(
child: Text(
userRankLabel(rank),
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: _kLabelSize,
// height 1.0 keeps the text box exactly the glyph
// height, so the label cannot grow the toolbar.
height: 1.0,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
color: color,
),
),
),
],
],
),
),
),
);
}
Expand Down
Loading
Loading