Files
zapstore/lib/screens/profile_screen.dart
T
Henrique Velloso 84906164bb feat: auto-backup installed apps on install/uninstall
Back up the list of installed apps to Nostr (encrypted AppStack) when
batch installs complete or apps are uninstalled. Toggle in profile, off
by default. Restore screen fetches backup and lets users reinstall apps.

Changes by file:

- installed_apps_backup_service: Listener for batch completion and
  installed-list changes; debounced backup (3s); restore-only batches
  ignored; timer cancelled on provider dispose; logs reduced to errors
  and completion only; null-aware assignment for baseline keys.

- secure_storage_service: Persistence for backup-enabled toggle (off
  by default).

- profile_screen: Toggle to enable/disable backup; entry point for
  restore flow.

- restore_installed_apps_screen: Restore UI with shared AppBar; lists
  apps to install and already installed; cards navigate to app detail.

- main_scaffold: Watches backup listener provider so it stays active.

- package_manager: Removed unused installSourceProvider; install source
  handling for restore operations.

- install_operation, android_package_manager, install_button,
  batch_progress_banner: InstallSource.restore support for restore flow.

- app_card: Factory constructors (forRestore, forDiscovery, etc.) to
  simplify restore and other screens.

- router, installed_packages_snapshot: Supporting wiring for the feature.
2026-02-26 18:27:41 -03:00

