mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
2087 lines
72 KiB
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;
|
|
}
|