mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48: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/app_restart_service.dart';
|
||||||
import 'package:zapstore/services/background_update_service.dart';
|
import 'package:zapstore/services/background_update_service.dart';
|
||||||
import 'package:zapstore/services/notification_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/router.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/theme.dart';
|
import 'package:zapstore/theme.dart';
|
||||||
@@ -242,9 +242,8 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
|||||||
|
|
||||||
// Load local relay config BEFORE storage init
|
// Load local relay config BEFORE storage init
|
||||||
// This ensures custom relays work even when signed out
|
// This ensures custom relays work even when signed out
|
||||||
final secureStorage = ref.read(secureStorageServiceProvider);
|
final settings = await ref.read(settingsServiceProvider).load();
|
||||||
final localRelays = await secureStorage.getAppCatalogRelays();
|
final appCatalogRelays = settings.appCatalogRelays ?? {_kDefaultAppCatalogRelay};
|
||||||
final appCatalogRelays = localRelays ?? {_kDefaultAppCatalogRelay};
|
|
||||||
|
|
||||||
// Initialize storage with local relay config
|
// Initialize storage with local relay config
|
||||||
await ref.read(
|
await ref.read(
|
||||||
@@ -275,7 +274,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
|||||||
await DeviceCapabilitiesCache.initialize();
|
await DeviceCapabilitiesCache.initialize();
|
||||||
|
|
||||||
// Record app open time for background notification throttling
|
// 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
|
// Ensure installed packages are available before anything categorizes
|
||||||
final packageManager = ref.read(packageManagerProvider.notifier);
|
final packageManager = ref.read(packageManagerProvider.notifier);
|
||||||
@@ -303,10 +304,10 @@ Future<void> _maybeCopySeedDatabase(String dbPath) async {
|
|||||||
final dbFile = File(dbPath);
|
final dbFile = File(dbPath);
|
||||||
if (dbFile.existsSync()) return;
|
if (dbFile.existsSync()) return;
|
||||||
|
|
||||||
final customRelays = await SecureStorageService().getAppCatalogRelays();
|
final settings = await SettingsService().load();
|
||||||
final isDefault = customRelays == null ||
|
final isDefault = settings.appCatalogRelays == null ||
|
||||||
(customRelays.length == 1 &&
|
(settings.appCatalogRelays!.length == 1 &&
|
||||||
customRelays.contains(_kDefaultAppCatalogRelay));
|
settings.appCatalogRelays!.contains(_kDefaultAppCatalogRelay));
|
||||||
if (!isDefault) return;
|
if (!isDefault) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -402,7 +403,6 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
|||||||
/// Record that the user opened the app.
|
/// Record that the user opened the app.
|
||||||
/// This is used to check inactivity for background notifications.
|
/// This is used to check inactivity for background notifications.
|
||||||
Future<void> _recordAppOpened() async {
|
Future<void> _recordAppOpened() async {
|
||||||
final secureStorage = SecureStorageService();
|
await SettingsService().update((s) => s.copyWith(lastAppOpened: DateTime.now()));
|
||||||
await secureStorage.setLastAppOpenedTime(DateTime.now());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
@@ -44,12 +47,17 @@ class AppStackScreen extends HookConsumerWidget {
|
|||||||
final stack = stackState.models.firstOrNull;
|
final stack = stackState.models.firstOrNull;
|
||||||
|
|
||||||
if (stack == null) {
|
if (stack == null) {
|
||||||
return Scaffold(
|
// Still loading — show skeleton
|
||||||
body: SingleChildScrollView(
|
if (stackState is StorageLoading) {
|
||||||
padding: const EdgeInsets.all(16),
|
return Scaffold(
|
||||||
child: _AppStackSkeleton(),
|
body: SingleChildScrollView(
|
||||||
),
|
padding: const EdgeInsets.all(16),
|
||||||
);
|
child: _AppStackSkeleton(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Loading complete but stack not found
|
||||||
|
return _NotFoundScaffold(stackId: stackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _AppStackContentWithApps(stack: stack);
|
return _AppStackContentWithApps(stack: stack);
|
||||||
@@ -64,10 +72,63 @@ class _AppStackContentWithApps extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final appAddressableIds = stack.event
|
final isEncrypted = stack.content.isNotEmpty;
|
||||||
.getTagSetValues('a')
|
|
||||||
.where((id) => id.startsWith('32267:'))
|
// For encrypted stacks, decrypt content to get app IDs
|
||||||
.toSet();
|
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) {
|
if (appAddressableIds.isEmpty) {
|
||||||
return _AppStackContent(stack: stack, apps: const []);
|
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
|
/// Internal widget that displays stack details
|
||||||
class _AppStackContent extends HookConsumerWidget {
|
class _AppStackContent extends HookConsumerWidget {
|
||||||
final AppStack stack;
|
final AppStack stack;
|
||||||
final List<App> apps;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -211,11 +318,12 @@ class _AppStackContent extends HookConsumerWidget {
|
|||||||
...sortedApps.map(
|
...sortedApps.map(
|
||||||
(app) => AppCard(app: app, showUpdateArrow: app.hasUpdate),
|
(app) => AppCard(app: app, showUpdateArrow: app.hasUpdate),
|
||||||
),
|
),
|
||||||
// Comments section
|
// Comments section - hidden for private/encrypted stacks
|
||||||
Padding(
|
if (stack.content.isEmpty)
|
||||||
padding: const EdgeInsets.only(top: 24),
|
Padding(
|
||||||
child: StackCommentsSection(stack: stack),
|
padding: const EdgeInsets.only(top: 24),
|
||||||
),
|
child: StackCommentsSection(stack: stack),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -261,15 +369,36 @@ class _StackHeader extends StatelessWidget {
|
|||||||
final Profile? author;
|
final Profile? author;
|
||||||
final bool isAuthorLoading;
|
final bool isAuthorLoading;
|
||||||
|
|
||||||
|
bool get _isEncrypted => stack.content.isNotEmpty;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
final subtitleStyle = Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: subtitleColor,
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Stack name
|
// Stack name with padlock for private stacks
|
||||||
Text(
|
Row(
|
||||||
stack.name ?? stack.identifier,
|
children: [
|
||||||
style: context.textTheme.headlineMedium,
|
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),
|
const SizedBox(height: 8),
|
||||||
// Published by author - always show, with fallback to npub
|
// Published by author - always show, with fallback to npub
|
||||||
@@ -283,27 +412,34 @@ class _StackHeader extends StatelessWidget {
|
|||||||
onTap: () => pushUser(context, stack.pubkey),
|
onTap: () => pushUser(context, stack.pubkey),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// Last updated timestamp
|
// Metadata row: updated timestamp + private indicator
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.update, size: 14, color: subtitleColor),
|
||||||
Icons.update,
|
|
||||||
size: 14,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text('Updated ', style: subtitleStyle),
|
||||||
'Updated ',
|
TimeAgoText(stack.event.createdAt, style: subtitleStyle),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
if (_isEncrypted) ...[
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
const SizedBox(width: 8),
|
||||||
),
|
Container(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
TimeAgoText(
|
decoration: BoxDecoration(
|
||||||
stack.event.createdAt,
|
color: subtitleColor.withValues(alpha: 0.15),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
),
|
||||||
),
|
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?) ...[
|
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:url_launcher/url_launcher.dart';
|
||||||
import 'package:purplebase/purplebase.dart';
|
import 'package:purplebase/purplebase.dart';
|
||||||
import 'package:zapstore/main.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/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/extensions.dart';
|
||||||
import 'package:zapstore/utils/nostr_route.dart';
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
import 'package:zapstore/widgets/common/profile_identity_row.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/theme.dart';
|
||||||
import 'package:zapstore/services/notification_service.dart';
|
import 'package:zapstore/services/notification_service.dart';
|
||||||
import 'package:zapstore/widgets/common/note_parser.dart';
|
import 'package:zapstore/widgets/common/note_parser.dart';
|
||||||
@@ -42,13 +41,8 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Saved Apps Heading
|
// User Stacks Section (Saved Apps + Installed Apps)
|
||||||
const _SavedAppsHeading(),
|
const _UserStacksSection(),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Saved Apps Section
|
|
||||||
const _SavedAppsSection(),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
@@ -1404,8 +1398,8 @@ class _InstalledAppsBackupToggle extends ConsumerWidget {
|
|||||||
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
||||||
if (pubkey == null) return const SizedBox.shrink();
|
if (pubkey == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
final backupAsync = ref.watch(installedAppsBackupEnabledProvider);
|
final settingsAsync = ref.watch(localSettingsProvider);
|
||||||
final enabled = backupAsync.valueOrNull ?? false;
|
final enabled = settingsAsync.valueOrNull?.installedAppsBackupEnabled ?? false;
|
||||||
|
|
||||||
return SwitchListTile(
|
return SwitchListTile(
|
||||||
secondary: CircleAvatar(
|
secondary: CircleAvatar(
|
||||||
@@ -1419,9 +1413,9 @@ class _InstalledAppsBackupToggle extends ConsumerWidget {
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
await ref
|
await ref
|
||||||
.read(secureStorageServiceProvider)
|
.read(settingsServiceProvider)
|
||||||
.setInstalledAppsBackupEnabled(value);
|
.update((s) => s.copyWith(installedAppsBackupEnabled: value));
|
||||||
ref.invalidate(installedAppsBackupEnabledProvider);
|
ref.invalidate(localSettingsProvider);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1551,149 +1545,64 @@ class _DataManagementSection extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SavedAppsHeading extends ConsumerWidget {
|
class _UserStacksSection extends ConsumerWidget {
|
||||||
const _SavedAppsHeading();
|
const _UserStacksSection();
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
|
||||||
|
|
||||||
if (signedInPubkey == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always show heading when signed in
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: Text('Saved Apps', style: context.textTheme.headlineSmall),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SavedAppsSection extends ConsumerWidget {
|
|
||||||
const _SavedAppsSection();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
||||||
if (signedInPubkey == null) return const SizedBox.shrink();
|
if (signedInPubkey == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
// Query for ALL user's stacks (we'll filter to encrypted ones)
|
||||||
|
final stacksState = ref.watch(
|
||||||
// Keep previous value during refresh, if available.
|
query<AppStack>(
|
||||||
final addressableIds = savedAppsAsync.valueOrNull;
|
authors: {signedInPubkey},
|
||||||
|
|
||||||
// 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()},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
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()
|
// Don't show section if no private stacks exist
|
||||||
..sort(
|
if (privateStacks.isEmpty) {
|
||||||
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
|
return const SizedBox.shrink();
|
||||||
(b.name ?? b.identifier).toLowerCase(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show spinner only when we truly have nothing to render yet
|
|
||||||
// If we're refreshing but still have models, keep showing the list
|
|
||||||
if (isLoading && savedApps.isEmpty) {
|
|
||||||
return _loadingCard(context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedApps.isEmpty) {
|
// Sort: Saved Apps first, then Installed Apps, then others alphabetically
|
||||||
return _emptyState(context);
|
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(
|
return Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(16),
|
children: [
|
||||||
child: Column(
|
Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
children: [
|
child: Text('Your Private Stacks', style: context.textTheme.headlineSmall),
|
||||||
for (final app in savedApps)
|
|
||||||
AppCard(app: app, showUpdateArrow: false, showDescription: false),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
);
|
...privateStacks.asMap().entries.map((entry) {
|
||||||
}
|
final index = entry.key;
|
||||||
|
final stack = entry.value;
|
||||||
Widget _loadingCard(BuildContext context) {
|
final displayName = stack.identifier == kAppBookmarksIdentifier
|
||||||
return Card(
|
? 'Saved Apps'
|
||||||
child: Padding(
|
: stack.identifier == kInstalledAppsBackupIdentifier
|
||||||
padding: const EdgeInsets.all(16),
|
? 'Installed Apps'
|
||||||
child: Center(
|
: null;
|
||||||
child: CircularProgressIndicator(
|
return Padding(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
padding: EdgeInsets.only(top: index > 0 ? 8 : 0),
|
||||||
),
|
child: StackLinkCard(stack: stack, displayName: displayName),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
);
|
],
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.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/extensions.dart';
|
||||||
import 'package:zapstore/utils/nostr_route.dart';
|
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../widgets/common/note_parser.dart';
|
import '../widgets/common/note_parser.dart';
|
||||||
import '../widgets/common/profile_identity_row.dart';
|
import '../widgets/common/profile_identity_row.dart';
|
||||||
@@ -132,7 +132,10 @@ class UserScreen extends HookConsumerWidget {
|
|||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final stack = stacks[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),
|
}, 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 {
|
class _UserBio extends HookWidget {
|
||||||
const _UserBio({required this.profile});
|
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/dummy_package_manager.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/services/catalog_fetcher.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';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
|
||||||
/// Unique task name for background update checking
|
/// Unique task name for background update checking
|
||||||
@@ -234,17 +234,17 @@ Future<void> _showUpdateNotificationIfNeeded(
|
|||||||
List<App> updates,
|
List<App> updates,
|
||||||
Map<String, Installable> installables,
|
Map<String, Installable> installables,
|
||||||
) async {
|
) async {
|
||||||
final secureStorage = SecureStorageService();
|
final settingsService = SettingsService();
|
||||||
|
final settings = await settingsService.load();
|
||||||
|
|
||||||
// Skip if user recently opened the app
|
// Skip if user recently opened the app
|
||||||
final lastOpened = await secureStorage.getLastAppOpenedTime();
|
if (settings.lastAppOpened != null &&
|
||||||
if (lastOpened != null &&
|
DateTime.now().difference(settings.lastAppOpened!) < _inactivityThreshold) {
|
||||||
DateTime.now().difference(lastOpened) < _inactivityThreshold) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the "seen until" timestamp - updates with createdAt > this are new
|
// 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:
|
// Filter to only updates that are genuinely new:
|
||||||
// - installable.createdAt > seenUntil (not already notified via background)
|
// - 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
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ Future<void> _showUpdateNotificationIfNeeded(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Update seenUntil to now - future checks will only notify about releases after this
|
// 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
|
/// Service for managing background update checks
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:purplebase/purplebase.dart';
|
import 'package:purplebase/purplebase.dart';
|
||||||
import 'package:zapstore/constants/app_constants.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
|
/// 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.
|
/// 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.
|
/// original author — those targets are deleted explicitly here.
|
||||||
Future<void> processDeletions({
|
Future<void> processDeletions({
|
||||||
required PurplebaseStorageNotifier storage,
|
required PurplebaseStorageNotifier storage,
|
||||||
required SecureStorageService secureStorage,
|
required SettingsService settingsService,
|
||||||
required String subscriptionPrefix,
|
required String subscriptionPrefix,
|
||||||
}) async {
|
}) 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.
|
// First run: no local data to delete, just seed the cursor.
|
||||||
if (lastSync == null) {
|
if (lastSync == null) {
|
||||||
await secureStorage.setDeletionsSyncedUntil(DateTime.now());
|
await settingsService.update(
|
||||||
|
(s) => s.copyWith(deletionSyncedUntil: DateTime.now()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,8 @@ Future<void> processDeletions({
|
|||||||
subscriptionPrefix: subscriptionPrefix,
|
subscriptionPrefix: subscriptionPrefix,
|
||||||
);
|
);
|
||||||
|
|
||||||
await secureStorage.setDeletionsSyncedUntil(DateTime.now());
|
await settingsService.update(
|
||||||
|
(s) => s.copyWith(deletionSyncedUntil: DateTime.now()));
|
||||||
|
|
||||||
if (deletionRequests.isEmpty) return;
|
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/catalog_fetcher.dart';
|
||||||
import 'package:zapstore/services/deletion_processor.dart';
|
import 'package:zapstore/services/deletion_processor.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.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/extensions.dart';
|
||||||
|
|
||||||
/// How often to poll for updates from remote relays
|
/// How often to poll for updates from remote relays
|
||||||
@@ -160,7 +160,7 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
|||||||
),
|
),
|
||||||
processDeletions(
|
processDeletions(
|
||||||
storage: storage,
|
storage: storage,
|
||||||
secureStorage: ref.read(secureStorageServiceProvider),
|
settingsService: ref.read(settingsServiceProvider),
|
||||||
subscriptionPrefix: 'app-deletions-poll',
|
subscriptionPrefix: 'app-deletions-poll',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -195,10 +195,8 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
|||||||
final pubkey = ref.read(Signer.activePubkeyProvider);
|
final pubkey = ref.read(Signer.activePubkeyProvider);
|
||||||
if (pubkey == null) return;
|
if (pubkey == null) return;
|
||||||
|
|
||||||
final enabled = await ref
|
final settings = await ref.read(settingsServiceProvider).load();
|
||||||
.read(secureStorageServiceProvider)
|
if (!settings.installedAppsBackupEnabled) return;
|
||||||
.isInstalledAppsBackupEnabled();
|
|
||||||
if (!enabled) return;
|
|
||||||
|
|
||||||
final signer = ref.read(Signer.activeSignerProvider)!;
|
final signer = ref.read(Signer.activeSignerProvider)!;
|
||||||
final pmNotifier = ref.read(packageManagerProvider.notifier);
|
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).
|
/// Helper to compute preview app addressable IDs for a stack (3 apps max).
|
||||||
/// Returns full addressable IDs (e.g. '32267:pubkey:identifier').
|
/// Returns full addressable IDs (e.g. '32267:pubkey:identifier').
|
||||||
List<String> getPreviewAddressableIds(AppStack stack) {
|
List<String> getPreviewAddressableIds(AppStack stack) {
|
||||||
final rawTags = getRawAppTagValues(stack)
|
final rawTags =
|
||||||
.where((id) => id.startsWith('32267:'))
|
getRawAppTagValues(stack).where((id) => id.startsWith('32267:')).toList()
|
||||||
.toList()
|
..shuffle(Random(stack.id.hashCode));
|
||||||
..shuffle(Random(stack.id.hashCode));
|
|
||||||
return rawTags.take(3).toList();
|
return rawTags.take(3).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decompose addressable IDs (e.g. '32267:pubkey:identifier') into
|
/// Decompose addressable IDs (e.g. '32267:pubkey:identifier') into
|
||||||
/// the sets of authors and identifiers needed for a query filter.
|
/// the sets of authors and identifiers needed for a query filter.
|
||||||
({Set<String> authors, Set<String> identifiers}) decomposeAddressableIds(
|
({Set<String> authors, Set<String> identifiers}) decomposeAddressableIds(
|
||||||
Iterable<String> addressableIds) {
|
Iterable<String> addressableIds,
|
||||||
|
) {
|
||||||
final authors = <String>{};
|
final authors = <String>{};
|
||||||
final identifiers = <String>{};
|
final identifiers = <String>{};
|
||||||
for (final id in addressableIds) {
|
for (final id in addressableIds) {
|
||||||
@@ -85,7 +85,7 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
tags: {
|
tags: {
|
||||||
'#f': {platform},
|
'#f': {platform},
|
||||||
},
|
},
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
subscriptionPrefix: 'app-stack',
|
subscriptionPrefix: 'app-stack',
|
||||||
schemaFilter: appStackEventFilter,
|
schemaFilter: appStackEventFilter,
|
||||||
),
|
),
|
||||||
@@ -133,8 +133,7 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
final appsMap = {
|
final appsMap = {
|
||||||
for (final app in previewAppsState?.models ?? <App>[])
|
for (final app in previewAppsState?.models ?? <App>[]) app.id: app,
|
||||||
app.id: app,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Infinite horizontal scroll: load more when near end
|
// Infinite horizontal scroll: load more when near end
|
||||||
@@ -393,23 +392,26 @@ class StackCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Builder(builder: (context) {
|
Builder(
|
||||||
final titleStyle = context.textTheme.titleMedium?.copyWith(
|
builder: (context) {
|
||||||
fontFamily: 'Inter',
|
final titleStyle = context.textTheme.titleMedium?.copyWith(
|
||||||
fontSize:
|
fontFamily: 'Inter',
|
||||||
(context.textTheme.titleMedium?.fontSize ?? 16) * 0.9,
|
fontSize:
|
||||||
);
|
(context.textTheme.titleMedium?.fontSize ?? 16) * 0.9,
|
||||||
final lineHeight = (titleStyle?.fontSize ?? 14.4) *
|
);
|
||||||
(titleStyle?.height ?? 1.2);
|
final lineHeight =
|
||||||
return SizedBox(
|
(titleStyle?.fontSize ?? 14.4) *
|
||||||
height: lineHeight * 2,
|
(titleStyle?.height ?? 1.2);
|
||||||
child: _FadingText(
|
return SizedBox(
|
||||||
stack.name ?? stack.identifier,
|
height: lineHeight * 2,
|
||||||
style: titleStyle,
|
child: _FadingText(
|
||||||
maxLines: 2,
|
stack.name ?? stack.identifier,
|
||||||
),
|
style: titleStyle,
|
||||||
);
|
maxLines: 2,
|
||||||
}),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
if (showAuthor) ...[
|
if (showAuthor) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
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:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:zapstore/services/notification_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/theme.dart';
|
import 'package:zapstore/theme.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ class NWCConnectionCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final hasNwc = ref.watch(hasNwcStringProvider);
|
final settingsAsync = ref.watch(localSettingsProvider);
|
||||||
final connected = hasNwc.maybeWhen(data: (v) => v, orElse: () => false);
|
final connected = settingsAsync.valueOrNull?.hasNwcString ?? false;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -42,7 +42,7 @@ class NWCConnectionCard extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Connection Status
|
// Connection Status
|
||||||
hasNwc.when(
|
settingsAsync.when(
|
||||||
loading: () => Row(
|
loading: () => Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -59,7 +59,7 @@ class NWCConnectionCard extends HookConsumerWidget {
|
|||||||
const Text('Checking wallet connection...'),
|
const Text('Checking wallet connection...'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
data: (value) => value
|
data: (settings) => settings.hasNwcString
|
||||||
? Row(
|
? Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -159,7 +159,7 @@ class NWCConnectionCard extends HookConsumerWidget {
|
|||||||
builder: (context) => NWCConnectionDialog(ref: ref),
|
builder: (context) => NWCConnectionDialog(ref: ref),
|
||||||
);
|
);
|
||||||
if (connected == true) {
|
if (connected == true) {
|
||||||
ref.invalidate(hasNwcStringProvider);
|
ref.invalidate(localSettingsProvider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +195,10 @@ class NWCConnectionCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
try {
|
try {
|
||||||
final secureStorage = ref.read(secureStorageServiceProvider);
|
await ref
|
||||||
await secureStorage.clearNWCString();
|
.read(settingsServiceProvider)
|
||||||
ref.invalidate(hasNwcStringProvider);
|
.update((s) => s.copyWith(clearNwc: true));
|
||||||
|
ref.invalidate(localSettingsProvider);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.showInfo(
|
context.showInfo(
|
||||||
'Wallet disconnected',
|
'Wallet disconnected',
|
||||||
@@ -325,8 +326,9 @@ class NWCConnectionDialog extends HookWidget {
|
|||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final secureStorage = ref.read(secureStorageServiceProvider);
|
await ref
|
||||||
await secureStorage.setNWCString(nwcString.trim());
|
.read(settingsServiceProvider)
|
||||||
|
.update((s) => s.copyWith(nwcConnectionString: nwcString.trim()));
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context, true);
|
Navigator.pop(context, true);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:purplebase/purplebase.dart';
|
import 'package:purplebase/purplebase.dart';
|
||||||
import 'package:zapstore/services/app_restart_service.dart';
|
import 'package:zapstore/services/app_restart_service.dart';
|
||||||
import 'package:zapstore/services/notification_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.
|
/// App Catalog Relay Management Card - manages app catalog relays.
|
||||||
/// These are relays for discovering apps, NOT social relays like Damus/Primal.
|
/// 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
|
// Load local relays once - they only change on app restart
|
||||||
final localRelaysFuture = useMemoized(
|
final localRelaysFuture = useMemoized(
|
||||||
() => ref.read(secureStorageServiceProvider).getAppCatalogRelays(),
|
() async => (await ref.read(settingsServiceProvider).load()).appCatalogRelays,
|
||||||
);
|
);
|
||||||
final localRelaysSnapshot = useFuture(localRelaysFuture);
|
final localRelaysSnapshot = useFuture(localRelaysFuture);
|
||||||
final localRelays = localRelaysSnapshot.data?.toList()?..sort();
|
final localRelays = localRelaysSnapshot.data?.toList()?..sort();
|
||||||
@@ -154,15 +154,16 @@ class RelayManagementCard extends HookConsumerWidget {
|
|||||||
isApplying.value = true;
|
isApplying.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final secureStorage = ref.read(secureStorageServiceProvider);
|
final settingsService = ref.read(settingsServiceProvider);
|
||||||
final relaysToSave = displayRelays.toSet();
|
final relaysToSave = displayRelays.toSet();
|
||||||
|
|
||||||
// Save to local secure storage
|
// Save to local settings
|
||||||
await secureStorage.setAppCatalogRelays(relaysToSave);
|
final updated = await settingsService.update(
|
||||||
|
(s) => s.copyWith(appCatalogRelays: relaysToSave));
|
||||||
|
|
||||||
// Verify write succeeded by reading back
|
// Verify write succeeded
|
||||||
final verified = await secureStorage.getAppCatalogRelays();
|
if (updated.appCatalogRelays == null ||
|
||||||
if (verified == null || !verified.containsAll(relaysToSave)) {
|
!updated.appCatalogRelays!.containsAll(relaysToSave)) {
|
||||||
throw StateError('Failed to persist relay configuration');
|
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:models/models.dart';
|
||||||
import 'package:zapstore/router.dart';
|
import 'package:zapstore/router.dart';
|
||||||
import 'package:zapstore/services/notification_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/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
import 'package:zapstore/widgets/auth_widgets.dart';
|
import 'package:zapstore/widgets/auth_widgets.dart';
|
||||||
import 'package:zapstore/widgets/common/base_dialog.dart';
|
import 'package:zapstore/widgets/common/base_dialog.dart';
|
||||||
@@ -242,9 +242,7 @@ class ZapAmountDialog extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
||||||
final secureStorage = ref.watch(secureStorageServiceProvider);
|
final settingsService = ref.watch(settingsServiceProvider);
|
||||||
final hasNwc = ref.watch(hasNwcStringProvider);
|
|
||||||
final knownHasNwc = hasNwc.valueOrNull;
|
|
||||||
|
|
||||||
return BaseDialog(
|
return BaseDialog(
|
||||||
titleIcon: const Text('⚡️'),
|
titleIcon: const Text('⚡️'),
|
||||||
@@ -428,10 +426,9 @@ class ZapAmountDialog extends HookConsumerWidget {
|
|||||||
await signer.signIn(registerSigner: false);
|
await signer.signIn(registerSigner: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read NWC from secure storage
|
// Read NWC from settings
|
||||||
final nwcString = (knownHasNwc == false)
|
final settings = await settingsService.load();
|
||||||
? null
|
final nwcString = settings.nwcConnectionString;
|
||||||
: await secureStorage.getNWCString();
|
|
||||||
|
|
||||||
// Build zap request
|
// Build zap request
|
||||||
final latestMetadata = app.installable;
|
final latestMetadata = app.installable;
|
||||||
@@ -621,7 +618,7 @@ class NWCZapDialog extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final controller = useTextEditingController();
|
final controller = useTextEditingController();
|
||||||
final secureStorage = ref.watch(secureStorageServiceProvider);
|
final settingsService = ref.watch(settingsServiceProvider);
|
||||||
|
|
||||||
return BaseDialog(
|
return BaseDialog(
|
||||||
titleIcon: const Text('⚡️'),
|
titleIcon: const Text('⚡️'),
|
||||||
@@ -697,8 +694,9 @@ class NWCZapDialog extends HookConsumerWidget {
|
|||||||
|
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
await secureStorage.setNWCString(nwcString.trim());
|
await settingsService.update(
|
||||||
refAsRef.invalidate(hasNwcStringProvider);
|
(s) => s.copyWith(nwcConnectionString: nwcString.trim()));
|
||||||
|
refAsRef.invalidate(localSettingsProvider);
|
||||||
await _executeZapPayment(signedZapRequest, nwcString, refAsRef);
|
await _executeZapPayment(signedZapRequest, nwcString, refAsRef);
|
||||||
if (toastContext != null && toastContext.mounted) {
|
if (toastContext != null && toastContext.mounted) {
|
||||||
toastContext.showInfo('⚡ Zap sent! $amount sats');
|
toastContext.showInfo('⚡ Zap sent! $amount sats');
|
||||||
|
|||||||
Reference in New Issue
Block a user