Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
609f764
feat: prototype a permanence panel in the profile card
vilenarios Oct 7, 2026
27765e2
feat: permanence panel reads real spend, shares a card, fixes the sta…
vilenarios Oct 8, 2026
17403d3
fix: permanence spend counts credit grants, and says why it has no fi…
vilenarios Oct 8, 2026
338cb8d
ui: permanence strata are a heat map of how much was uploaded each year
vilenarios Oct 9, 2026
66a237d
feat: permanence panel counts only what landed, says what its numbers…
vilenarios Oct 9, 2026
0ca446d
ui: permanence panel fits without scrolling, follows the modal standard
vilenarios Oct 9, 2026
838158e
feat: permanence panel shows AR paid directly, beside the dollars
vilenarios Oct 10, 2026
87d7ea2
fix: six findings from an adversarial review of the permanence panel
vilenarios Oct 10, 2026
17c3b4c
fix: the profile card and the permanence panel show the same size
vilenarios Oct 11, 2026
3e0a5b8
fix: AR tally skips bundled data items on ar.io gateways; no figure o…
vilenarios Oct 11, 2026
af2cc70
fix: start AR tallies afresh under a new storage key
vilenarios Oct 11, 2026
577a6da
fix: a top-up history cut short gives no spend estimate, not a wrong one
vilenarios Oct 11, 2026
ca8efbf
feat: cost today is the network's price for permanent storage, in AR …
vilenarios Oct 11, 2026
15612ac
feat: spent is turbo credits used and AR paid, with no dollar estimate
vilenarios Oct 11, 2026
b68bb72
fix: credits used subtract the wallet's own credits, not what it can …
vilenarios Oct 11, 2026
42a51ca
feat: AR paid directly counts what the wallet's synced drives are mad…
vilenarios Oct 11, 2026
416cdd4
fix: AR left out before its drive synced is counted once sync knows it
vilenarios Oct 11, 2026
407f5a9
ui: strata on a log scale; the permanence panel fits a phone
vilenarios Oct 12, 2026
4be2031
chore: merge dev into permanence-panel
vilenarios Oct 18, 2026
ee979b7
test: the fragment-free check covers the wallet fees query
vilenarios Oct 18, 2026
d88eed1
fix: share my permanence opens the share card
vilenarios Oct 18, 2026
370f0b5
fix: permanence sizes, separators, long histories and the share dialog
vilenarios Oct 18, 2026
d2bd8ae
fix: the year chart spreads the labels of thin years apart
vilenarios Oct 18, 2026
ef8e677
fix: permanence figures count every version, date uploads and include…
vilenarios Oct 18, 2026
c7f408c
perf: permanence figures read only what they need, and no surprise pr…
vilenarios Oct 18, 2026
a9169c4
fix: the fine print says spent leaves out credits shared from a sign-…
vilenarios Oct 18, 2026
bb36af9
chore: merge dev into permanence-panel
vilenarios Oct 19, 2026
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
278 changes: 223 additions & 55 deletions lib/components/profile_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import 'package:ardrive/entities/profile_types.dart';
import 'package:ardrive/gar/domain/repositories/gar_repository.dart';
import 'package:ardrive/gar/presentation/widgets/gateway_input_modal.dart';
import 'package:ardrive/pages/drive_detail/components/hover_widget.dart';
import 'package:ardrive/permanence/permanence_panel.dart';
import 'package:ardrive/permanence/ar_spend.dart';
import 'package:ardrive/turbo/topup/models/crypto_token.dart';
import 'package:ardrive/services/arweave/arweave_service.dart';
import 'package:ardrive/utils/local_key_value_store.dart';
import 'package:ardrive/services/config/config.dart';
import 'package:ardrive/services/config/config_service.dart';
import 'package:ardrive/utils/constants.dart';
Expand Down Expand Up @@ -55,6 +59,17 @@ class _ProfileCardState extends State<ProfileCard> {
/// Whose statistics [_accountStatsFuture] holds.
String? _accountStatsForWallet;

/// The last stats that finished, shown while a fresh count runs, so the
/// line does not blink out every time the card opens.
_AccountStats? _lastAccountStats;

/// The last stats counted, and what they were counted from: the wallet,
/// its drives and [DriveDao.permanentDataVersion]. The card counts again
/// each time it opens, and while nothing it counts has changed that is
/// this, rather than a scan of every revision.
_AccountStats? _countedStats;
String? _countedStatsKey;

@override
Widget build(BuildContext context) {
return BlocBuilder<ProfileCubit, ProfileState>(
Expand Down Expand Up @@ -436,6 +451,10 @@ class _ProfileCardState extends State<ProfileCard> {
// while the profile changes between logged-in and logged-out, so a plain
// `??=` let a second account read the first one's drive count, file count
// and size.
if (_accountStatsForWallet != walletAddress) {
_lastAccountStats = null;
}

if (_accountStatsFuture == null ||
_accountStatsForWallet != walletAddress) {
_accountStatsForWallet = walletAddress;
Expand All @@ -445,46 +464,188 @@ class _ProfileCardState extends State<ProfileCard> {
return FutureBuilder<_AccountStats>(
future: _accountStatsFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final stats = snapshot.data!;
if (snapshot.hasData) {
_lastAccountStats = snapshot.data;
}

final stats = snapshot.data ?? _lastAccountStats;

// Nothing synced, or nothing owned: no line saying "0 drives". The
// numbers appear once there is something to count.
if (stats == null || stats.driveCount == 0) {
return const SizedBox.shrink();
}

// What is stored for good - each piece of data once, only what
// landed - rather than the size of every file row above.
final permanentBytes = stats.permanentBytes;

return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Center(
child: Text(
'${stats.driveCount} ${stats.driveCount == 1 ? 'drive' : 'drives'} · ${stats.fileCount} ${stats.fileCount == 1 ? 'file' : 'files'} · ${_formatBytes(stats.totalSize)}',
style: typography.caption(
color: colorTokens.textLow,
fontWeight: ArFontWeight.book,
),
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
[
permanenceCount(stats.driveCount, 'drive'),
permanenceCount(stats.fileCount, 'file'),
formatPermanenceSize(stats.permanentBytes),
].join(permanenceSeparator),
style: typography.caption(
color: colorTokens.textLow,
fontWeight: ArFontWeight.book,
),
),
// A way in, not a figure: the panel says what it costs.
if (permanentBytes > 0) ...[
const SizedBox(height: 4),
ArDriveClickArea(
child: GestureDetector(
onTap: () {
// Everything below is asked for here, when the panel
// opens, and shown as it arrives - never on the card.
final paymentUrl = context
.read<ConfigService>()
.config
.defaultTurboPaymentUrl ??
'https://payment.ardrive.io';
final paymentService = context.read<PaymentService>();
final arweave = context.read<ArweaveService>();
final wallet =
context.read<ArDriveAuth>().currentUser.wallet;

// A signed request. A keyfile, or the wallet an
// Ethereum or Solana sign-in derives, signs without
// a word, so it is read as the panel opens. A browser
// extension shows its owner a prompt instead, so
// there it waits until they ask.
Future<SpendLookup> readSpend() => loadCreditsSpent(
paymentService: paymentService,
wallet: wallet,
);
final signsSilently = context
.read<ArDriveAuth>()
.currentUser
.profileType !=
ProfileType.arConnect;

// Public, so no signature: what this wallet paid in
// AR to upload directly, counted a page at a time and
// remembered. See [tallyArSpend].
final owner = context
.read<ArDriveAuth>()
.currentUser
.walletAddress;
// Which transactions this wallet's synced drives
// are made of: what makes one of its payments ArDrive
// storage when its tags don't say. Asked only about
// those, never for the whole set. See
// [isArDriveTransaction].
final driveDao = context.read<DriveDao>();
Future<Set<String>> knownAmong(Set<String> txIds) =>
driveDao.arDriveTransactionsAmong(
stats.driveIds,
txIds,
);
final arSpend = LocalKeyValueStore.getInstance().then(
(store) async => tallyArSpend(
// v4: counts made without the tips direct
// uploads sent, or before the drives' own
// transactions decided what is ArDrive's.
storageKey: 'permanenceArSpend_v4_$owner',
read: store.getString,
write: (key, value) => store.putString(key, value),
knownAmong: knownAmong,
fetchPage: ({required minHeight, after}) async =>
arweave.getArDriveFeesPage(
owner: owner,
minHeight: minHeight,
knownAmong: knownAmong,
after: after,
),
),
);

// What the network charges today for the same size,
// from the configured gateway, with AR's dollar price.
final costToday = loadNetworkCost(
price: () =>
arweave.getPrice(byteSize: permanentBytes),
usdPerAr: fetchUsdPerAr(paymentUrl),
);

setState(() {
_showProfileCard = false;
});

showPermanencePanel(
context,
PermanenceSummary(
totalBytes: permanentBytes,
fileCount: stats.fileCount,
driveCount: stats.driveCount,
bytesByYear: {
for (final year in stats.permanentByYear.entries)
year.key: year.value.bytes,
},
costToday: costToday,
spend: signsSilently ? readSpend() : null,
loadSpend: signsSilently ? null : readSpend,
signInChain: switch (walletTypeOfSourceAddress(
context
.read<ArDriveAuth>()
.currentUser
.sourceWalletAddress,
)) {
WalletType.ethereum => 'Ethereum',
WalletType.solana => 'Solana',
_ => null,
},
arSpend: arSpend,
),
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Your permanence',
style: typography.caption(
color: colorTokens.textRed,
fontWeight: ArFontWeight.semiBold,
),
),
const SizedBox(width: 2),
ArDriveIcons.carretRight(
size: 14,
color: colorTokens.textRed,
),
],
),
),
),
],
],
),
),
);
},
);
}

