mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Improve settings storage, save installed apps and show private stacks
This commit is contained in:
+11
-11
@@ -14,7 +14,7 @@ import 'package:amber_signer/amber_signer.dart';
|
||||
import 'package:zapstore/services/app_restart_service.dart';
|
||||
import 'package:zapstore/services/background_update_service.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/services/secure_storage_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
import 'package:zapstore/router.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
@@ -242,9 +242,8 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
|
||||
// Load local relay config BEFORE storage init
|
||||
// This ensures custom relays work even when signed out
|
||||
final secureStorage = ref.read(secureStorageServiceProvider);
|
||||
final localRelays = await secureStorage.getAppCatalogRelays();
|
||||
final appCatalogRelays = localRelays ?? {_kDefaultAppCatalogRelay};
|
||||
final settings = await ref.read(settingsServiceProvider).load();
|
||||
final appCatalogRelays = settings.appCatalogRelays ?? {_kDefaultAppCatalogRelay};
|
||||
|
||||
// Initialize storage with local relay config
|
||||
await ref.read(
|
||||
@@ -275,7 +274,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
await DeviceCapabilitiesCache.initialize();
|
||||
|
||||
// Record app open time for background notification throttling
|
||||
await secureStorage.setLastAppOpenedTime(DateTime.now());
|
||||
await ref.read(settingsServiceProvider).update(
|
||||
(s) => s.copyWith(lastAppOpened: DateTime.now()),
|
||||
);
|
||||
|
||||
// Ensure installed packages are available before anything categorizes
|
||||
final packageManager = ref.read(packageManagerProvider.notifier);
|
||||
@@ -303,10 +304,10 @@ Future<void> _maybeCopySeedDatabase(String dbPath) async {
|
||||
final dbFile = File(dbPath);
|
||||
if (dbFile.existsSync()) return;
|
||||
|
||||
final customRelays = await SecureStorageService().getAppCatalogRelays();
|
||||
final isDefault = customRelays == null ||
|
||||
(customRelays.length == 1 &&
|
||||
customRelays.contains(_kDefaultAppCatalogRelay));
|
||||
final settings = await SettingsService().load();
|
||||
final isDefault = settings.appCatalogRelays == null ||
|
||||
(settings.appCatalogRelays!.length == 1 &&
|
||||
settings.appCatalogRelays!.contains(_kDefaultAppCatalogRelay));
|
||||
if (!isDefault) return;
|
||||
|
||||
try {
|
||||
@@ -402,7 +403,6 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||
/// Record that the user opened the app.
|
||||
/// This is used to check inactivity for background notifications.
|
||||
Future<void> _recordAppOpened() async {
|
||||
final secureStorage = SecureStorageService();
|
||||
await secureStorage.setLastAppOpenedTime(DateTime.now());
|
||||
await SettingsService().update((s) => s.copyWith(lastAppOpened: DateTime.now()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
@@ -44,12 +47,17 @@ class AppStackScreen extends HookConsumerWidget {
|
||||
final stack = stackState.models.firstOrNull;
|
||||
|
||||
if (stack == null) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _AppStackSkeleton(),
|
||||
),
|
||||
);
|
||||
// Still loading — show skeleton
|
||||
if (stackState is StorageLoading) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _AppStackSkeleton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Loading complete but stack not found
|
||||
return _NotFoundScaffold(stackId: stackId);
|
||||
}
|
||||
|
||||
return _AppStackContentWithApps(stack: stack);
|
||||
@@ -64,10 +72,63 @@ class _AppStackContentWithApps extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appAddressableIds = stack.event
|
||||
.getTagSetValues('a')
|
||||
.where((id) => id.startsWith('32267:'))
|
||||
.toSet();
|
||||
final isEncrypted = stack.content.isNotEmpty;
|
||||
|
||||
// For encrypted stacks, decrypt content to get app IDs
|
||||
final decryptedAppIds = useState<Set<String>?>(null);
|
||||
final decryptError = useState<String?>(null);
|
||||
|
||||
useEffect(() {
|
||||
if (!isEncrypted) return null;
|
||||
|
||||
Future<void> decrypt() async {
|
||||
final signer = ref.read(Signer.activeSignerProvider);
|
||||
final pubkey = ref.read(Signer.activePubkeyProvider);
|
||||
|
||||
if (signer == null || pubkey == null) {
|
||||
decryptError.value = 'Sign in required to view this stack';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decrypted = await signer.nip44Decrypt(stack.content, pubkey);
|
||||
final ids = (jsonDecode(decrypted) as List).cast<String>().toSet();
|
||||
decryptedAppIds.value = ids;
|
||||
} catch (e) {
|
||||
decryptError.value = 'Failed to decrypt stack';
|
||||
}
|
||||
}
|
||||
|
||||
decrypt();
|
||||
return null;
|
||||
}, [stack.content]);
|
||||
|
||||
// Handle decrypt error
|
||||
if (decryptError.value != null) {
|
||||
return _AppStackContent(
|
||||
stack: stack,
|
||||
apps: const [],
|
||||
errorMessage: decryptError.value,
|
||||
);
|
||||
}
|
||||
|
||||
// For encrypted stacks, wait for decryption
|
||||
if (isEncrypted && decryptedAppIds.value == null) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _AppStackSkeleton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Get app addressable IDs from either tags (public) or decrypted content (private)
|
||||
final appAddressableIds = isEncrypted
|
||||
? decryptedAppIds.value!
|
||||
: stack.event
|
||||
.getTagSetValues('a')
|
||||
.where((id) => id.startsWith('32267:'))
|
||||
.toSet();
|
||||
|
||||
if (appAddressableIds.isEmpty) {
|
||||
return _AppStackContent(stack: stack, apps: const []);
|
||||
@@ -139,12 +200,58 @@ class _ErrorScaffold extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _NotFoundScaffold extends StatelessWidget {
|
||||
final String stackId;
|
||||
const _NotFoundScaffold({required this.stackId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('App Stack')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.apps_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Stack not found',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This stack may have been deleted or is not available',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal widget that displays stack details
|
||||
class _AppStackContent extends HookConsumerWidget {
|
||||
final AppStack stack;
|
||||
final List<App> apps;
|
||||
final String? errorMessage;
|
||||
|
||||
const _AppStackContent({required this.stack, required this.apps});
|
||||
const _AppStackContent({
|
||||
required this.stack,
|
||||
required this.apps,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -211,11 +318,12 @@ class _AppStackContent extends HookConsumerWidget {
|
||||
...sortedApps.map(
|
||||
(app) => AppCard(app: app, showUpdateArrow: app.hasUpdate),
|
||||
),
|
||||
// Comments section
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: StackCommentsSection(stack: stack),
|
||||
),
|
||||
// Comments section - hidden for private/encrypted stacks
|
||||
if (stack.content.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: StackCommentsSection(stack: stack),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -261,15 +369,36 @@ class _StackHeader extends StatelessWidget {
|
||||
final Profile? author;
|
||||
final bool isAuthorLoading;
|
||||
|
||||
bool get _isEncrypted => stack.content.isNotEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final subtitleStyle = Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: subtitleColor,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Stack name
|
||||
Text(
|
||||
stack.name ?? stack.identifier,
|
||||
style: context.textTheme.headlineMedium,
|
||||
// Stack name with padlock for private stacks
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
stack.name ?? stack.identifier,
|
||||
style: context.textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
if (_isEncrypted) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.lock,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Published by author - always show, with fallback to npub
|
||||
@@ -283,27 +412,34 @@ class _StackHeader extends StatelessWidget {
|
||||
onTap: () => pushUser(context, stack.pubkey),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Last updated timestamp
|
||||
// Metadata row: updated timestamp + private indicator
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.update,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.update, size: 14, color: subtitleColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Updated ',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
TimeAgoText(
|
||||
stack.event.createdAt,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text('Updated ', style: subtitleStyle),
|
||||
TimeAgoText(stack.event.createdAt, style: subtitleStyle),
|
||||
if (_isEncrypted) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.lock_outline, size: 12, color: subtitleColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Private',
|
||||
style: subtitleStyle?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (stack.description case final description?) ...[
|
||||
|
||||
+52
-143
@@ -14,13 +14,12 @@ 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/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/secure_storage_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
import 'package:zapstore/widgets/common/profile_identity_row.dart';
|
||||
import 'package:zapstore/widgets/app_card.dart';
|
||||
import 'package:zapstore/widgets/common/stack_link_card.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/widgets/common/note_parser.dart';
|
||||
@@ -42,13 +41,8 @@ class ProfileScreen extends ConsumerWidget {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Saved Apps Heading
|
||||
const _SavedAppsHeading(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Saved Apps Section
|
||||
const _SavedAppsSection(),
|
||||
// User Stacks Section (Saved Apps + Installed Apps)
|
||||
const _UserStacksSection(),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -1404,8 +1398,8 @@ class _InstalledAppsBackupToggle extends ConsumerWidget {
|
||||
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
if (pubkey == null) return const SizedBox.shrink();
|
||||
|
||||
final backupAsync = ref.watch(installedAppsBackupEnabledProvider);
|
||||
final enabled = backupAsync.valueOrNull ?? false;
|
||||
final settingsAsync = ref.watch(localSettingsProvider);
|
||||
final enabled = settingsAsync.valueOrNull?.installedAppsBackupEnabled ?? false;
|
||||
|
||||
return SwitchListTile(
|
||||
secondary: CircleAvatar(
|
||||
@@ -1419,9 +1413,9 @@ class _InstalledAppsBackupToggle extends ConsumerWidget {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(secureStorageServiceProvider)
|
||||
.setInstalledAppsBackupEnabled(value);
|
||||
ref.invalidate(installedAppsBackupEnabledProvider);
|
||||
.read(settingsServiceProvider)
|
||||
.update((s) => s.copyWith(installedAppsBackupEnabled: value));
|
||||
ref.invalidate(localSettingsProvider);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1551,149 +1545,64 @@ class _DataManagementSection extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
class _UserStacksSection extends ConsumerWidget {
|
||||
const _UserStacksSection();
|
||||
|
||||
@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.latestAsset.query(),
|
||||
app.latestRelease.query(
|
||||
and: (release) => {release.latestMetadata.query()},
|
||||
),
|
||||
},
|
||||
// Query for ALL user's stacks (we'll filter to encrypted ones)
|
||||
final stacksState = ref.watch(
|
||||
query<AppStack>(
|
||||
authors: {signedInPubkey},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'app-profile-saved-apps',
|
||||
subscriptionPrefix: 'app-profile-user-stacks',
|
||||
),
|
||||
);
|
||||
|
||||
final isLoading = savedAppsState is StorageLoading;
|
||||
// Filter to only encrypted (private) stacks
|
||||
final privateStacks = stacksState.models
|
||||
.where((s) => s.content.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
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);
|
||||
// Don't show section if no private stacks exist
|
||||
if (privateStacks.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (savedApps.isEmpty) {
|
||||
return _emptyState(context);
|
||||
}
|
||||
// Sort: Saved Apps first, then Installed Apps, then others alphabetically
|
||||
privateStacks.sort((a, b) {
|
||||
if (a.identifier == kAppBookmarksIdentifier) return -1;
|
||||
if (b.identifier == kAppBookmarksIdentifier) return 1;
|
||||
if (a.identifier == kInstalledAppsBackupIdentifier) return -1;
|
||||
if (b.identifier == kInstalledAppsBackupIdentifier) return 1;
|
||||
return (a.name ?? a.identifier).compareTo(b.name ?? b.identifier);
|
||||
});
|
||||
|
||||
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),
|
||||
],
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text('Your Private Stacks', style: context.textTheme.headlineSmall),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
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 == kInstalledAppsBackupIdentifier
|
||||
? 'Installed Apps'
|
||||
: null;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: index > 0 ? 8 : 0),
|
||||
child: StackLinkCard(stack: stack, displayName: displayName),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/widgets/common/stack_link_card.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/common/note_parser.dart';
|
||||
import '../widgets/common/profile_identity_row.dart';
|
||||
@@ -132,7 +132,10 @@ class UserScreen extends HookConsumerWidget {
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final stack = stacks[index];
|
||||
return _StackLinkCard(stack: stack, pubkey: pubkey);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: StackLinkCard(stack: stack),
|
||||
);
|
||||
}, childCount: stacks.length),
|
||||
),
|
||||
],
|
||||
@@ -259,65 +262,6 @@ class _UserZapsList extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _StackLinkCard extends StatelessWidget {
|
||||
const _StackLinkCard({required this.stack, required this.pubkey});
|
||||
|
||||
final AppStack stack;
|
||||
final String pubkey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: InkWell(
|
||||
onTap: () => pushStack(
|
||||
context,
|
||||
stack.identifier,
|
||||
author: pubkey,
|
||||
kind: stack.event.kind,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
stack.name ?? stack.identifier,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UserBio extends HookWidget {
|
||||
const _UserBio({required this.profile});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import 'package:zapstore/services/package_manager/background_package_manager.dar
|
||||
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/catalog_fetcher.dart';
|
||||
import 'package:zapstore/services/secure_storage_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
/// Unique task name for background update checking
|
||||
@@ -234,17 +234,17 @@ Future<void> _showUpdateNotificationIfNeeded(
|
||||
List<App> updates,
|
||||
Map<String, Installable> installables,
|
||||
) async {
|
||||
final secureStorage = SecureStorageService();
|
||||
final settingsService = SettingsService();
|
||||
final settings = await settingsService.load();
|
||||
|
||||
// Skip if user recently opened the app
|
||||
final lastOpened = await secureStorage.getLastAppOpenedTime();
|
||||
if (lastOpened != null &&
|
||||
DateTime.now().difference(lastOpened) < _inactivityThreshold) {
|
||||
if (settings.lastAppOpened != null &&
|
||||
DateTime.now().difference(settings.lastAppOpened!) < _inactivityThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the "seen until" timestamp - updates with createdAt > this are new
|
||||
final seenUntil = await secureStorage.getSeenUntil();
|
||||
final seenUntil = settings.seenUntil;
|
||||
|
||||
// Filter to only updates that are genuinely new:
|
||||
// - installable.createdAt > seenUntil (not already notified via background)
|
||||
@@ -261,7 +261,7 @@ Future<void> _showUpdateNotificationIfNeeded(
|
||||
}
|
||||
|
||||
// Must be newer than last app open (if any) - user may have seen it in UI
|
||||
if (lastOpened != null && !releaseTime.isAfter(lastOpened)) {
|
||||
if (settings.lastAppOpened != null && !releaseTime.isAfter(settings.lastAppOpened!)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ Future<void> _showUpdateNotificationIfNeeded(
|
||||
);
|
||||
|
||||
// Update seenUntil to now - future checks will only notify about releases after this
|
||||
await secureStorage.setSeenUntil(DateTime.now());
|
||||
await settingsService.update((s) => s.copyWith(seenUntil: DateTime.now()));
|
||||
}
|
||||
|
||||
/// Service for managing background update checks
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:models/models.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/constants/app_constants.dart';
|
||||
import 'package:zapstore/services/secure_storage_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
|
||||
/// Fetches NIP-09 kind-5 deletion events from the AppCatalog relay since the
|
||||
/// last sync cursor, applies them to local storage, and advances the cursor.
|
||||
@@ -15,14 +15,16 @@ import 'package:zapstore/services/secure_storage_service.dart';
|
||||
/// original author — those targets are deleted explicitly here.
|
||||
Future<void> processDeletions({
|
||||
required PurplebaseStorageNotifier storage,
|
||||
required SecureStorageService secureStorage,
|
||||
required SettingsService settingsService,
|
||||
required String subscriptionPrefix,
|
||||
}) async {
|
||||
final lastSync = await secureStorage.getDeletionsSyncedUntil();
|
||||
final settings = await settingsService.load();
|
||||
final lastSync = settings.deletionSyncedUntil;
|
||||
|
||||
// First run: no local data to delete, just seed the cursor.
|
||||
if (lastSync == null) {
|
||||
await secureStorage.setDeletionsSyncedUntil(DateTime.now());
|
||||
await settingsService.update(
|
||||
(s) => s.copyWith(deletionSyncedUntil: DateTime.now()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,7 +37,8 @@ Future<void> processDeletions({
|
||||
subscriptionPrefix: subscriptionPrefix,
|
||||
);
|
||||
|
||||
await secureStorage.setDeletionsSyncedUntil(DateTime.now());
|
||||
await settingsService.update(
|
||||
(s) => s.copyWith(deletionSyncedUntil: DateTime.now()));
|
||||
|
||||
if (deletionRequests.isEmpty) return;
|
||||
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:amber_signer/amber_signer.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Service for securely storing sensitive data (NWC connection strings,
|
||||
/// app catalog relays) using platform-native secure storage
|
||||
/// (Keychain on iOS, KeyStore on Android).
|
||||
///
|
||||
/// This does NOT require user authentication - data is encrypted at rest
|
||||
/// by the platform's secure storage mechanism.
|
||||
class SecureStorageService {
|
||||
SecureStorageService();
|
||||
|
||||
// Use explicit options for reliability across platforms
|
||||
static final _storage = FlutterSecureStorage(
|
||||
aOptions: const AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: const IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock,
|
||||
),
|
||||
);
|
||||
|
||||
static const _nwcKey = 'nwc_connection_string';
|
||||
static const _appCatalogRelaysKey = 'app_catalog_relays';
|
||||
|
||||
/// Get the stored NWC connection string
|
||||
Future<String?> getNWCString() async {
|
||||
final value = await _storage.read(key: _nwcKey);
|
||||
return (value?.isNotEmpty == true) ? value : null;
|
||||
}
|
||||
|
||||
/// Store an NWC connection string
|
||||
Future<void> setNWCString(String connectionString) async {
|
||||
await _storage.write(key: _nwcKey, value: connectionString);
|
||||
}
|
||||
|
||||
/// Clear the stored NWC connection string
|
||||
Future<void> clearNWCString() async {
|
||||
await _storage.delete(key: _nwcKey);
|
||||
}
|
||||
|
||||
/// Check if an NWC connection string is stored
|
||||
Future<bool> hasNWCString() async {
|
||||
final value = await _storage.read(key: _nwcKey);
|
||||
return value?.isNotEmpty == true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// App Open Tracking (for background notification throttling)
|
||||
// =========================================================================
|
||||
|
||||
static const _lastAppOpenedKey = 'last_app_opened';
|
||||
|
||||
/// Get the last time the user opened the app.
|
||||
Future<DateTime?> getLastAppOpenedTime() async {
|
||||
final value = await _storage.read(key: _lastAppOpenedKey);
|
||||
if (int.tryParse(value ?? '') case final ms?) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Store the last app opened time.
|
||||
Future<void> setLastAppOpenedTime(DateTime time) async {
|
||||
await _storage.write(
|
||||
key: _lastAppOpenedKey,
|
||||
value: '${time.millisecondsSinceEpoch}',
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Seen Until Timestamp (for background notification deduplication)
|
||||
// =========================================================================
|
||||
|
||||
static const _seenUntilKey = 'seen_until';
|
||||
|
||||
/// Get the "seen until" timestamp.
|
||||
/// Updates with release.createdAt <= this timestamp have already been notified.
|
||||
Future<DateTime?> getSeenUntil() async {
|
||||
final value = await _storage.read(key: _seenUntilKey);
|
||||
if (int.tryParse(value ?? '') case final ms?) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Store the "seen until" timestamp.
|
||||
/// Called when a notification is shown, set to now() so future checks
|
||||
/// only notify about releases created after this time.
|
||||
Future<void> setSeenUntil(DateTime time) async {
|
||||
await _storage.write(
|
||||
key: _seenUntilKey,
|
||||
value: '${time.millisecondsSinceEpoch}',
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// App Catalog Relays
|
||||
// =========================================================================
|
||||
|
||||
/// Get the stored app catalog relay URLs.
|
||||
///
|
||||
/// Returns null if no relays have been stored (use defaults).
|
||||
/// Returns empty set if user explicitly cleared all relays (invalid state,
|
||||
/// but handled gracefully).
|
||||
Future<Set<String>?> getAppCatalogRelays() async {
|
||||
final json = await _storage.read(key: _appCatalogRelaysKey);
|
||||
if (json == null || json.isEmpty) return null;
|
||||
try {
|
||||
final list = jsonDecode(json) as List;
|
||||
return Set<String>.from(list.cast<String>());
|
||||
} catch (e) {
|
||||
// Corrupted data - treat as unset
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Deletion Sync Timestamp (for NIP-09 incremental checks)
|
||||
// =========================================================================
|
||||
|
||||
static const _deletionSyncedUntilKey = 'deletion_synced_until';
|
||||
|
||||
/// Get the timestamp of the last successful NIP-09 deletion sync.
|
||||
/// Used as `since` on the next kind-5 query so checks are incremental.
|
||||
Future<DateTime?> getDeletionsSyncedUntil() async {
|
||||
final value = await _storage.read(key: _deletionSyncedUntilKey);
|
||||
if (int.tryParse(value ?? '') case final ms?) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Persist the deletion sync cursor after a successful kind-5 fetch.
|
||||
Future<void> setDeletionsSyncedUntil(DateTime time) async {
|
||||
await _storage.write(
|
||||
key: _deletionSyncedUntilKey,
|
||||
value: '${time.millisecondsSinceEpoch}',
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// App Catalog Relays
|
||||
// =========================================================================
|
||||
|
||||
/// Store app catalog relay URLs.
|
||||
///
|
||||
/// This is the local source of truth for relay configuration,
|
||||
/// used to initialize the app before sign-in.
|
||||
Future<void> setAppCatalogRelays(Set<String> relays) async {
|
||||
await _storage.write(
|
||||
key: _appCatalogRelaysKey,
|
||||
value: jsonEncode(relays.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Installed Apps Backup
|
||||
// =========================================================================
|
||||
|
||||
static const _installedAppsBackupKey = 'installed_apps_backup_enabled';
|
||||
|
||||
Future<bool> isInstalledAppsBackupEnabled() async {
|
||||
final value = await _storage.read(key: _installedAppsBackupKey);
|
||||
return value == 'true';
|
||||
}
|
||||
|
||||
Future<void> setInstalledAppsBackupEnabled(bool enabled) async {
|
||||
await _storage.write(key: _installedAppsBackupKey, value: '$enabled');
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the AmberSigner pubkey in flutter_secure_storage.
|
||||
/// This survives app data clears (database deletion) and is encrypted.
|
||||
class SecureStoragePubkeyPersistence implements AmberPubkeyPersistence {
|
||||
static const _key = 'amber_pubkey';
|
||||
|
||||
@override
|
||||
Future<void> persistPubkey(String pubkey) async {
|
||||
await SecureStorageService._storage.write(key: _key, value: pubkey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> loadPubkey() async {
|
||||
return SecureStorageService._storage.read(key: _key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearPubkey() async {
|
||||
await SecureStorageService._storage.delete(key: _key);
|
||||
}
|
||||
}
|
||||
|
||||
final secureStorageServiceProvider = Provider<SecureStorageService>(
|
||||
(ref) => SecureStorageService(),
|
||||
);
|
||||
|
||||
/// Whether an NWC connection string is currently stored.
|
||||
///
|
||||
/// Use `ref.invalidate(hasNwcStringProvider)` after updating or clearing the
|
||||
/// stored string to refresh UI.
|
||||
final hasNwcStringProvider = FutureProvider.autoDispose<bool>((ref) async {
|
||||
final secureStorage = ref.watch(secureStorageServiceProvider);
|
||||
return secureStorage.hasNWCString();
|
||||
});
|
||||
|
||||
/// Whether the installed apps backup setting is enabled.
|
||||
///
|
||||
/// Use `ref.invalidate(installedAppsBackupEnabledProvider)` after toggling
|
||||
/// the setting to refresh UI.
|
||||
final installedAppsBackupEnabledProvider =
|
||||
FutureProvider.autoDispose<bool>((ref) async {
|
||||
final secureStorage = ref.watch(secureStorageServiceProvider);
|
||||
return secureStorage.isInstalledAppsBackupEnabled();
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:amber_signer/amber_signer.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
/// All local settings stored as a single JSON blob in secure storage.
|
||||
class LocalSettings {
|
||||
final String? nwcConnectionString;
|
||||
final Set<String>? appCatalogRelays;
|
||||
final DateTime? lastAppOpened;
|
||||
final DateTime? seenUntil;
|
||||
final DateTime? deletionSyncedUntil;
|
||||
final bool installedAppsBackupEnabled;
|
||||
|
||||
const LocalSettings({
|
||||
this.nwcConnectionString,
|
||||
this.appCatalogRelays,
|
||||
this.lastAppOpened,
|
||||
this.seenUntil,
|
||||
this.deletionSyncedUntil,
|
||||
this.installedAppsBackupEnabled = false,
|
||||
});
|
||||
|
||||
bool get hasNwcString => nwcConnectionString?.isNotEmpty == true;
|
||||
|
||||
factory LocalSettings.fromJson(Map<String, dynamic> json) {
|
||||
return LocalSettings(
|
||||
nwcConnectionString: json['nwc'] as String?,
|
||||
appCatalogRelays: (json['relays'] as List?)?.cast<String>().toSet(),
|
||||
lastAppOpened: _parseDateTime(json['lastAppOpened']),
|
||||
seenUntil: _parseDateTime(json['seenUntil']),
|
||||
deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']),
|
||||
installedAppsBackupEnabled: json['backupEnabled'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (nwcConnectionString != null) 'nwc': nwcConnectionString,
|
||||
if (appCatalogRelays != null) 'relays': appCatalogRelays!.toList(),
|
||||
if (lastAppOpened != null)
|
||||
'lastAppOpened': lastAppOpened!.millisecondsSinceEpoch,
|
||||
if (seenUntil != null) 'seenUntil': seenUntil!.millisecondsSinceEpoch,
|
||||
if (deletionSyncedUntil != null)
|
||||
'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch,
|
||||
if (installedAppsBackupEnabled) 'backupEnabled': true,
|
||||
};
|
||||
|
||||
LocalSettings copyWith({
|
||||
String? nwcConnectionString,
|
||||
Set<String>? appCatalogRelays,
|
||||
DateTime? lastAppOpened,
|
||||
DateTime? seenUntil,
|
||||
DateTime? deletionSyncedUntil,
|
||||
bool? installedAppsBackupEnabled,
|
||||
bool clearNwc = false,
|
||||
}) {
|
||||
return LocalSettings(
|
||||
nwcConnectionString:
|
||||
clearNwc ? null : (nwcConnectionString ?? this.nwcConnectionString),
|
||||
appCatalogRelays: appCatalogRelays ?? this.appCatalogRelays,
|
||||
lastAppOpened: lastAppOpened ?? this.lastAppOpened,
|
||||
seenUntil: seenUntil ?? this.seenUntil,
|
||||
deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil,
|
||||
installedAppsBackupEnabled:
|
||||
installedAppsBackupEnabled ?? this.installedAppsBackupEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _parseDateTime(dynamic value) =>
|
||||
value is int ? DateTime.fromMillisecondsSinceEpoch(value) : null;
|
||||
}
|
||||
|
||||
/// Service for reading and writing local settings.
|
||||
class SettingsService {
|
||||
static const _key = 'settings';
|
||||
|
||||
// Legacy keys for migration
|
||||
static const _legacyNwcKey = 'nwc_connection_string';
|
||||
static const _legacyRelaysKey = 'app_catalog_relays';
|
||||
static const _legacyLastAppOpenedKey = 'last_app_opened';
|
||||
static const _legacySeenUntilKey = 'seen_until';
|
||||
static const _legacyDeletionSyncedUntilKey = 'deletion_synced_until';
|
||||
static const _legacyBackupKey = 'installed_apps_backup_enabled';
|
||||
|
||||
Future<LocalSettings> load() async {
|
||||
final json = await _storage.read(key: _key);
|
||||
if (json != null && json.isNotEmpty) {
|
||||
try {
|
||||
return LocalSettings.fromJson(jsonDecode(json) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return const LocalSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate from legacy format if present
|
||||
return _migrateFromLegacy();
|
||||
}
|
||||
|
||||
Future<LocalSettings> _migrateFromLegacy() async {
|
||||
final nwc = await _storage.read(key: _legacyNwcKey);
|
||||
final relaysJson = await _storage.read(key: _legacyRelaysKey);
|
||||
final lastAppOpened = await _storage.read(key: _legacyLastAppOpenedKey);
|
||||
final seenUntil = await _storage.read(key: _legacySeenUntilKey);
|
||||
final deletionSynced = await _storage.read(key: _legacyDeletionSyncedUntilKey);
|
||||
final backupEnabled = await _storage.read(key: _legacyBackupKey);
|
||||
|
||||
// No legacy data found
|
||||
if ([nwc, relaysJson, lastAppOpened, seenUntil, deletionSynced, backupEnabled]
|
||||
.every((v) => v == null || v.isEmpty)) {
|
||||
return const LocalSettings();
|
||||
}
|
||||
|
||||
// Parse legacy values
|
||||
Set<String>? relays;
|
||||
if (relaysJson != null && relaysJson.isNotEmpty) {
|
||||
try {
|
||||
relays = Set<String>.from((jsonDecode(relaysJson) as List).cast<String>());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final settings = LocalSettings(
|
||||
nwcConnectionString: (nwc?.isNotEmpty == true) ? nwc : null,
|
||||
appCatalogRelays: relays,
|
||||
lastAppOpened: _parseLegacyDateTime(lastAppOpened),
|
||||
seenUntil: _parseLegacyDateTime(seenUntil),
|
||||
deletionSyncedUntil: _parseLegacyDateTime(deletionSynced),
|
||||
installedAppsBackupEnabled: backupEnabled == 'true',
|
||||
);
|
||||
|
||||
// Save migrated settings and clean up legacy keys
|
||||
await save(settings);
|
||||
await Future.wait([
|
||||
_storage.delete(key: _legacyNwcKey),
|
||||
_storage.delete(key: _legacyRelaysKey),
|
||||
_storage.delete(key: _legacyLastAppOpenedKey),
|
||||
_storage.delete(key: _legacySeenUntilKey),
|
||||
_storage.delete(key: _legacyDeletionSyncedUntilKey),
|
||||
_storage.delete(key: _legacyBackupKey),
|
||||
]);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
static DateTime? _parseLegacyDateTime(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
final ms = int.tryParse(value);
|
||||
return ms != null ? DateTime.fromMillisecondsSinceEpoch(ms) : null;
|
||||
}
|
||||
|
||||
Future<void> save(LocalSettings settings) async {
|
||||
await _storage.write(key: _key, value: jsonEncode(settings.toJson()));
|
||||
}
|
||||
|
||||
Future<LocalSettings> update(
|
||||
LocalSettings Function(LocalSettings) updater) async {
|
||||
final current = await load();
|
||||
final updated = updater(current);
|
||||
await save(updated);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the AmberSigner pubkey in secure storage.
|
||||
class SecureStoragePubkeyPersistence implements AmberPubkeyPersistence {
|
||||
static const _key = 'amber_pubkey';
|
||||
|
||||
@override
|
||||
Future<void> persistPubkey(String pubkey) =>
|
||||
_storage.write(key: _key, value: pubkey);
|
||||
|
||||
@override
|
||||
Future<String?> loadPubkey() => _storage.read(key: _key);
|
||||
|
||||
@override
|
||||
Future<void> clearPubkey() => _storage.delete(key: _key);
|
||||
}
|
||||
|
||||
final settingsServiceProvider = Provider<SettingsService>(
|
||||
(ref) => SettingsService(),
|
||||
);
|
||||
|
||||
/// Current local settings. Invalidate after updates to refresh UI.
|
||||
final localSettingsProvider = FutureProvider<LocalSettings>((ref) async {
|
||||
return ref.watch(settingsServiceProvider).load();
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/services/catalog_fetcher.dart';
|
||||
import 'package:zapstore/services/deletion_processor.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/secure_storage_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
/// How often to poll for updates from remote relays
|
||||
@@ -160,7 +160,7 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
||||
),
|
||||
processDeletions(
|
||||
storage: storage,
|
||||
secureStorage: ref.read(secureStorageServiceProvider),
|
||||
settingsService: ref.read(settingsServiceProvider),
|
||||
subscriptionPrefix: 'app-deletions-poll',
|
||||
),
|
||||
]);
|
||||
@@ -195,10 +195,8 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
||||
final pubkey = ref.read(Signer.activePubkeyProvider);
|
||||
if (pubkey == null) return;
|
||||
|
||||
final enabled = await ref
|
||||
.read(secureStorageServiceProvider)
|
||||
.isInstalledAppsBackupEnabled();
|
||||
if (!enabled) return;
|
||||
final settings = await ref.read(settingsServiceProvider).load();
|
||||
if (!settings.installedAppsBackupEnabled) return;
|
||||
|
||||
final signer = ref.read(Signer.activeSignerProvider)!;
|
||||
final pmNotifier = ref.read(packageManagerProvider.notifier);
|
||||
|
||||
@@ -25,17 +25,17 @@ Set<String> getRawAppTagValues(AppStack stack) {
|
||||
/// Helper to compute preview app addressable IDs for a stack (3 apps max).
|
||||
/// Returns full addressable IDs (e.g. '32267:pubkey:identifier').
|
||||
List<String> getPreviewAddressableIds(AppStack stack) {
|
||||
final rawTags = getRawAppTagValues(stack)
|
||||
.where((id) => id.startsWith('32267:'))
|
||||
.toList()
|
||||
..shuffle(Random(stack.id.hashCode));
|
||||
final rawTags =
|
||||
getRawAppTagValues(stack).where((id) => id.startsWith('32267:')).toList()
|
||||
..shuffle(Random(stack.id.hashCode));
|
||||
return rawTags.take(3).toList();
|
||||
}
|
||||
|
||||
/// Decompose addressable IDs (e.g. '32267:pubkey:identifier') into
|
||||
/// the sets of authors and identifiers needed for a query filter.
|
||||
({Set<String> authors, Set<String> identifiers}) decomposeAddressableIds(
|
||||
Iterable<String> addressableIds) {
|
||||
Iterable<String> addressableIds,
|
||||
) {
|
||||
final authors = <String>{};
|
||||
final identifiers = <String>{};
|
||||
for (final id in addressableIds) {
|
||||
@@ -85,7 +85,7 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
tags: {
|
||||
'#f': {platform},
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'app-stack',
|
||||
schemaFilter: appStackEventFilter,
|
||||
),
|
||||
@@ -133,8 +133,7 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
: null;
|
||||
|
||||
final appsMap = {
|
||||
for (final app in previewAppsState?.models ?? <App>[])
|
||||
app.id: app,
|
||||
for (final app in previewAppsState?.models ?? <App>[]) app.id: app,
|
||||
};
|
||||
|
||||
// Infinite horizontal scroll: load more when near end
|
||||
@@ -393,23 +392,26 @@ class StackCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Builder(builder: (context) {
|
||||
final titleStyle = context.textTheme.titleMedium?.copyWith(
|
||||
fontFamily: 'Inter',
|
||||
fontSize:
|
||||
(context.textTheme.titleMedium?.fontSize ?? 16) * 0.9,
|
||||
);
|
||||
final lineHeight = (titleStyle?.fontSize ?? 14.4) *
|
||||
(titleStyle?.height ?? 1.2);
|
||||
return SizedBox(
|
||||
height: lineHeight * 2,
|
||||
child: _FadingText(
|
||||
stack.name ?? stack.identifier,
|
||||
style: titleStyle,
|
||||
maxLines: 2,
|
||||
),
|
||||
);
|
||||
}),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final titleStyle = context.textTheme.titleMedium?.copyWith(
|
||||
fontFamily: 'Inter',
|
||||
fontSize:
|
||||
(context.textTheme.titleMedium?.fontSize ?? 16) * 0.9,
|
||||
);
|
||||
final lineHeight =
|
||||
(titleStyle?.fontSize ?? 14.4) *
|
||||
(titleStyle?.height ?? 1.2);
|
||||
return SizedBox(
|
||||
height: lineHeight * 2,
|
||||
child: _FadingText(
|
||||
stack.name ?? stack.identifier,
|
||||
style: titleStyle,
|
||||
maxLines: 2,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (showAuthor) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
|
||||
/// Shared stack link card used in profile and user screens.
|
||||
/// Shows stack name, app count badge, and padlock icon for private stacks.
|
||||
class StackLinkCard extends HookConsumerWidget {
|
||||
const StackLinkCard({
|
||||
super.key,
|
||||
required this.stack,
|
||||
this.displayName,
|
||||
});
|
||||
|
||||
final AppStack stack;
|
||||
final String? displayName;
|
||||
|
||||
bool get _isEncrypted => stack.content.isNotEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appCount = useState<int?>(null);
|
||||
|
||||
useEffect(() {
|
||||
if (_isEncrypted) {
|
||||
Future<void> decrypt() async {
|
||||
final signer = ref.read(Signer.activeSignerProvider);
|
||||
final pubkey = ref.read(Signer.activePubkeyProvider);
|
||||
if (signer == null || pubkey == null) return;
|
||||
|
||||
try {
|
||||
final decrypted = await signer.nip44Decrypt(stack.content, pubkey);
|
||||
final ids = (jsonDecode(decrypted) as List).cast<String>();
|
||||
appCount.value = ids.length;
|
||||
} catch (_) {
|
||||
// Decryption failed, leave count as null
|
||||
}
|
||||
}
|
||||
|
||||
decrypt();
|
||||
} else {
|
||||
// Public stack: count 'a' tags
|
||||
final count = stack.event
|
||||
.getTagSetValues('a')
|
||||
.where((id) => id.startsWith('32267:'))
|
||||
.length;
|
||||
appCount.value = count;
|
||||
}
|
||||
return null;
|
||||
}, [stack.content, stack.id]);
|
||||
|
||||
final title = displayName ?? stack.name ?? stack.identifier;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
child: InkWell(
|
||||
onTap: () => pushStack(
|
||||
context,
|
||||
stack.identifier,
|
||||
author: stack.pubkey,
|
||||
kind: stack.event.kind,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (_isEncrypted) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.lock,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
if (appCount.value != null && appCount.value! > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${appCount.value}',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/services/secure_storage_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
@@ -13,8 +13,8 @@ class NWCConnectionCard extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasNwc = ref.watch(hasNwcStringProvider);
|
||||
final connected = hasNwc.maybeWhen(data: (v) => v, orElse: () => false);
|
||||
final settingsAsync = ref.watch(localSettingsProvider);
|
||||
final connected = settingsAsync.valueOrNull?.hasNwcString ?? false;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -42,7 +42,7 @@ class NWCConnectionCard extends HookConsumerWidget {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Connection Status
|
||||
hasNwc.when(
|
||||
settingsAsync.when(
|
||||
loading: () => Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
@@ -59,7 +59,7 @@ class NWCConnectionCard extends HookConsumerWidget {
|
||||
const Text('Checking wallet connection...'),
|
||||
],
|
||||
),
|
||||
data: (value) => value
|
||||
data: (settings) => settings.hasNwcString
|
||||
? Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -159,7 +159,7 @@ class NWCConnectionCard extends HookConsumerWidget {
|
||||
builder: (context) => NWCConnectionDialog(ref: ref),
|
||||
);
|
||||
if (connected == true) {
|
||||
ref.invalidate(hasNwcStringProvider);
|
||||
ref.invalidate(localSettingsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,9 +195,10 @@ class NWCConnectionCard extends HookConsumerWidget {
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final secureStorage = ref.read(secureStorageServiceProvider);
|
||||
await secureStorage.clearNWCString();
|
||||
ref.invalidate(hasNwcStringProvider);
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.update((s) => s.copyWith(clearNwc: true));
|
||||
ref.invalidate(localSettingsProvider);
|
||||
if (context.mounted) {
|
||||
context.showInfo(
|
||||
'Wallet disconnected',
|
||||
@@ -325,8 +326,9 @@ class NWCConnectionDialog extends HookWidget {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
final secureStorage = ref.read(secureStorageServiceProvider);
|
||||
await secureStorage.setNWCString(nwcString.trim());
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.update((s) => s.copyWith(nwcConnectionString: nwcString.trim()));
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context, true);
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/services/app_restart_service.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/services/secure_storage_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
|
||||
/// App Catalog Relay Management Card - manages app catalog relays.
|
||||
/// These are relays for discovering apps, NOT social relays like Damus/Primal.
|
||||
@@ -29,7 +29,7 @@ class RelayManagementCard extends HookConsumerWidget {
|
||||
|
||||
// Load local relays once - they only change on app restart
|
||||
final localRelaysFuture = useMemoized(
|
||||
() => ref.read(secureStorageServiceProvider).getAppCatalogRelays(),
|
||||
() async => (await ref.read(settingsServiceProvider).load()).appCatalogRelays,
|
||||
);
|
||||
final localRelaysSnapshot = useFuture(localRelaysFuture);
|
||||
final localRelays = localRelaysSnapshot.data?.toList()?..sort();
|
||||
@@ -154,15 +154,16 @@ class RelayManagementCard extends HookConsumerWidget {
|
||||
isApplying.value = true;
|
||||
|
||||
try {
|
||||
final secureStorage = ref.read(secureStorageServiceProvider);
|
||||
final settingsService = ref.read(settingsServiceProvider);
|
||||
final relaysToSave = displayRelays.toSet();
|
||||
|
||||
// Save to local secure storage
|
||||
await secureStorage.setAppCatalogRelays(relaysToSave);
|
||||
// Save to local settings
|
||||
final updated = await settingsService.update(
|
||||
(s) => s.copyWith(appCatalogRelays: relaysToSave));
|
||||
|
||||
// Verify write succeeded by reading back
|
||||
final verified = await secureStorage.getAppCatalogRelays();
|
||||
if (verified == null || !verified.containsAll(relaysToSave)) {
|
||||
// Verify write succeeded
|
||||
if (updated.appCatalogRelays == null ||
|
||||
!updated.appCatalogRelays!.containsAll(relaysToSave)) {
|
||||
throw StateError('Failed to persist relay configuration');
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/router.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/services/secure_storage_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/widgets/auth_widgets.dart';
|
||||
import 'package:zapstore/widgets/common/base_dialog.dart';
|
||||
@@ -242,9 +242,7 @@ class ZapAmountDialog extends HookConsumerWidget {
|
||||
],
|
||||
);
|
||||
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
final secureStorage = ref.watch(secureStorageServiceProvider);
|
||||
final hasNwc = ref.watch(hasNwcStringProvider);
|
||||
final knownHasNwc = hasNwc.valueOrNull;
|
||||
final settingsService = ref.watch(settingsServiceProvider);
|
||||
|
||||
return BaseDialog(
|
||||
titleIcon: const Text('⚡️'),
|
||||
@@ -428,10 +426,9 @@ class ZapAmountDialog extends HookConsumerWidget {
|
||||
await signer.signIn(registerSigner: false);
|
||||
}
|
||||
|
||||
// Read NWC from secure storage
|
||||
final nwcString = (knownHasNwc == false)
|
||||
? null
|
||||
: await secureStorage.getNWCString();
|
||||
// Read NWC from settings
|
||||
final settings = await settingsService.load();
|
||||
final nwcString = settings.nwcConnectionString;
|
||||
|
||||
// Build zap request
|
||||
final latestMetadata = app.installable;
|
||||
@@ -621,7 +618,7 @@ class NWCZapDialog extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useTextEditingController();
|
||||
final secureStorage = ref.watch(secureStorageServiceProvider);
|
||||
final settingsService = ref.watch(settingsServiceProvider);
|
||||
|
||||
return BaseDialog(
|
||||
titleIcon: const Text('⚡️'),
|
||||
@@ -697,8 +694,9 @@ class NWCZapDialog extends HookConsumerWidget {
|
||||
|
||||
unawaited(() async {
|
||||
try {
|
||||
await secureStorage.setNWCString(nwcString.trim());
|
||||
refAsRef.invalidate(hasNwcStringProvider);
|
||||
await settingsService.update(
|
||||
(s) => s.copyWith(nwcConnectionString: nwcString.trim()));
|
||||
refAsRef.invalidate(localSettingsProvider);
|
||||
await _executeZapPayment(signedZapRequest, nwcString, refAsRef);
|
||||
if (toastContext != null && toastContext.mounted) {
|
||||
toastContext.showInfo('⚡ Zap sent! $amount sats');
|
||||
|
||||
Reference in New Issue
Block a user