From ed1cc9c810e699b4ddf7c7a411b3b56ec1de6c52 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Tue, 25 Aug 2026 09:32:48 -0400 Subject: [PATCH] Settings: profile + billing on top, everything else behind More The web Settings page rendered nine sections in one flat column, so the two things people actually open it for -- who they are signed in as, and what their storage costs -- sat buried among Share ID, wallet keys and endpoint config. It is now three entries: PROFILE (was ACCOUNT, larger avatar), a new BILLING row, and a collapsed "More" tile holding the other seven sections verbatim, so expanding it restores exactly the previous page. Billing has no in-app screen on web, so the row opens cloud.fx.land at /login?returnTo=%2Fbilling -- pinning-webui has a real billing route, and its Login honours any returnTo beginning with a slash. Resolving through issuerBaseUrl keeps a user-configured billing server working instead of hardcoding the default. That row's subtitle is deliberately quiet. getStorageAndCredits fails fast when there is no JWT, which is the normal state both when signed out and for a tokenless Mode-C vault user, so a plain FutureBuilder would have parked a BillingApiException error row at the very top of the page this change exists to simplify. Pending and failed both fall back to static copy -- no spinner, no error row -- matching how the home screen and the rank badge already treat this same fetch. ExpansionTile's own top/bottom rules are suppressed via shape and collapsedShape rather than a transparent dividerColor, which would also have erased the Dividers between the sections nested inside the tile. Expansion state rides on the tile's own State, which survives the setState the Security section fires from inside it. Two incidental fixes to the same file: it was the only one of 394 .dart files under lib/ carrying a UTF-8 BOM, and it held four mojibake ellipsis sequences in user-visible strings, so the app was rendering a garbled "Generating..." on screen. The BOM is the likely cause of both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X6TQxyiZN6cv1NJrXkX5Ps --- lib/web/screens/web_settings_screen.dart | 167 +++++++++++++++++++---- 1 file changed, 144 insertions(+), 23 deletions(-) diff --git a/lib/web/screens/web_settings_screen.dart b/lib/web/screens/web_settings_screen.dart index dab3ea7..d790bf9 100644 --- a/lib/web/screens/web_settings_screen.dart +++ b/lib/web/screens/web_settings_screen.dart @@ -1,4 +1,4 @@ -import 'dart:convert'; +import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter/material.dart'; @@ -6,6 +6,9 @@ import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:fula_files/core/models/billing/storage_info.dart'; +import 'package:fula_files/core/services/auth_core.dart'; +import 'package:fula_files/core/services/billing_api_service.dart'; import 'package:fula_files/core/services/fula_api_service.dart'; import 'package:fula_files/core/services/nft_wallet_service.dart'; import 'package:fula_files/core/services/secure_storage_service.dart'; @@ -21,6 +24,11 @@ const String kWebAppVersion = 'v1.11.16.0'; /// sections that are meaningful on web (Account, Your Share ID, NFT Wallet, /// Security, API Configuration, About) and falls back to cloud.fx.land via /// the "Other settings" row for the sections that are mobile-only. +/// +/// Layout: the two things people actually come here for — their profile and +/// their billing/storage — sit at the top, and everything else lives behind a +/// collapsed "More" tile so the page reads as a short list rather than a wall +/// of sections. class WebSettingsScreen extends StatefulWidget { const WebSettingsScreen({super.key}); @@ -37,6 +45,10 @@ class _WebSettingsScreenState extends State { late final Future _encryptionKey = SecureStorageService.instance.read(SecureStorageKeys.encryptionKey); + /// Storage + credits for the Billing row's subtitle. Deliberately + /// non-throwing: see [_loadStorage]. + late final Future _storage = _loadStorage(); + bool _revealKey = false; Future _resolveShareId() async { @@ -44,6 +56,38 @@ class _WebSettingsScreenState extends State { return encodeFulaShareId(pk); } + /// Best-effort storage/credits fetch for the Billing subtitle. + /// + /// Never throws and never surfaces an error: `getStorageAndCredits()` + /// fails fast when there is no JWT — its `_ensureConfigured` throws before + /// any HTTP, which an `async` method surfaces as a rejected Future — and + /// that is the normal state both when signed out and for a tokenless + /// Mode-C vault user. An error row at the very top of the page is exactly + /// the noise this layout exists to remove, so on any failure the row falls + /// back to its static subtitle. Same "stay quiet until it resolves" + /// treatment the home screen and the rank badge give this same fetch. + Future _loadStorage() async { + if (WebSession.instance.user == null) return null; + try { + return await BillingApiService.instance.getStorageAndCredits(); + } catch (_) { + return null; + } + } + + /// Opens the cloud.fx.land billing page. Routes through `/login` with a + /// `returnTo` so a browser without an active cloud session lands on billing + /// after signing in rather than on the dashboard (pinning-webui's Login + /// honours any `returnTo` that starts with `/`). Uses the user's configured + /// billing server when they have overridden it in API Configuration. + Future _openBilling() async { + final base = await AuthCore.issuerBaseUrl(); + await launchUrl( + Uri.parse('$base/login?returnTo=%2Fbilling'), + webOnlyWindowName: '_blank', + ); + } + Future _copy(String value, String label) async { await Clipboard.setData(ClipboardData(text: value)); if (!mounted) return; @@ -75,21 +119,9 @@ class _WebSettingsScreenState extends State { const SizedBox(height: 8), _accountSection(context), const Divider(height: 1), - _shareIdSection(context), + _billingSection(context), const Divider(height: 1), - _nftWalletSection(context), - const Divider(height: 1), - _securitySection(context), - const Divider(height: 1), - _apiConfigSection(context), - const Divider(height: 1), - _integrationsSection(context), - const Divider(height: 1), - _syncQueueSection(context), - const Divider(height: 1), - _otherSection(context), - const Divider(height: 1), - _aboutSection(context), + _moreSection(context), const SizedBox(height: 24), ], ), @@ -106,7 +138,7 @@ class _WebSettingsScreenState extends State { final identity = user == null ? 'Not signed in' : isVault - ? 'Vault ${user.id.length >= 8 ? user.id.substring(0, 8) : user.id}…' + ? 'Vault ${user.id.length >= 8 ? user.id.substring(0, 8) : user.id}…' : user.email; final subtitle = user == null ? null @@ -116,11 +148,11 @@ class _WebSettingsScreenState extends State { ? user.displayName : 'Signed in'; return _Section( - label: 'ACCOUNT', + label: 'PROFILE', children: [ ListTile( leading: CircleAvatar( - radius: 18, + radius: 22, backgroundColor: Theme.of(context).colorScheme.primary, backgroundImage: !isVault && user?.photoUrl != null ? NetworkImage(user!.photoUrl!) @@ -130,7 +162,7 @@ class _WebSettingsScreenState extends State { : Icon( isVault ? Icons.person_outline : Icons.person, color: Colors.white, - size: 20, + size: 24, ), ), title: Text( @@ -161,6 +193,94 @@ class _WebSettingsScreenState extends State { ); } + // Billing ----------------------------------------------------------------- + // Second of the two always-visible sections. There is no in-app billing + // screen on web, so the row hands off to cloud.fx.land; the subtitle shows + // live usage when we can get it so the row is worth reading even when the + // user doesn't click through. + Widget _billingSection(BuildContext context) { + return _Section( + label: 'BILLING', + children: [ + ListTile( + leading: const Icon(Icons.account_balance_wallet_outlined), + title: const Text('Billing & storage'), + subtitle: _billingSubtitle(), + trailing: const Icon(Icons.north_east, size: 16), + onTap: _openBilling, + ), + ], + ); + } + + Widget _billingSubtitle() { + const fallback = Text('Credits, plan and payment on cloud.fx.land'); + return FutureBuilder( + future: _storage, + builder: (context, snap) { + final info = snap.data; + // Pending and failed both fall through to the static copy -- no + // spinner, no error row (see _loadStorage). + if (info == null) return fallback; + final credits = info.remainingCredits; + return Text( + '${info.formattedCurrentStorage} of ${info.formattedTotalStorage} ' + 'used, ${_formatCredits(credits)} credits left', + ); + }, + ); + } + + /// Credits come back as a double; show whole numbers without a trailing + /// ".0" and fractional balances to two places. + String _formatCredits(double credits) { + if (credits <= 0) return '0'; + return credits == credits.roundToDouble() + ? credits.toStringAsFixed(0) + : credits.toStringAsFixed(2); + } + + // More -------------------------------------------------------------------- + // Everything that isn't profile or billing, collapsed. Each child is one of + // the original _Section widgets, unchanged -- expanding restores exactly the + // page people had before. + Widget _moreSection(BuildContext context) { + return ExpansionTile( + leading: const Icon(Icons.more_horiz), + title: const Text('More'), + subtitle: + const Text('Share ID, wallet, security, integrations, advanced'), + // ExpansionTile draws its own top/bottom rules when open, which would + // double up with the Dividers around it. Suppressing them here rather + // than via a transparent dividerColor, which would also erase the + // Dividers between the sections inside. + shape: const Border(), + collapsedShape: const Border(), + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + // The nested _Sections bring their own horizontal padding. + childrenPadding: EdgeInsets.zero, + expandedCrossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Divider(height: 1), + _shareIdSection(context), + const Divider(height: 1), + _nftWalletSection(context), + const Divider(height: 1), + _securitySection(context), + const Divider(height: 1), + _apiConfigSection(context), + const Divider(height: 1), + _integrationsSection(context), + const Divider(height: 1), + _syncQueueSection(context), + const Divider(height: 1), + _otherSection(context), + const Divider(height: 1), + _aboutSection(context), + ], + ); + } + // ── Your Share ID ──────────────────────────────────────────────────── Widget _shareIdSection(BuildContext context) { return _Section( @@ -178,7 +298,7 @@ class _WebSettingsScreenState extends State { future: _shareId, builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { - return const _LoadingRow(label: 'Generating…'); + return const _LoadingRow(label: 'Generating…'); } if (snap.hasError || snap.data == null) { return _ErrorRow(message: 'Could not generate Share ID', @@ -211,7 +331,7 @@ class _WebSettingsScreenState extends State { future: _nftAddress, builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { - return const _LoadingRow(label: 'Deriving…'); + return const _LoadingRow(label: 'Deriving…'); } final addr = snap.data; if (snap.hasError || addr == null || addr.isEmpty) { @@ -249,7 +369,7 @@ class _WebSettingsScreenState extends State { future: _encryptionKey, builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { - return const _LoadingRow(label: 'Loading…'); + return const _LoadingRow(label: 'Loading…'); } final key = snap.data; if (snap.hasError || key == null || key.isEmpty) { @@ -408,7 +528,8 @@ class _WebSettingsScreenState extends State { ListTile( leading: const Icon(Icons.open_in_new), title: const Text('Other settings'), - subtitle: const Text('Billing, devices and more on cloud.fx.land'), + subtitle: const Text( + 'API keys, pins, referrals and profile on cloud.fx.land'), trailing: const Icon(Icons.north_east, size: 16), onTap: () => launchUrl( Uri.parse('https://cloud.fx.land'),