diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cf3ae33c..2241d21f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -622,33 +622,6 @@ "disconnected": "Disconnected", "@disconnected": {}, - "consensusInSync": "{inSync} / {total} Guardians in sync · new session {age}", - "@consensusInSync": { - "placeholders": { - "inSync": { - "type": "int" - }, - "total": { - "type": "int" - }, - "age": { - "type": "String" - } - } - }, - - "consensusStalled": "No new session for {duration}", - "@consensusStalled": { - "placeholders": { - "duration": { - "type": "String" - } - } - }, - - "consensusWaiting": "Waiting for guardians to report their session…", - "@consensusWaiting": {}, - "guardianSessionCount": "Session {count}", "@guardianSessionCount": { "placeholders": { @@ -688,6 +661,63 @@ } }, + "guardianStateInSync": "In sync", + "@guardianStateInSync": {}, + + "guardianStateBehind": "Behind", + "@guardianStateBehind": {}, + + "guardianStateUnknown": "No report", + "@guardianStateUnknown": {}, + + "guardianStateInSyncDetail": "Keeping up with the other guardians", + "@guardianStateInSyncDetail": {}, + + "guardianStateBehindDetail": "Falling behind the other guardians", + "@guardianStateBehindDetail": {}, + + "guardianStateDownDetail": "This wallet cannot reach the guardian", + "@guardianStateDownDetail": {}, + + "guardianStateUnknownDetail": "Reachable, but has not reported its session yet", + "@guardianStateUnknownDetail": {}, + + "guardianConsensusSection": "Consensus", + "@guardianConsensusSection": {}, + + "guardianConnectionSection": "Connection", + "@guardianConnectionSection": {}, + + "guardianSessionLabel": "Session", + "@guardianSessionLabel": {}, + + "guardianFirstSeenLabel": "First seen", + "@guardianFirstSeenLabel": {}, + + "guardianSessionsBehindLabel": "Sessions behind", + "@guardianSessionsBehindLabel": {}, + + "guardianStatusLabel": "Status", + "@guardianStatusLabel": {}, + + "guardianOnline": "Online", + "@guardianOnline": {}, + + "guardianConnectionTypeLabel": "Connection type", + "@guardianConnectionTypeLabel": {}, + + "guardianVersionLabel": "Version", + "@guardianVersionLabel": {}, + + "guardianUrlLabel": "URL", + "@guardianUrlLabel": {}, + + "guardianOpenDashboard": "Open Guardian Dashboard", + "@guardianOpenDashboard": {}, + + "guardianOfflineLoginHint": "The dashboard is available while the guardian is online", + "@guardianOfflineLoginHint": {}, + "connectionDirect": "Direct", "@connectionDirect": {}, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ad691665..2af1d63d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -178,14 +178,30 @@ "selectARelay": "Seleccionar un Relé", "connected": "Conectado", "disconnected": "Desconectado", - "consensusInSync": "{inSync} / {total} Guardianes sincronizados · nueva sesión {age}", - "consensusStalled": "Sin nueva sesión desde hace {duration}", - "consensusWaiting": "Esperando a que los guardianes informen su sesión…", "guardianSessionCount": "Sesión {count}", "guardianSessionAgeJustNow": "ahora mismo", "guardianSessionAgeMinutes": "hace {count} min", "guardianSessionAgeHours": "hace {count} h", "guardianSessionAgeDays": "hace {count} d", + "guardianStateInSync": "Sincronizado", + "guardianStateBehind": "Atrasado", + "guardianStateUnknown": "Sin informe", + "guardianStateInSyncDetail": "Al día con los demás guardianes", + "guardianStateBehindDetail": "Quedándose atrás de los demás guardianes", + "guardianStateDownDetail": "Esta billetera no puede contactar al guardián", + "guardianStateUnknownDetail": "Accesible, pero aún no ha informado su sesión", + "guardianConsensusSection": "Consenso", + "guardianConnectionSection": "Conexión", + "guardianSessionLabel": "Sesión", + "guardianFirstSeenLabel": "Visto por primera vez", + "guardianSessionsBehindLabel": "Sesiones de retraso", + "guardianStatusLabel": "Estado", + "guardianOnline": "En línea", + "guardianConnectionTypeLabel": "Tipo de conexión", + "guardianVersionLabel": "Versión", + "guardianUrlLabel": "URL", + "guardianOpenDashboard": "Abrir panel del guardián", + "guardianOfflineLoginHint": "El panel está disponible mientras el guardián esté en línea", "connectionDirect": "Directo", "connectionRelay": "Retransmitido", "connectionMixed": "Mixto", diff --git a/lib/screens/federation_info_screen.dart b/lib/screens/federation_info_screen.dart index fee93b9c..f7ec7273 100644 --- a/lib/screens/federation_info_screen.dart +++ b/lib/screens/federation_info_screen.dart @@ -1,8 +1,7 @@ import 'dart:async'; import 'package:ecashapp/extensions/build_context_l10n.dart'; -import 'package:ecashapp/generated/app_localizations.dart'; -import 'package:ecashapp/screens/guardian_dashboard.dart'; +import 'package:ecashapp/screens/guardian_screen.dart'; import 'package:ecashapp/widgets/federation_utxo_list.dart'; import 'package:ecashapp/lib.dart'; import 'package:ecashapp/multimint.dart'; @@ -13,153 +12,9 @@ import 'package:ecashapp/widgets/federation_expiry_banner.dart'; import 'package:ecashapp/widgets/gateways.dart'; import 'package:ecashapp/widgets/leave_federation_dialog.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; -import 'package:qr_flutter/qr_flutter.dart'; enum _InfoSection { guardians, utxos, gateways } -/// The session count a guardian last reported, labelled and grouped the way -/// the locale does. Null while the guardian has never answered. -String? formatGuardianSessionCount( - AppLocalizations l10n, - GuardianSessionStatus? status, -) { - final sessionCount = status?.sessionCount; - if (sessionCount == null) return null; - return l10n.guardianSessionCount( - NumberFormat.decimalPattern(l10n.localeName).format(sessionCount.toInt()), - ); -} - -/// How long ago this wallet first saw the guardian report its current session -/// count, in the largest unit that fits. Null while the guardian has never -/// answered. -/// -/// The age is what tells a guardian that is keeping up from one that is stuck, -/// since a session outcome carries no time of its own. -String? formatGuardianSessionAge( - AppLocalizations l10n, - GuardianSessionStatus? status, { - DateTime? now, -}) { - final firstSeenAt = status?.firstSeenAt; - if (firstSeenAt == null) return null; - return formatSessionAge( - l10n, - (now ?? DateTime.now()).difference(_fromUnixSeconds(firstSeenAt)), - ); -} - -/// How long ago a session was first seen, abbreviated, in the largest unit -/// that fits. -String formatSessionAge(AppLocalizations l10n, Duration age) { - // Also covers a negative age, which means the clock was set back since. - if (age.inMinutes < 1) return l10n.guardianSessionAgeJustNow; - if (age.inDays > 0) return l10n.guardianSessionAgeDays(age.inDays); - if (age.inHours > 0) return l10n.guardianSessionAgeHours(age.inHours); - return l10n.guardianSessionAgeMinutes(age.inMinutes); -} - -String _formatLongDuration(AppLocalizations l10n, Duration duration) { - if (duration.inDays > 0) return l10n.federationExpiryInDays(duration.inDays); - if (duration.inHours > 0) { - return l10n.federationExpiryInHours(duration.inHours); - } - return l10n.federationExpiryInMinutes(duration.inMinutes); -} - -DateTime _fromUnixSeconds(BigInt seconds) => - DateTime.fromMillisecondsSinceEpoch(seconds.toInt() * 1000); - -/// How long the most advanced session may go without a successor before -/// consensus is reported as stalled. A session lasts about three minutes; -/// the margin is for federations configured with longer ones and for the -/// half minute between two rounds of requests. -const consensusStalledAfter = Duration(minutes: 10); - -/// Where a guardian stands relative to the others. -enum GuardianSyncState { - /// Answered, and is on the most advanced session or the one before it. - inSync, - - /// Answered, but trails the most advanced guardian by more than the spread - /// between healthy guardians explains. - behind, - - /// Did not answer the latest round of requests, so nothing is known about - /// where it is now. - unknown, -} - -/// What the guardians' sessions say about consensus as a whole. -class ConsensusSummary { - const ConsensusSummary({required this.states, required this.tipAge}); - - /// One per guardian, those in sync first and those that did not answer - /// last, so the bar fills towards its threshold marker like the one for - /// connectivity does. - final List states; - - /// How long ago this wallet first saw the most advanced session. Null while - /// no guardian has answered. - final Duration? tipAge; - - int get inSync => - states.where((state) => state == GuardianSyncState.inSync).length; - - /// Whether the guardians, however well they agree, have stopped producing - /// sessions. - bool get isStalled { - final tipAge = this.tipAge; - return tipAge != null && tipAge >= consensusStalledAfter; - } -} - -ConsensusSummary summarizeConsensus( - Iterable guardians, { - DateTime? now, -}) { - final states = [ - for (final guardian in guardians) - if (guardian == null || !guardian.fresh || guardian.sessionCount == null) - GuardianSyncState.unknown - else if (guardian.isBehind) - GuardianSyncState.behind - else - GuardianSyncState.inSync, - ]..sort((a, b) => a.index.compareTo(b.index)); - - // The most advanced session, and the earliest any guardian was seen on it: - // that is when the federation last moved, as far as this wallet can tell. - BigInt? tip; - BigInt? tipFirstSeenAt; - for (final guardian in guardians) { - final count = guardian?.sessionCount; - final firstSeenAt = guardian?.firstSeenAt; - if (guardian == null || !guardian.fresh) continue; - if (count == null || firstSeenAt == null) continue; - if (tip == null || count > tip) { - tip = count; - tipFirstSeenAt = firstSeenAt; - } else if (count == tip && firstSeenAt < tipFirstSeenAt!) { - tipFirstSeenAt = firstSeenAt; - } - } - - final Duration? tipAge; - if (tipFirstSeenAt == null) { - tipAge = null; - } else { - final age = (now ?? DateTime.now()).difference( - _fromUnixSeconds(tipFirstSeenAt), - ); - tipAge = age.isNegative ? Duration.zero : age; - } - - return ConsensusSummary(states: states, tipAge: tipAge); -} - class FederationInfoScreen extends StatefulWidget { /// The federation to display. For joinable previews this can be null: the /// screen then downloads the metadata itself (from [inviteCode]) and shows a @@ -198,12 +53,16 @@ class _FederationInfoScreenState extends State { StreamSubscription>? _peerUpdates; StreamSubscription? _metaUpdates; StreamSubscription>? _sessionUpdates; - List? _peers; + + /// Notifiers rather than plain fields so an open [GuardianScreen] follows + /// the same updates as this screen. + final ValueNotifier?> _peers = ValueNotifier(null); /// Each guardian's consensus progress, by peer id. Replaced wholesale every /// time the guardians are asked again, which is also what keeps the ages on /// the rows current. - Map _sessions = {}; + final ValueNotifier> _sessions = + ValueNotifier(const {}); _InfoSection _selectedSection = _InfoSection.guardians; bool _isJoining = false; @@ -266,6 +125,8 @@ class _FederationInfoScreenState extends State { _peerUpdates?.cancel(); _metaUpdates?.cancel(); _sessionUpdates?.cancel(); + _peers.dispose(); + _sessions.dispose(); super.dispose(); } @@ -339,7 +200,7 @@ class _FederationInfoScreenState extends State { if (!mounted) return; setState(() { - _peers = event; + _peers.value = event; _animatedPercent = totalCount > 0 ? onlineCount / totalCount : 0.0; }); }); @@ -356,7 +217,7 @@ class _FederationInfoScreenState extends State { (List event) { if (!mounted) return; setState(() { - _sessions = {for (final status in event) status.peerId: status}; + _sessions.value = {for (final status in event) status.peerId: status}; }); }, onError: (Object e) { @@ -373,111 +234,6 @@ class _FederationInfoScreenState extends State { onLeaveFederation: widget.onLeaveFederation, ); - // --- Guardian dashboard login logic --- - - Future _onGuardianTapped(PeerStatus peer) async { - final passwordController = TextEditingController(); - - await showDialog( - context: context, - builder: (dialogContext) { - bool isVerifying = false; - String? errorText; - - return StatefulBuilder( - builder: (sbContext, setState) { - Future submit() async { - final password = passwordController.text; - if (password.isEmpty || isVerifying) return; - setState(() { - isVerifying = true; - errorText = null; - }); - - try { - final ok = await guardianLogin( - federationId: _fed!.federationId, - peer: peer.peerId, - password: password, - ); - if (!sbContext.mounted) return; - if (!ok) { - setState(() { - isVerifying = false; - errorText = sbContext.l10n.guardianInvalidPassword; - }); - return; - } - Navigator.of(dialogContext).pop(); - if (!mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: - (_) => GuardianDashboardScreen( - fed: _fed!, - peer: peer, - password: password, - ), - ), - ); - } catch (e) { - AppLogger.instance.error("Guardian login failed: $e"); - if (!sbContext.mounted) return; - setState(() { - isVerifying = false; - errorText = sbContext.l10n.guardianLoginFailed; - }); - } - } - - return AlertDialog( - title: Text(sbContext.l10n.guardianDashboardTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(sbContext.l10n.guardianLoginPrompt(peer.name)), - const SizedBox(height: 12), - TextField( - controller: passwordController, - obscureText: true, - autofocus: true, - enabled: !isVerifying, - decoration: InputDecoration( - labelText: sbContext.l10n.guardianPasswordLabel, - errorText: errorText, - ), - onSubmitted: (_) => submit(), - ), - ], - ), - actions: [ - TextButton( - onPressed: - isVerifying - ? null - : () => Navigator.of(dialogContext).pop(), - child: Text(sbContext.l10n.cancel), - ), - TextButton( - onPressed: isVerifying ? null : submit, - child: - isVerifying - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(sbContext.l10n.logIn), - ), - ], - ); - }, - ); - }, - ); - } - // --- Join federation logic --- Future _onJoinPressed(bool recover) async { @@ -659,99 +415,8 @@ class _FederationInfoScreenState extends State { ); } - /// Whether the guardians are working together, as a twin of the bar above: - /// one segment per guardian, and the same marker for how many of them - /// consensus needs. - /// - /// Agreement alone would not do. A federation whose guardians all sit on the - /// same session while producing no new ones agrees perfectly, so once the - /// most advanced session has gone [consensusStalledAfter] without a - /// successor the bar reports that instead, whatever the guardians agree on. - Widget _buildConsensusBar({ - required ThemeData theme, - required int threshold, - }) { - final peers = _peers; - if (peers == null || peers.isEmpty || _sessions.isEmpty) { - return const SizedBox.shrink(); - } - - final l10n = context.l10n; - final summary = summarizeConsensus( - peers.map((peer) => _sessions[peer.peerId]), - ); - final tipAge = summary.tipAge; - - final Color color; - final String label; - if (tipAge == null) { - color = Colors.grey; - label = l10n.consensusWaiting; - } else if (summary.isStalled) { - color = Colors.red; - label = l10n.consensusStalled(_formatLongDuration(l10n, tipAge)); - } else { - color = - summary.inSync == peers.length - ? Colors.green - : summary.inSync >= threshold - ? Colors.amber - : Colors.red; - label = l10n.consensusInSync( - summary.inSync, - peers.length, - formatSessionAge(l10n, tipAge), - ); - } - - // The segments keep their own colours rather than taking the bar's, or a - // guardian that is behind would look the same as the ones keeping up. - Color segmentColor(GuardianSyncState state) => switch (state) { - GuardianSyncState.inSync => summary.isStalled ? Colors.red : Colors.green, - GuardianSyncState.behind => Colors.amber, - GuardianSyncState.unknown => theme.colorScheme.surfaceContainerHighest - .withValues(alpha: 0.3), - }; - - // The lock under the bar above hangs below its own box, so the gap has to - // clear it. - return Padding( - padding: const EdgeInsets.only(top: 16), - child: _buildThresholdBar( - theme: theme, - label: label, - color: color, - totalCount: peers.length, - threshold: threshold, - fill: _consensusSegments(summary, segmentColor), - ), - ); - } - - Widget _consensusSegments( - ConsensusSummary summary, - Color Function(GuardianSyncState) segmentColor, - ) { - return SizedBox( - height: 10, - child: Row( - children: [ - for (final (index, state) in summary.states.indexed) - Expanded( - child: AnimatedContainer( - duration: const Duration(milliseconds: 400), - margin: EdgeInsets.only(left: index == 0 ? 0 : 2), - color: segmentColor(state), - ), - ), - ], - ), - ); - } - - /// The frame the guardian bars share: a label, a bordered bar around [fill], - /// and a marker with a lock under it at the share of guardians that make up - /// the threshold. + /// A label, a bordered bar around [fill], and a marker with a lock under it + /// at the share of guardians that make up the threshold. Widget _buildThresholdBar({ required ThemeData theme, required String label, @@ -897,210 +562,102 @@ class _FederationInfoScreenState extends State { ); } - Widget _buildGuardianList(bool isFederationOnline) { - if (_peers == null || _peers!.isEmpty) { + Widget _buildGuardianList() { + final peers = _peers.value; + if (peers == null || peers.isEmpty) { return Center(child: Text(context.l10n.loading)); } - return ListView.builder( - padding: const EdgeInsets.only(top: 8), - itemCount: _peers!.length, - itemBuilder: (context, index) { - final peer = _peers![index]; - final isOnline = peer.online; + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: peers.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) => _buildGuardianRow(peers[index]), + ); + } - final theme = Theme.of(context); - final sessionColumn = _sessionColumn(theme, _sessions[peer.peerId]); - return ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - onTap: - !widget.joinable && isOnline - ? () => _onGuardianTapped(peer) - : null, - leading: Icon( - Icons.circle, - color: isOnline ? Colors.green : Colors.red, - size: 12, - ), - title: Row( + /// One guardian at a glance: whether it can be reached (the dot on the + /// avatar), its place in consensus (the pill), and the session it last + /// reported. Everything else is on the [GuardianScreen] it opens. + Widget _buildGuardianRow(PeerStatus peer) { + final theme = Theme.of(context); + final l10n = context.l10n; + final session = _sessions.value[peer.peerId]; + final state = guardianSyncState(online: peer.online, session: session); + final count = formatGuardianSessionCount(l10n, session); + final age = formatGuardianSessionAge(l10n, session); + // Faded until the guardian has answered this round: what is on disk says + // where it was, not where it is. + final alpha = session?.fresh == true ? 1.0 : 0.5; + + return Material( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _openGuardian(peer), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( children: [ - Expanded(child: Text(peer.name, overflow: TextOverflow.ellipsis)), - if (isOnline) _connectivityBadge(theme, peer.connectivity), - // Centred in what is left of the row, which puts it halfway - // between the badge and the invite code buttons. - if (isOnline || sessionColumn != null) - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Center(child: sessionColumn), - ), - ), - ], - ), - subtitle: - isOnline - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.versionLabel(peer.version ?? ''), - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey, - ), + GuardianAvatar(name: peer.name, online: peer.online), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + peer.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, ), + ), + if (count != null && age != null) ...[ + const SizedBox(height: 2), Text( - peer.url, + '$count · $age', // i18n-ignore + maxLines: 1, + overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey, + color: Colors.grey.withValues(alpha: alpha), ), ), ], - ) - : Text(context.l10n.disconnected), - trailing: - !widget.joinable && isFederationOnline - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: context.l10n.copyInviteCode, - icon: const Icon(Icons.copy, size: 20), - onPressed: () async { - try { - final inviteCode = await getInviteCode( - federationId: _fed!.federationId, - peer: peer.peerId, - ); - if (!context.mounted) return; - await Clipboard.setData( - ClipboardData(text: inviteCode), - ); - ToastService().show( - message: context.l10n.inviteCodeCopied(peer.name), - duration: const Duration(seconds: 5), - onTap: () {}, - icon: Icon(Icons.check), - ); - } catch (e) { - AppLogger.instance.error( - "Error getting invite code: $e", - ); - ToastService().show( - message: context.l10n.couldNotGetInviteCode, - duration: const Duration(seconds: 5), - onTap: () {}, - icon: Icon(Icons.error), - ); - } - }, - ), - IconButton( - tooltip: context.l10n.viewInviteCode, - icon: const Icon(Icons.qr_code, size: 20), - onPressed: () async { - try { - final inviteCode = await getInviteCode( - federationId: _fed!.federationId, - peer: peer.peerId, - ); - if (!context.mounted) return; - showDialog( - context: context, - builder: - (context) => AlertDialog( - title: Center( - child: Text( - context.l10n.inviteCode, - textAlign: TextAlign.center, - ), - ), - content: AspectRatio( - aspectRatio: 1, - child: GestureDetector( - onTap: () { - showDialog( - context: context, - builder: - (_) => Dialog( - backgroundColor: - Colors.transparent, - insetPadding: EdgeInsets.zero, - child: GestureDetector( - onTap: - () => - Navigator.of( - context, - rootNavigator: - true, - ).pop(), - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.black - .withValues( - alpha: 0.9, - ), - child: Center( - child: QrImageView( - data: inviteCode, - version: - QrVersions.auto, - backgroundColor: - Colors.white, - size: - MediaQuery.of( - context, - ).size.width * - 0.9, - ), - ), - ), - ), - ), - ); - }, - child: QrImageView( - data: inviteCode, - version: QrVersions.auto, - backgroundColor: Colors.white, - ), - ), - ), - actions: [ - TextButton( - onPressed: - () => Navigator.of(context).pop(), - child: Text(context.l10n.close), - ), - ], - ), - ); - } catch (e) { - AppLogger.instance.error( - "Error getting invite code: $e", - ); - ToastService().show( - message: context.l10n.couldNotGetInviteCode, - duration: const Duration(seconds: 5), - onTap: () {}, - icon: Icon(Icons.error), - ); - } - }, - ), - ], - ) - : null, - ); - }, + ], + ), + ), + const SizedBox(width: 8), + GuardianPill.sync(l10n, theme, state), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, color: Colors.grey), + ], + ), + ), + ), + ); + } + + void _openGuardian(PeerStatus peer) { + Navigator.of(context).push( + MaterialPageRoute( + builder: + (_) => GuardianScreen( + fed: _fed!, + peer: peer, + peers: _peers, + sessions: _sessions, + joinable: widget.joinable, + ), + ), ); } Widget _buildSelectedContent(bool isFederationOnline) { switch (_selectedSection) { case _InfoSection.guardians: - return _buildGuardianList(isFederationOnline); + return _buildGuardianList(); case _InfoSection.utxos: return FederationUtxoList( invite: widget.joinable ? widget.inviteCode : null, @@ -1223,9 +780,9 @@ class _FederationInfoScreenState extends State { return _buildLoadingOrError(theme); } - final totalGuardians = _peers?.length ?? 0; + final totalGuardians = _peers.value?.length ?? 0; final thresh = threshold(totalGuardians); - final onlineGuardians = _peers?.where((p) => p.online).toList() ?? []; + final onlineGuardians = _peers.value?.where((p) => p.online).toList() ?? []; final isFederationOnline = totalGuardians > 0 && onlineGuardians.length >= thresh; @@ -1364,7 +921,6 @@ class _FederationInfoScreenState extends State { totalCount: totalGuardians, threshold: thresh, ), - _buildConsensusBar(theme: theme, threshold: thresh), const SizedBox(height: 16), _buildSectionChips(theme), const SizedBox(height: 8), @@ -1385,86 +941,4 @@ class _FederationInfoScreenState extends State { ), ); } - - /// A guardian's session count with its age underneath, or null while the - /// guardian has never answered. - /// - /// The column is narrow on a phone. The count is left to wrap there, which - /// puts the number under the word; the age shrinks instead, since half an - /// age is worse than a small one. - Widget? _sessionColumn(ThemeData theme, GuardianSessionStatus? session) { - final l10n = context.l10n; - final count = formatGuardianSessionCount(l10n, session); - final age = formatGuardianSessionAge(l10n, session); - if (session == null || count == null || age == null) return null; - - // Faded until the guardian has answered this round: what is on disk says - // where it was, not where it is. - final alpha = session.fresh ? 1.0 : 0.5; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - count, - maxLines: 2, - textAlign: TextAlign.center, - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: alpha), - fontWeight: FontWeight.w600, - ), - ), - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - age, - maxLines: 1, - softWrap: false, - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey.withValues(alpha: alpha), - ), - ), - ), - ], - ); - } - - Widget _connectivityBadge(ThemeData theme, PeerConnectivity c) { - final color = switch (c) { - PeerConnectivity.direct => Colors.green, - PeerConnectivity.relay => Colors.amber, - PeerConnectivity.mixed => Colors.teal, - PeerConnectivity.tor => Colors.deepPurple, - PeerConnectivity.unknown => Colors.grey, - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.5), width: 1), - ), - child: Text( - _connectivityLabel(context, c), - style: theme.textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w600, - ), - ), - ); - } - - String _connectivityLabel(BuildContext context, PeerConnectivity c) { - switch (c) { - case PeerConnectivity.direct: - return context.l10n.connectionDirect; - case PeerConnectivity.relay: - return context.l10n.connectionRelay; - case PeerConnectivity.mixed: - return context.l10n.connectionMixed; - case PeerConnectivity.tor: - return context.l10n.connectionTor; - case PeerConnectivity.unknown: - return context.l10n.connectionUnknown; - } - } } diff --git a/lib/screens/guardian_screen.dart b/lib/screens/guardian_screen.dart new file mode 100644 index 00000000..0f4dce55 --- /dev/null +++ b/lib/screens/guardian_screen.dart @@ -0,0 +1,850 @@ +import 'package:ecashapp/extensions/build_context_l10n.dart'; +import 'package:ecashapp/generated/app_localizations.dart'; +import 'package:ecashapp/lib.dart'; +import 'package:ecashapp/multimint.dart'; +import 'package:ecashapp/screens/guardian_dashboard.dart'; +import 'package:ecashapp/toast.dart'; +import 'package:ecashapp/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +const _cardColor = Color(0xFF1A1A1A); + +String _formatSessionNumber(AppLocalizations l10n, BigInt sessionCount) => + NumberFormat.decimalPattern(l10n.localeName).format(sessionCount.toInt()); + +/// The session count a guardian last reported, labelled and grouped the way +/// the locale does. Null while the guardian has never answered. +String? formatGuardianSessionCount( + AppLocalizations l10n, + GuardianSessionStatus? status, +) { + final sessionCount = status?.sessionCount; + if (sessionCount == null) return null; + return l10n.guardianSessionCount(_formatSessionNumber(l10n, sessionCount)); +} + +/// How long ago this wallet first saw the guardian report its current session +/// count, in the largest unit that fits. Null while the guardian has never +/// answered. +/// +/// The age is what tells a guardian that is keeping up from one that is stuck, +/// since a session outcome carries no time of its own. +String? formatGuardianSessionAge( + AppLocalizations l10n, + GuardianSessionStatus? status, { + DateTime? now, +}) { + final firstSeenAt = status?.firstSeenAt; + if (firstSeenAt == null) return null; + return formatSessionAge( + l10n, + (now ?? DateTime.now()).difference(_fromUnixSeconds(firstSeenAt)), + ); +} + +/// How long ago a session was first seen, abbreviated, in the largest unit +/// that fits. +String formatSessionAge(AppLocalizations l10n, Duration age) { + // Also covers a negative age, which means the clock was set back since. + if (age.inMinutes < 1) return l10n.guardianSessionAgeJustNow; + if (age.inDays > 0) return l10n.guardianSessionAgeDays(age.inDays); + if (age.inHours > 0) return l10n.guardianSessionAgeHours(age.inHours); + return l10n.guardianSessionAgeMinutes(age.inMinutes); +} + +DateTime _fromUnixSeconds(BigInt seconds) => + DateTime.fromMillisecondsSinceEpoch(seconds.toInt() * 1000); + +/// Where a guardian stands in consensus relative to the others. +enum GuardianSyncState { + /// Answered, and is on the most advanced session or the one before it. + inSync, + + /// Answered, but trails the most advanced guardian by more than the spread + /// between healthy guardians explains. + behind, + + /// Cannot be reached at all. + down, + + /// Reachable, but has not reported its session this round, so nothing is + /// known about where it is now. + unknown, +} + +GuardianSyncState guardianSyncState({ + required bool online, + required GuardianSessionStatus? session, +}) { + if (!online) return GuardianSyncState.down; + if (session == null || !session.fresh || session.sessionCount == null) { + return GuardianSyncState.unknown; + } + return session.isBehind ? GuardianSyncState.behind : GuardianSyncState.inSync; +} + +Color guardianSyncColor(ThemeData theme, GuardianSyncState state) => + switch (state) { + GuardianSyncState.inSync => Colors.green, + GuardianSyncState.behind => Colors.amber, + GuardianSyncState.down => Colors.red, + GuardianSyncState.unknown => Colors.grey, + }; + +String guardianSyncLabel(AppLocalizations l10n, GuardianSyncState state) => + switch (state) { + GuardianSyncState.inSync => l10n.guardianStateInSync, + GuardianSyncState.behind => l10n.guardianStateBehind, + GuardianSyncState.down => l10n.offline, + GuardianSyncState.unknown => l10n.guardianStateUnknown, + }; + +String _guardianSyncDetail(AppLocalizations l10n, GuardianSyncState state) => + switch (state) { + GuardianSyncState.inSync => l10n.guardianStateInSyncDetail, + GuardianSyncState.behind => l10n.guardianStateBehindDetail, + GuardianSyncState.down => l10n.guardianStateDownDetail, + GuardianSyncState.unknown => l10n.guardianStateUnknownDetail, + }; + +/// A rounded tile with the guardian's initial, and a dot in the corner for +/// whether this wallet can reach it. +class GuardianAvatar extends StatelessWidget { + final String name; + final bool online; + final double size; + + const GuardianAvatar({ + super.key, + required this.name, + required this.online, + this.size = 40, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final trimmed = name.trim(); + final initial = + trimmed.isEmpty ? '?' : trimmed.characters.first.toUpperCase(); + final dotSize = size * 0.3; + + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(size * 0.25), + ), + child: Text( + initial, + style: TextStyle( + color: theme.colorScheme.primary, + fontSize: size * 0.42, + fontWeight: FontWeight.bold, + ), + ), + ), + Positioned( + right: -dotSize * 0.2, + bottom: -dotSize * 0.2, + child: AnimatedContainer( + duration: const Duration(milliseconds: 400), + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: online ? Colors.green : Colors.red, + shape: BoxShape.circle, + border: Border.all( + color: theme.scaffoldBackgroundColor, + width: 2, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// A small coloured pill, used for a guardian's place in consensus and for +/// how this wallet reaches it. +class GuardianPill extends StatelessWidget { + final String label; + final Color color; + + const GuardianPill({super.key, required this.label, required this.color}); + + GuardianPill.sync( + AppLocalizations l10n, + ThemeData theme, + GuardianSyncState state, { + Key? key, + }) : this( + key: key, + label: guardianSyncLabel(l10n, state), + color: guardianSyncColor(theme, state), + ); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 400), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +/// Everything this wallet knows about one guardian, plus its invite code and +/// the way into its admin dashboard. +/// +/// Kept current from the peer and session streams the federation info screen +/// already follows, so nothing extra is asked of the guardians while it is +/// open. +class GuardianScreen extends StatefulWidget { + final FederationSelector fed; + + /// The guardian as it was when tapped; shown until [peers] has it. + final PeerStatus peer; + final ValueListenable?> peers; + final ValueListenable> sessions; + + /// A federation that has not been joined yet: there is no invite code to + /// hand out and no dashboard to log in to. + final bool joinable; + + const GuardianScreen({ + super.key, + required this.fed, + required this.peer, + required this.peers, + required this.sessions, + required this.joinable, + }); + + @override + State createState() => _GuardianScreenState(); +} + +class _GuardianScreenState extends State { + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([widget.peers, widget.sessions]), + builder: (context, _) { + final peers = widget.peers.value ?? const []; + final peer = peers.firstWhere( + (p) => p.peerId == widget.peer.peerId, + orElse: () => widget.peer, + ); + final session = widget.sessions.value[peer.peerId]; + final onlineCount = peers.where((p) => p.online).length; + final isFederationOnline = + peers.isNotEmpty && onlineCount >= threshold(peers.length); + return _buildScaffold(peer, session, isFederationOnline); + }, + ); + } + + Widget _buildScaffold( + PeerStatus peer, + GuardianSessionStatus? session, + bool isFederationOnline, + ) { + final theme = Theme.of(context); + final l10n = context.l10n; + final state = guardianSyncState(online: peer.online, session: session); + + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text(peer.name, overflow: TextOverflow.ellipsis), + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildHeader(theme, peer, state), + const SizedBox(height: 28), + _sectionHeader(theme, l10n.guardianConsensusSection), + const SizedBox(height: 8), + _buildConsensusCard(theme, state, session), + const SizedBox(height: 24), + _sectionHeader(theme, l10n.guardianConnectionSection), + const SizedBox(height: 8), + _buildConnectionCard(theme, peer), + if (!widget.joinable && isFederationOnline) ...[ + const SizedBox(height: 24), + _sectionHeader(theme, l10n.inviteCode), + const SizedBox(height: 8), + _actionTile( + theme: theme, + icon: Icons.copy, + title: l10n.copyInviteCode, + onTap: () => _copyInviteCode(peer), + ), + const SizedBox(height: 8), + _actionTile( + theme: theme, + icon: Icons.qr_code, + title: l10n.viewInviteCode, + onTap: () => _showInviteCode(peer), + ), + ], + ], + ), + ), + if (!widget.joinable) _buildDashboardButton(theme, peer), + ], + ), + ), + ); + } + + Widget _buildHeader( + ThemeData theme, + PeerStatus peer, + GuardianSyncState state, + ) { + final l10n = context.l10n; + return Column( + children: [ + const SizedBox(height: 8), + GuardianAvatar(name: peer.name, online: peer.online, size: 76), + const SizedBox(height: 16), + Text( + peer.name, + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (peer.version != null) ...[ + const SizedBox(height: 4), + Text( + l10n.versionLabel(peer.version!), + style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey), + ), + ], + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + GuardianPill.sync(l10n, theme, state), + if (peer.online) + GuardianPill( + label: _connectivityLabel(peer.connectivity), + color: _connectivityColor(peer.connectivity), + ), + ], + ), + ], + ); + } + + Widget _buildConsensusCard( + ThemeData theme, + GuardianSyncState state, + GuardianSessionStatus? session, + ) { + final l10n = context.l10n; + final color = guardianSyncColor(theme, state); + final sessionCount = session?.sessionCount; + final age = formatGuardianSessionAge(l10n, session); + // Faded until the guardian has answered this round: what is on disk says + // where it was, not where it is. + final stale = session != null && !session.fresh; + + return _card( + children: [ + Row( + children: [ + Icon(Icons.circle, size: 10, color: color), + const SizedBox(width: 10), + Expanded( + child: Text( + _guardianSyncDetail(l10n, state), + style: theme.textTheme.bodyMedium?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (sessionCount != null) ...[ + const SizedBox(height: 8), + _statRow( + theme, + l10n.guardianSessionLabel, + _formatSessionNumber(l10n, sessionCount), + faded: stale, + ), + ], + if (age != null) + _statRow(theme, l10n.guardianFirstSeenLabel, age, faded: stale), + if (state == GuardianSyncState.behind) + _statRow( + theme, + l10n.guardianSessionsBehindLabel, + _formatSessionNumber(l10n, session!.sessionsBehind), + valueColor: color, + ), + ], + ); + } + + Widget _buildConnectionCard(ThemeData theme, PeerStatus peer) { + final l10n = context.l10n; + return _card( + children: [ + _statRow( + theme, + l10n.guardianStatusLabel, + peer.online ? l10n.guardianOnline : l10n.offline, + valueColor: peer.online ? Colors.green : Colors.red, + ), + if (peer.online) + _statRow( + theme, + l10n.guardianConnectionTypeLabel, + _connectivityLabel(peer.connectivity), + ), + if (peer.version != null) + _statRow(theme, l10n.guardianVersionLabel, peer.version!), + const SizedBox(height: 8), + Divider(height: 1, color: Colors.white.withValues(alpha: 0.08)), + const SizedBox(height: 8), + Text( + l10n.guardianUrlLabel, + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: SelectableText( + peer.url, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurface.withValues(alpha: 0.85), + ), + ), + ), + IconButton( + tooltip: l10n.copyTitle(l10n.guardianUrlLabel), + icon: const Icon(Icons.copy, size: 18), + visualDensity: VisualDensity.compact, + onPressed: () async { + await Clipboard.setData(ClipboardData(text: peer.url)); + if (!mounted) return; + ToastService().show( + message: context.l10n.copiedText( + context.l10n.guardianUrlLabel, + ), + duration: const Duration(seconds: 3), + onTap: () {}, + icon: const Icon(Icons.check), + ); + }, + ), + ], + ), + ], + ); + } + + Widget _buildDashboardButton(ThemeData theme, PeerStatus peer) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!peer.online) ...[ + Text( + context.l10n.guardianOfflineLoginHint, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 8), + ], + ElevatedButton.icon( + onPressed: peer.online ? () => _onLoginPressed(peer) : null, + icon: const Icon(Icons.admin_panel_settings_outlined), + label: Text(context.l10n.guardianOpenDashboard), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } + + // --- Building blocks --- + + Widget _sectionHeader(ThemeData theme, String title) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + title.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.grey, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + ), + ), + ); + } + + Widget _card({required List children}) { + return Container( + decoration: BoxDecoration( + color: _cardColor, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ); + } + + Widget _statRow( + ThemeData theme, + String label, + String value, { + Color? valueColor, + bool faded = false, + }) { + final alpha = faded ? 0.5 : 1.0; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + ), + const SizedBox(width: 12), + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + color: (valueColor ?? theme.colorScheme.onSurface).withValues( + alpha: alpha, + ), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + Widget _actionTile({ + required ThemeData theme, + required IconData icon, + required String title, + required VoidCallback onTap, + }) { + return Material( + color: _cardColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 20, color: theme.colorScheme.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const Icon(Icons.chevron_right, color: Colors.grey), + ], + ), + ), + ), + ); + } + + Color _connectivityColor(PeerConnectivity c) => switch (c) { + PeerConnectivity.direct => Colors.green, + PeerConnectivity.relay => Colors.amber, + PeerConnectivity.mixed => Colors.teal, + PeerConnectivity.tor => Colors.deepPurple, + PeerConnectivity.unknown => Colors.grey, + }; + + String _connectivityLabel(PeerConnectivity c) => switch (c) { + PeerConnectivity.direct => context.l10n.connectionDirect, + PeerConnectivity.relay => context.l10n.connectionRelay, + PeerConnectivity.mixed => context.l10n.connectionMixed, + PeerConnectivity.tor => context.l10n.connectionTor, + PeerConnectivity.unknown => context.l10n.connectionUnknown, + }; + + // --- Invite code --- + + Future _fetchInviteCode(PeerStatus peer) async { + try { + return await getInviteCode( + federationId: widget.fed.federationId, + peer: peer.peerId, + ); + } catch (e) { + AppLogger.instance.error("Error getting invite code: $e"); + if (mounted) { + ToastService().show( + message: context.l10n.couldNotGetInviteCode, + duration: const Duration(seconds: 5), + onTap: () {}, + icon: const Icon(Icons.error), + ); + } + return null; + } + } + + Future _copyInviteCode(PeerStatus peer) async { + final inviteCode = await _fetchInviteCode(peer); + if (inviteCode == null || !mounted) return; + await Clipboard.setData(ClipboardData(text: inviteCode)); + if (!mounted) return; + ToastService().show( + message: context.l10n.inviteCodeCopied(peer.name), + duration: const Duration(seconds: 5), + onTap: () {}, + icon: const Icon(Icons.check), + ); + } + + Future _showInviteCode(PeerStatus peer) async { + final inviteCode = await _fetchInviteCode(peer); + if (inviteCode == null || !mounted) return; + showDialog( + context: context, + builder: + (dialogContext) => AlertDialog( + title: Center( + child: Text( + dialogContext.l10n.inviteCode, + textAlign: TextAlign.center, + ), + ), + content: AspectRatio( + aspectRatio: 1, + child: GestureDetector( + onTap: () => _showFullscreenQr(dialogContext, inviteCode), + child: QrImageView( + data: inviteCode, + version: QrVersions.auto, + backgroundColor: Colors.white, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.close), + ), + ], + ), + ); + } + + void _showFullscreenQr(BuildContext context, String inviteCode) { + showDialog( + context: context, + builder: + (fullscreenContext) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.zero, + child: GestureDetector( + onTap: + () => + Navigator.of( + fullscreenContext, + rootNavigator: true, + ).pop(), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withValues(alpha: 0.9), + child: Center( + child: QrImageView( + data: inviteCode, + version: QrVersions.auto, + backgroundColor: Colors.white, + size: MediaQuery.of(fullscreenContext).size.width * 0.9, + ), + ), + ), + ), + ), + ); + } + + // --- Guardian dashboard login --- + + Future _onLoginPressed(PeerStatus peer) async { + final passwordController = TextEditingController(); + + await showDialog( + context: context, + builder: (dialogContext) { + bool isVerifying = false; + String? errorText; + + return StatefulBuilder( + builder: (sbContext, setState) { + Future submit() async { + final password = passwordController.text; + if (password.isEmpty || isVerifying) return; + setState(() { + isVerifying = true; + errorText = null; + }); + + try { + final ok = await guardianLogin( + federationId: widget.fed.federationId, + peer: peer.peerId, + password: password, + ); + if (!sbContext.mounted) return; + if (!ok) { + setState(() { + isVerifying = false; + errorText = sbContext.l10n.guardianInvalidPassword; + }); + return; + } + Navigator.of(dialogContext).pop(); + if (!mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: + (_) => GuardianDashboardScreen( + fed: widget.fed, + peer: peer, + password: password, + ), + ), + ); + } catch (e) { + AppLogger.instance.error("Guardian login failed: $e"); + if (!sbContext.mounted) return; + setState(() { + isVerifying = false; + errorText = sbContext.l10n.guardianLoginFailed; + }); + } + } + + return AlertDialog( + title: Text(sbContext.l10n.guardianDashboardTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(sbContext.l10n.guardianLoginPrompt(peer.name)), + const SizedBox(height: 12), + TextField( + controller: passwordController, + obscureText: true, + autofocus: true, + enabled: !isVerifying, + decoration: InputDecoration( + labelText: sbContext.l10n.guardianPasswordLabel, + errorText: errorText, + ), + onSubmitted: (_) => submit(), + ), + ], + ), + actions: [ + TextButton( + onPressed: + isVerifying + ? null + : () => Navigator.of(dialogContext).pop(), + child: Text(sbContext.l10n.cancel), + ), + TextButton( + onPressed: isVerifying ? null : submit, + child: + isVerifying + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(sbContext.l10n.logIn), + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/rust/ecashapp/Cargo.lock b/rust/ecashapp/Cargo.lock index e4420919..b035be7b 100644 --- a/rust/ecashapp/Cargo.lock +++ b/rust/ecashapp/Cargo.lock @@ -281,13 +281,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1300,7 +1300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -7393,6 +7393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -7407,6 +7408,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "0.1.2" diff --git a/test/federation_info_screen_test.dart b/test/federation_info_screen_test.dart deleted file mode 100644 index 76362bb6..00000000 --- a/test/federation_info_screen_test.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:ecashapp/generated/app_localizations.dart'; -import 'package:ecashapp/multimint.dart'; -import 'package:ecashapp/screens/federation_info_screen.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - final en = lookupAppLocalizations(const Locale('en')); - final es = lookupAppLocalizations(const Locale('es')); - final now = DateTime(2026, 9, 21, 12); - - GuardianSessionStatus seen(Duration ago, {int sessionCount = 48211}) { - final firstSeen = now.subtract(ago); - return GuardianSessionStatus( - peerId: 0, - sessionCount: BigInt.from(sessionCount), - firstSeenAt: BigInt.from(firstSeen.millisecondsSinceEpoch ~/ 1000), - fresh: true, - sessionsBehind: BigInt.zero, - isBehind: false, - ); - } - - final neverAnswered = GuardianSessionStatus( - peerId: 1, - fresh: false, - sessionsBehind: BigInt.zero, - isBehind: false, - ); - - group('formatGuardianSessionAge', () { - test('gives the age in the largest unit that fits', () { - String? at(Duration ago) => - formatGuardianSessionAge(en, seen(ago), now: now); - - expect(at(const Duration(days: 3, hours: 5)), '3 d ago'); - expect(at(const Duration(hours: 26)), '1 d ago'); - expect(at(const Duration(hours: 1, minutes: 59)), '1 h ago'); - expect(at(const Duration(minutes: 45)), '45 min ago'); - expect(at(const Duration(minutes: 1)), '1 min ago'); - expect( - formatGuardianSessionAge( - es, - seen(const Duration(minutes: 45)), - now: now, - ), - 'hace 45 min', - ); - }); - - test('reads under a minute, and a clock set back since, as just now', () { - expect( - formatGuardianSessionAge( - en, - seen(const Duration(seconds: 59)), - now: now, - ), - 'just now', - ); - expect( - formatGuardianSessionAge(en, seen(const Duration(hours: -2)), now: now), - 'just now', - ); - }); - - test('has nothing to say about a guardian that never answered', () { - expect(formatGuardianSessionAge(en, null, now: now), isNull); - expect(formatGuardianSessionAge(en, neverAnswered, now: now), isNull); - }); - }); - - group('summarizeConsensus', () { - GuardianSessionStatus guardian( - int sessionCount, - Duration ago, { - bool fresh = true, - bool isBehind = false, - }) { - return GuardianSessionStatus( - peerId: 0, - sessionCount: BigInt.from(sessionCount), - firstSeenAt: BigInt.from( - now.subtract(ago).millisecondsSinceEpoch ~/ 1000, - ), - fresh: fresh, - sessionsBehind: BigInt.zero, - isBehind: isBehind, - ); - } - - test('sorts guardians in sync first and unanswered ones last', () { - final summary = summarizeConsensus([ - null, - guardian(98, const Duration(hours: 2), isBehind: true), - guardian(100, const Duration(minutes: 1)), - guardian(100, const Duration(days: 1), fresh: false), - guardian(99, const Duration(minutes: 4)), - ], now: now); - - expect(summary.states, [ - GuardianSyncState.inSync, - GuardianSyncState.inSync, - GuardianSyncState.behind, - GuardianSyncState.unknown, - GuardianSyncState.unknown, - ]); - expect(summary.inSync, 2); - }); - - test('dates the most advanced session from when it was first seen', () { - final summary = summarizeConsensus([ - guardian(100, const Duration(minutes: 2)), - guardian(100, const Duration(minutes: 3)), - // Older, but on an earlier session, so it says nothing about the tip. - guardian(99, const Duration(hours: 5)), - // On the tip longest of all, but it did not answer this round. - guardian(100, const Duration(days: 1), fresh: false), - ], now: now); - - expect(summary.tipAge, const Duration(minutes: 3)); - expect(summary.isStalled, isFalse); - }); - - test('reports a stall however well the guardians agree', () { - final summary = summarizeConsensus([ - guardian(100, const Duration(minutes: 47)), - guardian(100, const Duration(minutes: 47)), - guardian(100, const Duration(minutes: 47)), - ], now: now); - - expect(summary.inSync, 3); - expect(summary.isStalled, isTrue); - expect( - summarizeConsensus([ - guardian(100, consensusStalledAfter - const Duration(seconds: 1)), - ], now: now).isStalled, - isFalse, - ); - }); - - test('knows nothing until a guardian has answered', () { - final summary = summarizeConsensus([ - null, - guardian(100, const Duration(hours: 1), fresh: false), - ], now: now); - - expect(summary.tipAge, isNull); - expect(summary.isStalled, isFalse); - expect(summary.inSync, 0); - }); - }); - - group('formatGuardianSessionCount', () { - test('labels the count and groups it the way the locale does', () { - final status = seen(Duration.zero, sessionCount: 1048211); - expect(formatGuardianSessionCount(en, status), 'Session 1,048,211'); - expect(formatGuardianSessionCount(es, status), 'Sesión 1.048.211'); - }); - - test('has nothing to say about a guardian that never answered', () { - expect(formatGuardianSessionCount(en, null), isNull); - expect(formatGuardianSessionCount(en, neverAnswered), isNull); - }); - }); -} diff --git a/test/guardian_screen_test.dart b/test/guardian_screen_test.dart new file mode 100644 index 00000000..756fd13c --- /dev/null +++ b/test/guardian_screen_test.dart @@ -0,0 +1,274 @@ +import 'package:ecashapp/generated/app_localizations.dart'; +import 'package:ecashapp/multimint.dart'; +import 'package:ecashapp/screens/guardian_screen.dart'; +import 'package:ecashapp/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Stands in for the Rust-backed selector, which the screen only needs once a +/// button calls into Rust. +class _FakeFederation implements FederationSelector { + @override + String get federationName => 'Test Federation'; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Widget harness(Widget child) { + return MaterialApp( + theme: cypherpunkNinjaTheme, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en'), Locale('es')], + home: child, + ); +} + +void main() { + final en = lookupAppLocalizations(const Locale('en')); + final es = lookupAppLocalizations(const Locale('es')); + final now = DateTime(2026, 9, 21, 12); + + GuardianSessionStatus seen(Duration ago, {int sessionCount = 48211}) { + final firstSeen = now.subtract(ago); + return GuardianSessionStatus( + peerId: 0, + sessionCount: BigInt.from(sessionCount), + firstSeenAt: BigInt.from(firstSeen.millisecondsSinceEpoch ~/ 1000), + fresh: true, + sessionsBehind: BigInt.zero, + isBehind: false, + ); + } + + final neverAnswered = GuardianSessionStatus( + peerId: 1, + fresh: false, + sessionsBehind: BigInt.zero, + isBehind: false, + ); + + group('formatGuardianSessionAge', () { + test('gives the age in the largest unit that fits', () { + String? at(Duration ago) => + formatGuardianSessionAge(en, seen(ago), now: now); + + expect(at(const Duration(days: 3, hours: 5)), '3 d ago'); + expect(at(const Duration(hours: 26)), '1 d ago'); + expect(at(const Duration(hours: 1, minutes: 59)), '1 h ago'); + expect(at(const Duration(minutes: 45)), '45 min ago'); + expect(at(const Duration(minutes: 1)), '1 min ago'); + expect( + formatGuardianSessionAge( + es, + seen(const Duration(minutes: 45)), + now: now, + ), + 'hace 45 min', + ); + }); + + test('reads under a minute, and a clock set back since, as just now', () { + expect( + formatGuardianSessionAge( + en, + seen(const Duration(seconds: 59)), + now: now, + ), + 'just now', + ); + expect( + formatGuardianSessionAge(en, seen(const Duration(hours: -2)), now: now), + 'just now', + ); + }); + + test('has nothing to say about a guardian that never answered', () { + expect(formatGuardianSessionAge(en, null, now: now), isNull); + expect(formatGuardianSessionAge(en, neverAnswered, now: now), isNull); + }); + }); + + group('guardianSyncState', () { + GuardianSessionStatus guardian({bool fresh = true, bool isBehind = false}) { + return GuardianSessionStatus( + peerId: 0, + sessionCount: BigInt.from(100), + firstSeenAt: BigInt.from(now.millisecondsSinceEpoch ~/ 1000), + fresh: fresh, + sessionsBehind: BigInt.zero, + isBehind: isBehind, + ); + } + + test('is in sync while on the most recent sessions', () { + expect( + guardianSyncState(online: true, session: guardian()), + GuardianSyncState.inSync, + ); + }); + + test('is behind when trailing the others', () { + expect( + guardianSyncState(online: true, session: guardian(isBehind: true)), + GuardianSyncState.behind, + ); + }); + + test('is down when unreachable, whatever it last reported', () { + expect( + guardianSyncState(online: false, session: guardian()), + GuardianSyncState.down, + ); + expect( + guardianSyncState(online: false, session: null), + GuardianSyncState.down, + ); + }); + + test('is unknown while reachable but not reporting this round', () { + expect( + guardianSyncState(online: true, session: null), + GuardianSyncState.unknown, + ); + expect( + guardianSyncState(online: true, session: guardian(fresh: false)), + GuardianSyncState.unknown, + ); + }); + }); + + group('formatGuardianSessionCount', () { + test('labels the count and groups it the way the locale does', () { + final status = seen(Duration.zero, sessionCount: 1048211); + expect(formatGuardianSessionCount(en, status), 'Session 1,048,211'); + expect(formatGuardianSessionCount(es, status), 'Sesión 1.048.211'); + }); + + test('has nothing to say about a guardian that never answered', () { + expect(formatGuardianSessionCount(en, null), isNull); + expect(formatGuardianSessionCount(en, neverAnswered), isNull); + }); + }); + + group('GuardianScreen', () { + PeerStatus peer({bool online = true}) => PeerStatus( + peerId: 0, + name: 'Alice', + online: online, + connectivity: PeerConnectivity.direct, + url: 'wss://alice.example.com/ws/', + version: '0.12.0', + ); + + Future?>> pump( + WidgetTester tester, { + required PeerStatus guardian, + GuardianSessionStatus? session, + bool joinable = false, + }) async { + final peers = ValueNotifier?>([ + guardian, + PeerStatus( + peerId: 1, + name: 'Bob', + online: true, + connectivity: PeerConnectivity.direct, + url: 'wss://bob.example.com/ws/', + ), + ]); + final sessions = ValueNotifier>({ + if (session != null) 0: session, + }); + // A phone, so the layout is checked at the width it ships at. + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + addTearDown(peers.dispose); + addTearDown(sessions.dispose); + await tester.pumpWidget( + harness( + GuardianScreen( + fed: _FakeFederation(), + peer: guardian, + peers: peers, + sessions: sessions, + joinable: joinable, + ), + ), + ); + return peers; + } + + testWidgets('shows what is known about a guardian and how to log in', ( + tester, + ) async { + await pump( + tester, + guardian: peer(), + session: seen(const Duration(minutes: 3), sessionCount: 48211), + ); + + expect(find.text('Alice'), findsWidgets); + expect(find.text('In sync'), findsOneWidget); + expect(find.text('48,211'), findsOneWidget); + expect(find.text('wss://alice.example.com/ws/'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('Copy invite code'), + 200, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Copy invite code'), findsOneWidget); + final button = tester.widget( + find.ancestor( + of: find.text('Open Guardian Dashboard'), + matching: find.bySubtype(), + ), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('cannot log in to a guardian that is offline', (tester) async { + await pump(tester, guardian: peer(online: false)); + + expect(find.text('Offline'), findsWidgets); + expect( + find.text('The dashboard is available while the guardian is online'), + findsOneWidget, + ); + final button = tester.widget( + find.ancestor( + of: find.text('Open Guardian Dashboard'), + matching: find.bySubtype(), + ), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('offers no login or invite code before joining', ( + tester, + ) async { + await pump(tester, guardian: peer(), joinable: true); + + expect(find.text('Open Guardian Dashboard'), findsNothing); + expect(find.text('Copy invite code'), findsNothing); + }); + + testWidgets('follows the guardian while open', (tester) async { + final peers = await pump(tester, guardian: peer()); + expect(find.text('No report'), findsOneWidget); + + peers.value = [peer(online: false), ...peers.value!.skip(1)]; + await tester.pump(); + + expect(find.text('No report'), findsNothing); + expect(find.text('Offline'), findsWidgets); + }); + }); +}