1848 lines
62 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:url_launcher/url_launcher.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/screens/restore_installed_apps_screen.dart';
import 'package:zapstore/services/installed_apps_backup_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/common/profile_identity_row.dart';
import 'package:zapstore/widgets/app_card.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';
/// 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),
// Saved Apps Heading
const _SavedAppsHeading(),
const SizedBox(height: 16),
// Saved Apps Section
const _SavedAppsSection(),
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),
// App Catalog Relay Management Section
const RelayManagementCard(),
const SizedBox(height: 16),
// Data Management Section
const _DataManagementSection(),
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),
),
),
);
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,
),
),
],
);
}
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');
}
}
}
}
// 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 ref.read(amberSignerProvider).signIn();
}
},
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(
relay.lastError!,
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontFamily: 'monospace',
),
),
],
],
),
),
],
),
);
}).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 _InstalledAppsTile extends ConsumerWidget {
const _InstalledAppsTile({
required this.icon,
required this.title,
required this.onAction,
});
final IconData icon;
final String title;
final Future<void> Function(BuildContext context, WidgetRef ref) onAction;
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final isSignedIn = signedInPubkey != null;
return AsyncButtonBuilder(
onPressed: () async {
if (!isSignedIn) {
if (context.mounted) {
context.showError(
'Sign in required',
description: 'You need to sign in to use this feature.',
);
}
return;
}
try {
await onAction(context, ref);
} catch (e) {
if (context.mounted) {
context.showError('Operation failed', technicalDetails: '$e');
}
}
},
builder: (context, child, callback, buttonState) {
final isLoading = buttonState.maybeWhen(
loading: () => true,
orElse: () => false,
);
return ListTile(
leading: CircleAvatar(
radius: 18,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary,
),
)
: Icon(icon, color: Theme.of(context).colorScheme.primary),
),
title: AutoSizeText(
title,
style: TextStyle(
color: isSignedIn
? Theme.of(context).colorScheme.onSurface
: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
fontWeight: FontWeight.w600,
),
maxLines: 1,
minFontSize: 12,
),
contentPadding: EdgeInsets.zero,
onTap: isLoading || !isSignedIn ? null : callback,
);
},
child: const SizedBox.shrink(),
);
}
}
class _DataManagementSection extends ConsumerWidget {
const _DataManagementSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final pubkey = ref.watch(Signer.activePubkeyProvider);
final backupEnabled =
ref.watch(backupSettingsProvider).valueOrNull ?? false;
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),
if (pubkey != null)
SwitchListTile(
secondary: Icon(
Icons.cloud_upload_outlined,
color: Theme.of(context).colorScheme.primary,
),
title: const Text('Enable installed apps backup'),
subtitle: const Text(
'Automatically syncs your installed apps list to Nostr after each install or uninstall.',
),
value: backupEnabled,
onChanged: (value) {
ref.read(backupSettingsProvider.notifier).setEnabled(value);
},
contentPadding: EdgeInsets.zero,
),
if (pubkey != null) const SizedBox(height: 8),
_InstalledAppsTile(
icon: Icons.backup,
title: 'Backup installed apps',
onAction: (context, ref) async {
final count = await ref.read(backupInstalledAppsProvider)();
if (context.mounted) {
context.showInfo(
count == 0
? 'No cataloged apps to backup'
: 'Backed up $count app${count == 1 ? '' : 's'}',
);
}
},
),
_InstalledAppsTile(
icon: Icons.restore,
title: 'Restore installed apps',
onAction: (context, ref) async {
final ids = await ref.read(restoreInstalledAppsProvider)();
if (!context.mounted) return;
if (ids.isEmpty) {
context.showInfo('No backup found');
return;
}
await showDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: false,
builder: (dialogContext) => PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
Navigator.of(dialogContext, rootNavigator: true).pop();
}
},
child: Dialog(
backgroundColor: AppColors.darkBackground,
insetPadding: EdgeInsets.zero,
child: RestoreInstalledAppsScreen(
addressableIds: ids,
onClose: () => Navigator.of(
dialogContext,
rootNavigator: true,
).pop(),
),
),
),
);
},
),
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 _SavedAppsHeading extends ConsumerWidget {
const _SavedAppsHeading();
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
if (signedInPubkey == null) {
return const SizedBox.shrink();
}
// Always show heading when signed in
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text('Saved Apps', style: context.textTheme.headlineSmall),
);
}
}
class _SavedAppsSection extends ConsumerWidget {
const _SavedAppsSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
if (signedInPubkey == null) return const SizedBox.shrink();
final savedAppsAsync = ref.watch(bookmarksProvider);
// Keep previous value during refresh, if available.
final addressableIds = savedAppsAsync.valueOrNull;
// Show loading only on first load (when no value exists yet).
if (addressableIds == null) {
return _savedAppsLoadingCard(context);
}
final identifiers = _toIdentifiers(addressableIds);
return _SavedAppsList(identifiers: identifiers);
}
Set<String> _toIdentifiers(Set<String> addressableIds) {
return addressableIds
.map((id) => id.split(':'))
.where((parts) => parts.length >= 3)
.map((parts) => parts[2])
.toSet();
}
Widget _savedAppsLoadingCard(BuildContext context) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
class _SavedAppsList extends ConsumerWidget {
const _SavedAppsList({required this.identifiers});
final Set<String> identifiers;
@override
Widget build(BuildContext context, WidgetRef ref) {
// No bookmarks saved - show empty state without querying
if (identifiers.isEmpty) {
return _emptyState(context);
}
final savedAppsState = ref.watch(
query<App>(
tags: {'#d': identifiers},
and: (app) => {app.latestRelease.query()},
source: const LocalSource(),
subscriptionPrefix: 'profile-saved-apps',
),
);
final isLoading = savedAppsState is StorageLoading;
final savedApps = savedAppsState.models.toList()
..sort(
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
(b.name ?? b.identifier).toLowerCase(),
),
);
// Show spinner only when we truly have nothing to render yet
// If we're refreshing but still have models, keep showing the list
if (isLoading && savedApps.isEmpty) {
return _loadingCard(context);
}
if (savedApps.isEmpty) {
return _emptyState(context);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final app in savedApps)
AppCard(app: app, showUpdateArrow: false, showDescription: false),
],
),
),
);
}
Widget _loadingCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
Widget _emptyState(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'No saved apps yet',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
);
}
}
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'));
},
),
],
),
),
);
}
}