/// What this account holds locally, counted the way the drives list counts
/// it.
///
/// This used `filesInDriveWithRevisionTransactions`, once per drive, and
/// added up the rows it returned. That query inner-joins each file to the
/// metadata and data transactions of its latest revision, so **a file whose
/// transaction rows have not synced yet is not in the result at all** - it is
/// dropped from the count and its bytes from the total. As a sync fills those
/// rows in, the same drive reports a larger total than it did before, which
/// is what a reader sees as "the same drive, a different number of GB in each
/// session".
/// What this account keeps on Arweave, as far as this device knows: its own
/// drives' data, every version, each piece once, only once its upload is
/// confirmed, and not pinned files. See [DriveDao.permanentDataByYear].
///
/// [DriveDao.driveContentSummaries] asks the question this line is actually
/// asking: how many files are in the local tables, and how big are they. One
/// aggregate query for every drive rather than one query per drive returning
/// every row, summed in SQL rather than in Dart - and, being the same source
/// the drives list reads, it cannot disagree with the per-drive figures shown
/// there.
/// The same figure the permanence panel shows, so the two can never
/// disagree. It used to be every file row, from the query the drives list
/// reads - which counted a copy twice, and an upload that never landed - so
/// this line said 13.1 GB beside a panel that said 12.4.
///
/// Both numbers still describe this device rather than Arweave: a drive that
/// has not been walked contributes nothing, because nothing about it is
/// known.
/// It describes this device rather than Arweave: a drive that has not been
/// walked contributes nothing, because nothing about it is known.
Future<_AccountStats> _getAccountStats(
DriveDao driveDao,
String walletAddress,
Expand All @@ -510,36 +671,30 @@ class _ProfileCardState extends State<ProfileCard> {
(drive) => drive.ownerAddress == walletAddress && !drive.isHidden)
.toList();

final summaries = await driveDao.driveContentSummaries();

var fileCount = 0;
var totalSize = 0;

for (final drive in drives) {
final summary = summaries[drive.id];
final driveIds = [for (final drive in drives) drive.id];
final key = [
walletAddress,
...driveIds,
await driveDao.permanentDataVersion(),
].join('|');

if (summary == null) {
continue;
}

fileCount += summary.fileCount;
totalSize += summary.totalSize;
if (key == _countedStatsKey && _countedStats != null) {
return _countedStats!;
}

return _AccountStats(
final permanentByYear = await driveDao.permanentDataByYear(driveIds);
final fileCount = await driveDao.permanentFileCount(driveIds);

final stats = _AccountStats(
driveCount: drives.length,
fileCount: fileCount,
totalSize: totalSize,
permanentByYear: permanentByYear,
driveIds: driveIds,
);
}
_countedStats = stats;
_countedStatsKey = key;

String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
return stats;
}

Widget _buildIdentityHeader(BuildContext context, ProfileLoggedIn state) {
Expand Down Expand Up @@ -1522,12 +1677,25 @@ Color getWalletIndicatorColor(User user) {

class _AccountStats {
final int driveCount;

/// What is stored for good, by the year it was uploaded. See
/// [DriveDao.permanentDataByYear].
final Map<int, PermanentData> permanentByYear;

/// The wallet's own drives that were counted.
final List<String> driveIds;

/// Files with at least one version stored for good. See
/// [DriveDao.permanentFileCount].
final int fileCount;
final int totalSize;

int get permanentBytes =>
permanentByYear.values.fold(0, (sum, year) => sum + year.bytes);

_AccountStats({
required this.driveCount,
required this.fileCount,
required this.totalSize,
this.fileCount = 0,
this.permanentByYear = const {},
this.driveIds = const [],
});
}
Loading
Loading