Skip to content
Merged
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
167 changes: 144 additions & 23 deletions lib/web/screens/web_settings_screen.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import 'dart:convert';
import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
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';
Expand All @@ -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});

Expand All @@ -37,13 +45,49 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
late final Future<String?> _encryptionKey =
SecureStorageService.instance.read(SecureStorageKeys.encryptionKey);

/// Storage + credits for the Billing row's subtitle. Deliberately
/// non-throwing: see [_loadStorage].
late final Future<StorageInfo?> _storage = _loadStorage();

bool _revealKey = false;

Future<String> _resolveShareId() async {
final pk = await FulaApiService.instance.getPublicKey();
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<StorageInfo?> _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<void> _openBilling() async {
final base = await AuthCore.issuerBaseUrl();
await launchUrl(
Uri.parse('$base/login?returnTo=%2Fbilling'),
webOnlyWindowName: '_blank',
);
}

Future<void> _copy(String value, String label) async {
await Clipboard.setData(ClipboardData(text: value));
if (!mounted) return;
Expand Down Expand Up @@ -75,21 +119,9 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
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),
],
),
Expand All @@ -106,7 +138,7 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
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
Expand All @@ -116,11 +148,11 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
? 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!)
Expand All @@ -130,7 +162,7 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
: Icon(
isVault ? Icons.person_outline : Icons.person,
color: Colors.white,
size: 20,
size: 24,
),
),
title: Text(
Expand Down Expand Up @@ -161,6 +193,94 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
);
}

// 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<StorageInfo?>(
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(
Expand All @@ -178,7 +298,7 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
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',
Expand Down Expand Up @@ -211,7 +331,7 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
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) {
Expand Down Expand Up @@ -249,7 +369,7 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
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) {
Expand Down Expand Up @@ -408,7 +528,8 @@ class _WebSettingsScreenState extends State<WebSettingsScreen> {
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'),
Expand Down
Loading