Files
zapstore/lib/screens/profile_screen.dart

2087 lines
72 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:async_button_builder/async_button_builder.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/app_restart_service.dart';
import 'package:zapstore/services/background_update_service.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/services/device_backup_service.dart';
import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/device_private_sync_service.dart';
import 'package:zapstore/services/device_state_service.dart';
import 'package:zapstore/services/log_service.dart' as app_logs;
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/settings_service.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/debug_utils.dart';
import 'package:zapstore/utils/nostr_route.dart';
import 'package:zapstore/widgets/common/profile_identity_row.dart';
import 'package:zapstore/widgets/common/stack_link_card.dart';
import 'package:zapstore/widgets/device_restore_dialog.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/widgets/common/note_parser.dart';
import 'package:zapstore/widgets/nwc_widgets.dart';
import 'package:zapstore/widgets/relay_management_card.dart';
import 'package:zapstore/screens/app_stacks_screen.dart';
/// Profile screen for authentication and app settings
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
body: ListView(
children: [
// Authentication Section
const _AuthenticationSection(),
const SizedBox(height: 24),
// User Stacks Section
const _UserStacksSection(),
const SizedBox(height: 24),
// Settings Heading
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text('Settings', style: context.textTheme.headlineSmall),
),
const SizedBox(height: 16),
// Lightning Wallet Section
const NWCConnectionCard(),
const SizedBox(height: 16),
// Data Management Section
const _DataManagementSection(),
const SizedBox(height: 16),
// App Catalog Relay Management Section
const RelayManagementCard(),
const SizedBox(height: 24),
// About Section
_AboutSection(),
const SizedBox(height: 24),
// Debug Messages Section
const _DebugMessagesSection(),
const SizedBox(height: 24),
],
),
);
}
}
class _AuthenticationSection extends ConsumerWidget {
const _AuthenticationSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final pubkey = ref.watch(Signer.activePubkeyProvider);
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (pubkey != null)
Consumer(
builder: (context, ref, _) {
final profileState = ref.watch(
query<Profile>(
authors: {pubkey},
limit: 1,
and: (profile) => {profile.contactList.query()},
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
stream: false,
cachedFor: Duration(hours: 2),
),
subscriptionPrefix: 'app-profile-detail',
),
);
final profile = profileState.models.firstOrNull;
return _buildSignedInProfile(context, ref, pubkey, profile);
},
)
else
_buildSignInOptions(context, ref),
],
),
),
);
}
Widget _buildSignedInProfile(
BuildContext context,
WidgetRef ref,
String pubkey,
Profile? profile,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Profile identity row (avatar, name, npub, nip05)
ProfileIdentityRow(
pubkey: pubkey,
profile: profile,
avatarRadius: 32,
onCopiedNpub: null,
),
const SizedBox(height: 12),
// Profile bio with NoteParser (non-interactive)
if ((profile?.about?.trim().isNotEmpty ?? false)) ...[
NoteParser.parse(
context,
profile!.about!,
textStyle: context.textTheme.bodySmall,
onNostrEntity: (entity) => NostrEntityWidget(
entity: entity,
colorPair: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary,
],
),
),
const SizedBox(height: 8),
],
// Link to full profile view
InkWell(
onTap: () => context.push('/profile/user/$pubkey'),
child: Row(
children: [
Text(
'View Full Profile',
style: context.textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
const SizedBox(height: 12),
// Sign Out button
FilledButton.icon(
onPressed: () => _signOut(context, ref),
icon: const Icon(Icons.logout, size: 14),
label: const Text('Sign Out', style: TextStyle(fontSize: 12)),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade900,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
_StackMigrationWarning(pubkey: pubkey),
],
);
}
Widget _buildSignInOptions(BuildContext context, WidgetRef ref) {
final pmState = ref.watch(packageManagerProvider);
final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Benefits Section
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
Icons.info_outline,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Sign in to unlock social features',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: _SignInButtonWithAmberCheck(
isAmberInstalled: isAmberInstalled,
),
),
],
);
}
Future<void> _signOut(BuildContext context, WidgetRef ref) async {
try {
await ref.read(amberSignerProvider).signOut();
} catch (e) {
if (context.mounted) {
context.showError('Sign out failed', technicalDetails: '$e');
}
}
}
}
class _StackMigrationWarning extends ConsumerWidget {
const _StackMigrationWarning({required this.pubkey});
final String pubkey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final platform = ref.read(packageManagerProvider.notifier).platform;
final stacksState = ref.watch(
query<AppStack>(
authors: {pubkey},
tags: {
'#h': {kZapstoreCommunityPubkey},
},
where: (s) => stackNeedsMigration(s, platform),
source: LocalAndRemoteSource(relays: {'AppCatalog'}, stream: false),
subscriptionPrefix: 'app-user-stacks-migration-profile',
),
);
final count = stacksState.models.length;
if (count == 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: InkWell(
onTap: () => pushStacks(context),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.errorContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.4),
),
),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 16,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'$count stack${count == 1 ? '' : 's'} need${count == 1 ? 's' : ''} updating — tap to fix',
style: context.textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
fontWeight: FontWeight.w500,
),
),
),
Icon(
Icons.arrow_forward,
size: 14,
color: Theme.of(context).colorScheme.error,
),
],
),
),
),
);
}
}
// Sign in button with Amber installation check
class _SignInButtonWithAmberCheck extends ConsumerWidget {
const _SignInButtonWithAmberCheck({required this.isAmberInstalled});
final bool isAmberInstalled;
@override
Widget build(BuildContext context, WidgetRef ref) {
return AsyncButtonBuilder(
onPressed: () async {
if (!isAmberInstalled) {
context.push('/profile/app/$kAmberNaddr');
} else {
await signInWithAmber(ref.read(refProvider));
}
},
builder: (context, child, callback, state) {
final onPressed = state.maybeWhen(
loading: () => null,
orElse: () => callback,
);
final loading = state.maybeWhen(
loading: () => true,
orElse: () => false,
);
return FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
child: loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.login, size: 16),
const SizedBox(width: 8),
Text(
isAmberInstalled
? 'Sign in with Amber'
: 'Install Amber to sign in',
style: const TextStyle(fontSize: 13),
),
],
),
);
},
child: const SizedBox.shrink(),
);
}
}
/// Debug Section - shows relay connections, active requests, and debug messages
class _DebugMessagesSection extends HookConsumerWidget {
const _DebugMessagesSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final poolState = ref.watch(poolStateProvider);
final selectedTab = useState(0); // 0=Subscriptions, 1=Relays
final now = useState(DateTime.now());
final expandedSubs = useState<Set<String>>({});
// Used to refresh the "time ago" labels
useEffect(() {
final timer = Timer.periodic(const Duration(seconds: 1), (_) {
now.value = DateTime.now();
});
return timer.cancel;
}, const []);
final subscriptions = poolState?.subscriptions ?? {};
final closedSubscriptions = poolState?.closedSubscriptions ?? {};
final logs = poolState?.logs ?? const [];
// Calculate unique relay URLs from subscriptions and logs (same logic as tab content)
final allRelayUrls = <String>{};
for (final sub in subscriptions.values) {
allRelayUrls.addAll(sub.relays.keys);
}
for (final log in logs) {
if (log.relayUrl != null) {
allRelayUrls.add(log.relayUrl!);
}
}
void toggleSubscription(String id) {
final next = {...expandedSubs.value};
if (!next.remove(id)) {
next.add(id);
}
expandedSubs.value = next;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(
Icons.bug_report,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Debug Info',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 10),
// Tab selector
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_TabButton(
label: 'Subscriptions (${subscriptions.length})',
isSelected: selectedTab.value == 0,
onTap: () => selectedTab.value = 0,
),
_TabButton(
label: 'Relays (${allRelayUrls.length})',
isSelected: selectedTab.value == 1,
onTap: () => selectedTab.value = 1,
),
],
),
const SizedBox(height: 12),
// Tab content
if (selectedTab.value == 0)
_buildSubscriptionsTab(
context,
subscriptions,
closedSubscriptions,
logs,
now.value,
expandedSubs.value,
toggleSubscription,
),
if (selectedTab.value == 1)
_buildRelaysTab(
context,
logs,
subscriptions,
closedSubscriptions,
expandedSubs.value,
toggleSubscription,
),
],
),
),
);
}
Widget _buildSubscriptionsTab(
BuildContext context,
Map<String, RelaySubscription> subscriptions,
Map<String, RelaySubscription> closedSubscriptions,
List<LogEntry> allLogs,
DateTime now,
Set<String> expandedSubs,
void Function(String id) onToggleSub,
) {
if (subscriptions.isEmpty && closedSubscriptions.isEmpty) {
return _EmptyState(message: 'No subscriptions');
}
final sortedSubs = subscriptions.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
// Sort closed by closedAt (newest first)
final sortedClosedSubs = closedSubscriptions.entries.toList()
..sort((a, b) {
final aTime = a.value.closedAt ?? a.value.startedAt;
final bTime = b.value.closedAt ?? b.value.startedAt;
return bTime.compareTo(aTime);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Active subscriptions
...sortedSubs.map(
(entry) => _buildSubscriptionCard(
context,
entry,
allLogs,
now,
expandedSubs,
onToggleSub,
isHistorical: false,
),
),
// Historical subscriptions header
if (sortedClosedSubs.isNotEmpty) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.history,
size: 14,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 6),
Text(
'History (${sortedClosedSubs.length})',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
const SizedBox(height: 8),
// Historical subscriptions
...sortedClosedSubs.map(
(entry) => _buildSubscriptionCard(
context,
entry,
allLogs,
now,
expandedSubs,
onToggleSub,
isHistorical: true,
),
),
],
],
);
}
Widget _buildSubscriptionCard(
BuildContext context,
MapEntry<String, RelaySubscription> entry,
List<LogEntry> allLogs,
DateTime now,
Set<String> expandedSubs,
void Function(String id) onToggleSub, {
required bool isHistorical,
}) {
final sub = entry.value;
final relays = sub.relays;
final isExpanded = expandedSubs.contains(sub.id);
final totalRelays = sub.totalRelayCount;
final activeRelays = sub.activeRelayCount;
final allEose = sub.allEoseReceived;
final relayEntries = relays.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
// For historical: calculate duration
final duration = isHistorical && sub.closedAt != null
? sub.closedAt!.difference(sub.startedAt)
: null;
return InkWell(
onTap: () => onToggleSub(sub.id),
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: isHistorical ? 0.15 : 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isHistorical
? Icons.check_circle_outline
: (sub.stream ? Icons.stream : Icons.download),
size: 16,
color: isHistorical
? Colors.blueGrey
: (allEose ? Colors.green : Colors.amber.shade700),
),
const SizedBox(width: 6),
Expanded(
child: Text(
entry.key,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: isHistorical
? Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6)
: null,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_StatusChip(
icon: Icons.event,
label: '${sub.eventCount}',
color: isHistorical
? Colors.blueGrey
: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 6),
if (isHistorical && duration != null)
_StatusChip(
icon: Icons.timer,
label: _formatDuration(duration),
color: Colors.blueGrey,
)
else
_StatusChip(
icon: Icons.cloud_done,
label: '$activeRelays/$totalRelays',
color: allEose ? Colors.green : Colors.amber.shade700,
),
const SizedBox(width: 6),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
size: 16,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
],
),
if (!isHistorical && relayEntries.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.15),
),
),
child: Column(
children: relayEntries.map((relayEntry) {
final relay = relayEntry.value;
final phase = relay.phase;
final phaseColor = switch (phase) {
RelaySubPhase.streaming => Colors.green,
RelaySubPhase.loading => Colors.blue,
RelaySubPhase.connecting => Colors.orange,
RelaySubPhase.waiting => Colors.amber,
RelaySubPhase.failed => Colors.red,
RelaySubPhase.disconnected => Colors.grey,
RelaySubPhase.closed => Colors.blueGrey,
};
final phaseIcon = switch (phase) {
RelaySubPhase.streaming => Icons.cloud_done,
RelaySubPhase.loading => Icons.cloud_sync,
RelaySubPhase.connecting => Icons.wifi_find,
RelaySubPhase.waiting => Icons.pause_circle,
RelaySubPhase.failed => Icons.error,
RelaySubPhase.disconnected => Icons.cloud_off,
RelaySubPhase.closed => Icons.check_circle_outline,
};
final shortUrl = relayEntry.key
.replaceAll('wss://', '')
.replaceAll('ws://', '')
.replaceAll(RegExp(r'/$'), '');
final streamingSince = relay.streamingSince;
final connectedFor =
(phase == RelaySubPhase.streaming ||
phase == RelaySubPhase.loading) &&
streamingSince != null
? _formatDuration(now.difference(streamingSince))
: null;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.1),
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(phaseIcon, size: 16, color: phaseColor),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shortUrl,
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'${phase.name}'
'${relay.reconnectAttempts > 0 ? ' · retry ${relay.reconnectAttempts}' : ''}'
'${connectedFor != null ? ' · connected for $connectedFor' : ''}',
style: TextStyle(
fontSize: 10,
color: phaseColor,
),
),
if (relay.lastError != null) ...[
const SizedBox(height: 2),
Text(
_humanizeRelayError(relay.lastError!),
style: TextStyle(
fontSize: 10,
color: Colors.orange.shade300,
),
),
],
],
),
),
],
),
);
}).toList(),
),
),
],
if (isExpanded) ...[
_buildRequestView(context, sub),
_buildSubscriptionLogs(context, sub.id, allLogs),
],
],
),
),
);
}
Widget _buildRelaysTab(
BuildContext context,
List<LogEntry> logs,
Map<String, RelaySubscription> subscriptions,
Map<String, RelaySubscription> closedSubscriptions,
Set<String> expandedSubs,
void Function(String id) onToggleSub,
) {
// Collect all unique relay URLs from subscriptions and logs
final allRelayUrls = <String>{};
for (final sub in subscriptions.values) {
allRelayUrls.addAll(sub.relays.keys);
}
for (final log in logs) {
if (log.relayUrl != null) {
allRelayUrls.add(log.relayUrl!);
}
}
if (allRelayUrls.isEmpty) {
return _EmptyState(message: 'No relays connected');
}
// Group logs by relay URL
final logsByRelay = <String, List<LogEntry>>{};
for (final relayUrl in allRelayUrls) {
logsByRelay[relayUrl] = [];
}
for (final log in logs) {
if (log.relayUrl != null && logsByRelay.containsKey(log.relayUrl)) {
logsByRelay[log.relayUrl]!.add(log);
}
}
// Sort relays alphabetically and logs by timestamp (newest first)
final sortedRelays = logsByRelay.keys.toList()..sort();
for (final url in sortedRelays) {
logsByRelay[url]!.sort((a, b) => b.timestamp.compareTo(a.timestamp));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sortedRelays.map((relayUrl) {
final relayLogs = logsByRelay[relayUrl]!;
final shortUrl = relayUrl
.replaceAll('wss://', '')
.replaceAll('ws://', '')
.replaceAll(RegExp(r'/$'), '');
final errorCount = relayLogs
.where((l) => l.level == LogLevel.error)
.length;
final warningCount = relayLogs
.where((l) => l.level == LogLevel.warning)
.length;
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 0,
),
childrenPadding: EdgeInsets.zero,
leading: Icon(
Icons.dns,
size: 18,
color: errorCount > 0
? Colors.red
: warningCount > 0
? Colors.orange
: Theme.of(context).colorScheme.primary,
),
title: Text(
shortUrl,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
),
),
subtitle: Row(
children: [
Text(
'${relayLogs.length} logs',
style: TextStyle(
fontSize: 10,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
if (errorCount > 0) ...[
const SizedBox(width: 8),
_StatusChip(
icon: Icons.error,
label: '$errorCount',
color: Colors.red,
),
],
if (warningCount > 0) ...[
const SizedBox(width: 4),
_StatusChip(
icon: Icons.warning,
label: '$warningCount',
color: Colors.orange,
),
],
],
),
children: [
Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.1),
),
),
),
child: relayLogs.isEmpty
? Padding(
padding: const EdgeInsets.all(16),
child: Text(
'No logs for this relay',
style: TextStyle(
fontSize: 11,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
)
: Column(
children: relayLogs.map((log) {
final time = _formatTime(log.timestamp);
final levelName = log.level.name.toUpperCase();
final color = switch (log.level) {
LogLevel.error => Colors.red,
LogLevel.warning => Colors.orange,
LogLevel.info => Theme.of(
context,
).colorScheme.primary,
};
final subId = log.subscriptionId;
final sub = subId != null
? (subscriptions[subId] ??
closedSubscriptions[subId])
: null;
final isExpanded =
subId != null && expandedSubs.contains(subId);
return InkWell(
onTap: subId != null
? () => onToggleSub(subId)
: null,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.1),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: color.withValues(
alpha: 0.12,
),
borderRadius: BorderRadius.circular(
4,
),
),
child: Text(
levelName,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: color,
),
),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'[$time] ${log.message}',
style: const TextStyle(
fontSize: 10,
fontFamily: 'monospace',
),
),
if (subId != null)
Text(
'Sub: $subId',
style: TextStyle(
fontSize: 9,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
if (log.exception != null)
Text(
log.exception!.toString(),
style: TextStyle(
fontSize: 9,
color: Colors.red
.withValues(alpha: 0.8),
),
),
],
),
),
if (subId != null)
Icon(
isExpanded
? Icons.expand_less
: Icons.expand_more,
size: 16,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.5),
),
],
),
// Show request when expanded
if (isExpanded && sub != null) ...[
const SizedBox(height: 8),
_buildRequestView(context, sub),
],
],
),
),
);
}).toList(),
),
),
],
),
),
);
}).toList(),
);
}
String _formatTime(DateTime timestamp) {
return '${timestamp.hour.toString().padLeft(2, '0')}:'
'${timestamp.minute.toString().padLeft(2, '0')}:'
'${timestamp.second.toString().padLeft(2, '0')}';
}
String _formatDuration(Duration duration) {
if (duration.inSeconds < 60) {
return '${duration.inSeconds}s';
}
if (duration.inMinutes < 60) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return seconds > 0 ? '${minutes}m ${seconds}s' : '${minutes}m';
}
if (duration.inHours < 24) {
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
return minutes > 0 ? '${hours}h ${minutes}m' : '${hours}h';
}
final days = duration.inDays;
final hours = duration.inHours % 24;
return hours > 0 ? '${days}d ${hours}h' : '${days}d';
}
Widget _buildRequestView(BuildContext context, RelaySubscription sub) {
final req = _formatReq(sub);
return Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'REQ',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(width: 8),
Expanded(
child: Text(
sub.id,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.copy, size: 16),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
Clipboard.setData(ClipboardData(text: req));
},
),
],
),
const SizedBox(height: 8),
SelectableText(
req,
style: const TextStyle(
fontSize: 11,
height: 1.4,
fontFamily: 'monospace',
),
),
],
),
);
}
String _formatReq(RelaySubscription sub) {
final payload = ['REQ', sub.id, ...sub.request.toMaps()];
try {
return const JsonEncoder.withIndent(' ').convert(payload);
} catch (_) {
return payload.toString();
}
}
Widget _buildSubscriptionLogs(
BuildContext context,
String subscriptionId,
List<LogEntry> allLogs,
) {
final subLogs =
allLogs.where((log) => log.subscriptionId == subscriptionId).toList()
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
if (subLogs.isEmpty) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'LOGS (${subLogs.length})',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
...subLogs.map((log) {
final time = _formatTime(log.timestamp);
final levelName = log.level.name.toUpperCase();
final color = switch (log.level) {
LogLevel.error => Colors.red,
LogLevel.warning => Colors.orange,
LogLevel.info => Theme.of(context).colorScheme.primary,
};
final parts = [
if (log.relayUrl != null)
log.relayUrl!
.replaceAll('wss://', '')
.replaceAll('ws://', '')
.replaceAll(RegExp(r'/$'), ''),
if (log.exception != null) log.exception!,
];
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Text(
levelName,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: color,
),
),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'[$time] ${log.message}',
style: const TextStyle(
fontSize: 10,
fontFamily: 'monospace',
),
),
if (parts.isNotEmpty)
Text(
parts.join(' • '),
style: TextStyle(
fontSize: 9,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
],
),
);
}),
],
),
);
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({
required this.icon,
required this.label,
required this.color,
});
final IconData icon;
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: color),
const SizedBox(width: 2),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
}
class _TabButton extends StatelessWidget {
const _TabButton({
required this.label,
required this.isSelected,
required this.onTap,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Icon(
Icons.check_circle_outline,
size: 36,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 12),
Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
);
}
}
class _DeviceKeyCard extends HookConsumerWidget {
const _DeviceKeyCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final devicePubkey = ref.watch(devicePubkeyProvider);
final deviceState = ref.watch(deviceStateProvider);
if (devicePubkey == null) return const SizedBox.shrink();
final npub = bech32Encode('npub', devicePubkey);
final shortened =
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
final isCopying = useState(false);
final isRestoring = useState(false);
final powElapsed = useState(Duration.zero);
useEffect(() {
final startedAt = deviceState.startedAt;
if (deviceState.phase != DeviceStatePhase.bootstrapping ||
startedAt == null) {
return null;
}
void updateElapsed() {
powElapsed.value = DateTime.now().difference(startedAt);
}
updateElapsed();
final timer = Timer.periodic(const Duration(seconds: 1), (_) {
updateElapsed();
});
return timer.cancel;
}, [deviceState.phase, deviceState.startedAt]);
String formatElapsed(Duration elapsed) {
final seconds = elapsed.inSeconds;
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}';
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.16),
),
),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
Icons.key,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Device key',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
if (deviceState.phase == DeviceStatePhase.bootstrapping) ...[
const Text('Preparing device backup…'),
const SizedBox(height: 6),
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(height: 2),
Text(
formatElapsed(powElapsed.value),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
] else if (deviceState.isReady)
Text(
shortened,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
overflow: TextOverflow.ellipsis,
)
else
Text(
'Device backup failed: ${deviceState.error}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon(
onPressed: !deviceState.isReady || isCopying.value
? null
: () async {
final shouldCopy = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Copy device private key?'),
content: const Text(
'This nsec controls your private Zapstore data, including saved apps '
'and unmanaged apps. Anyone with it can read and modify that data.',
),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () =>
Navigator.pop(dialogContext, true),
child: const Text('Copy nsec'),
),
],
),
);
if (shouldCopy != true) return;
isCopying.value = true;
try {
final nsec = await ref
.read(deviceKeyServiceProvider)
.getNsec();
await Clipboard.setData(ClipboardData(text: nsec));
if (context.mounted) {
context.showInfo('Device nsec copied');
}
} catch (e) {
if (context.mounted) {
context.showError(
'Could not copy device key',
technicalDetails: e.toString(),
);
}
} finally {
isCopying.value = false;
}
},
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copy nsec'),
),
TextButton.icon(
onPressed: isRestoring.value
? null
: () async {
final result = await showDialog<DeviceRestoreResult>(
context: context,
builder: (_) => const DeviceRestoreDialog(),
);
if (result == null || !context.mounted) return;
if (result.action == DeviceRestoreAction.amber) {
isRestoring.value = true;
try {
await ref
.read(deviceBackupServiceProvider)
.restoreFromAmber(ref: ref.read(refProvider));
if (context.mounted) {
context.showInfo('Device key restored');
}
} catch (error, stack) {
app_logs.LogService.I.warn(
'Amber device key restore failed',
tag: 'backup',
err: error,
stack: stack,
);
if (context.mounted) {
context.showError(
'Could not restore device key',
technicalDetails: error.toString(),
);
}
} finally {
isRestoring.value = false;
}
return;
}
final privateKeyHex = ref
.read(deviceKeyServiceProvider)
.parsePrivateKey(result.key ?? '');
if (privateKeyHex == null) {
context.showError('Enter a valid device nsec.');
return;
}
isRestoring.value = true;
try {
await ref
.read(deviceBackupServiceProvider)
.restoreDeviceKey(
ref: ref.read(refProvider),
privateKeyHex: privateKeyHex,
);
await ref
.read(devicePrivateSyncProvider.notifier)
.syncRestoredKey();
if (!ref.read(deviceStateProvider).isReady) {
unawaited(
ref
.read(deviceStateProvider.notifier)
.bootstrap(),
);
}
if (context.mounted) {
context.showInfo('Device key restored');
}
} catch (error, stack) {
app_logs.LogService.I.warn(
'device key restore failed',
tag: 'backup',
err: error,
stack: stack,
);
if (context.mounted) {
context.showError(
'Could not restore device key',
technicalDetails: error.toString(),
);
}
} finally {
isRestoring.value = false;
}
},
icon: const Icon(Icons.restore, size: 18),
label: const Text('Restore'),
),
],
),
],
),
);
}
}
class _BackgroundAutoUpdatesToggle extends ConsumerWidget {
const _BackgroundAutoUpdatesToggle();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settingsAsync = ref.watch(localSettingsProvider);
final ready = ref.watch(deviceStateProvider).isReady;
final enabled =
settingsAsync.valueOrNull?.backgroundAutoUpdatesEnabled ?? false;
return SwitchListTile(
secondary: CircleAvatar(
radius: 18,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
Icons.system_update_alt,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
title: const Text('Background auto-updates'),
value: enabled,
contentPadding: EdgeInsets.zero,
onChanged: !ready
? null
: (value) async {
if (value && !enabled) {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Turn on background auto-updates?'),
content: const Text(
'Zapstore will check for updates and apply them in the '
'background. The first check will start immediately when '
'Wi-Fi is available. After that, checks run approximately '
'every 24 hours. You can turn this off at any time.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Turn on'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
}
await ref
.read(deviceStateProvider.notifier)
.updatePortable(
(s) => s.copyWith(backgroundAutoUpdatesEnabled: value),
);
ref.invalidate(localSettingsProvider);
if (value) {
unawaited(() async {
try {
await ref
.read(backgroundUpdateServiceProvider)
.scheduleImmediateAutoUpdate();
} catch (error, stack) {
app_logs.LogService.I.warn(
'initial background auto-update scheduling failed',
tag: 'background_updates',
err: error,
stack: stack,
);
}
}());
}
},
);
}
}
class _DataManagementSection extends ConsumerWidget {
const _DataManagementSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Data Management',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
const _DeviceKeyCard(),
const SizedBox(height: 8),
const _BackgroundAutoUpdatesToggle(),
const SizedBox(height: 8),
ListTile(
leading: CircleAvatar(
radius: 18,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
Icons.bug_report_outlined,
color: Theme.of(context).colorScheme.primary,
),
),
title: const AutoSizeText(
'Diagnostics',
style: TextStyle(fontWeight: FontWeight.w600),
maxLines: 1,
minFontSize: 12,
),
subtitle: const Text('View and export local diagnostic logs'),
contentPadding: EdgeInsets.zero,
onTap: () => context.push('/profile/diagnostics'),
),
const SizedBox(height: 8),
ListTile(
leading: CircleAvatar(
radius: 18,
backgroundColor: Theme.of(
context,
).colorScheme.error.withValues(alpha: 0.12),
child: Icon(
Icons.delete_sweep,
color: Theme.of(context).colorScheme.error,
),
),
title: AutoSizeText(
'Clear local storage',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w600,
),
maxLines: 1,
minFontSize: 12,
),
contentPadding: EdgeInsets.zero,
onTap: () => _showClearAllDataDialog(context, ref),
),
],
),
),
);
}
void _showClearAllDataDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text('Clear local storage'),
),
),
],
),
content: const Text(
'Clears all cached data and restarts the app. '
'Your sign-in and wallet connection will be preserved.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
// Close the confirmation dialog first
Navigator.pop(context);
// Proceed with clearing data
await _clearAllData(context, ref);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
child: const Text('Clear storage'),
),
],
),
);
}
Future<void> _clearAllData(BuildContext context, WidgetRef ref) async {
try {
// Show loading dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: AppColors.darkSkeletonHighlight,
backgroundColor: AppColors.darkSkeletonBase,
),
SizedBox(height: 16),
Text('Restarting...'),
],
),
),
);
// Native restart - storage will be cleared on next launch
await restartApp();
} catch (e) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
context.showError('Restart failed', technicalDetails: e.toString());
}
}
}
}
class _UserStacksSection extends ConsumerWidget {
const _UserStacksSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final devicePubkey = ref.watch(devicePubkeyProvider);
if (devicePubkey == null) return const SizedBox.shrink();
// Query for device-owned private stacks.
final stacksState = ref.watch(
query<AppStack>(
authors: {devicePubkey},
source: const LocalSource(),
subscriptionPrefix: 'app-profile-user-stacks',
),
);
// Filter to only encrypted (private) stacks
final privateStacks = stacksState.models
.where(
(s) =>
s.content.isNotEmpty && s.identifier != 'zapstore-installed-apps',
)
.toList();
// Don't show section if no private stacks exist
if (privateStacks.isEmpty) {
return const SizedBox.shrink();
}
// Sort: Saved Apps first, then Unmanaged Apps, then others alphabetically
privateStacks.sort((a, b) {
if (a.identifier == kAppBookmarksIdentifier) return -1;
if (b.identifier == kAppBookmarksIdentifier) return 1;
if (a.identifier == kUnmanagedAppsIdentifier) return -1;
if (b.identifier == kUnmanagedAppsIdentifier) return 1;
return (a.name ?? a.identifier).compareTo(b.name ?? b.identifier);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'Your Private Stacks',
style: context.textTheme.headlineSmall,
),
),
const SizedBox(height: 16),
...privateStacks.asMap().entries.map((entry) {
final index = entry.key;
final stack = entry.value;
final displayName = stack.identifier == kAppBookmarksIdentifier
? 'Saved Apps'
: stack.identifier == kUnmanagedAppsIdentifier
? 'Unmanaged Apps'
: null;
return Padding(
padding: EdgeInsets.only(top: index > 0 ? 8 : 0),
child: StackLinkCard(stack: stack, displayName: displayName),
);
}),
],
);
}
}
class _AboutSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final pmState = ref.watch(packageManagerProvider);
final zsPackage = pmState.installed[kZapstoreAppIdentifier];
if (zsPackage == null) {
final isLoading = pmState.installed.isEmpty;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('About', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
if (isLoading) ...[
Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Loading Zapstore build information…',
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
] else ...[
Text(
'Zapstore version details are unavailable right now.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.orange[700]),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
unawaited(
ref
.read(packageManagerProvider.notifier)
.syncInstalledPackages(),
);
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
],
),
),
);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('About', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
ListTile(
leading: ClipOval(
child: Image.asset(
'assets/images/logo.png',
width: 28,
height: 28,
fit: BoxFit.cover,
color: Colors.grey,
colorBlendMode: BlendMode.saturation,
),
),
title: Text('Version'),
subtitle: Text('${zsPackage.version}+${zsPackage.versionCode}'),
contentPadding: EdgeInsets.zero,
),
ListTile(
leading: const Icon(Icons.code),
title: const Text('Source Code'),
subtitle: const Text('View on GitHub'),
trailing: const Icon(Icons.open_in_new),
contentPadding: EdgeInsets.zero,
onTap: () {
launchUrl(Uri.parse('https://github.com/zapstore/zapstore'));
},
),
],
),
),
);
}
}
String _humanizeRelayError(String raw) {
final lower = raw.toLowerCase();
if (lower.contains('timeoutexception') ||
lower.contains('future not completed')) {
return 'Connection timed out';
}
if (lower.contains('max retries exceeded')) {
return 'Failed after max retries';
}
if (lower.contains('connection refused') || lower.contains('econnrefused')) {
return 'Connection refused';
}
if (lower.contains('network') || lower.contains('socket')) {
return 'Network error';
}
if (lower.contains('ping timeout') || lower.contains('zombie')) {
return 'Connection lost (ping timeout)';
}
return raw.length > 60 ? '${raw.substring(0, 60)}…' : raw;
}