mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
merge master
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
analyzer:
|
analyzer:
|
||||||
errors:
|
errors:
|
||||||
dangling_library_doc_comments: ignore
|
dangling_library_doc_comments: ignore
|
||||||
|
deprecated_member_use: ignore
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ class ZapstoreHome extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final appInitializationProvider = FutureProvider<void>((ref) async {
|
final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final dbPath = path.join(dir.path, 'zapstore.db');
|
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||||
|
|
||||||
// Clear storage if requested from a clear all operation
|
// Clear storage if requested from a clear all operation
|
||||||
|
|||||||
+100
-126
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:async_button_builder/async_button_builder.dart';
|
import 'package:async_button_builder/async_button_builder.dart';
|
||||||
|
import 'package:auto_size_text/auto_size_text.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -1379,12 +1380,14 @@ class _DataManagementSection extends ConsumerWidget {
|
|||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: AutoSizeText(
|
||||||
'Clear local storage',
|
'Clear local storage',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
minFontSize: 12,
|
||||||
),
|
),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
onTap: () => _showClearAllDataDialog(context, ref),
|
onTap: () => _showClearAllDataDialog(context, ref),
|
||||||
@@ -1403,7 +1406,7 @@ class _DataManagementSection extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
|
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text('Clear local storage'),
|
Flexible(child: const Text('Clear local storage')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
@@ -1477,80 +1480,111 @@ class _SavedAppsHeading extends ConsumerWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
// Always show heading when signed in
|
||||||
|
return Padding(
|
||||||
return savedAppsAsync.when(
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
loading: () => const SizedBox.shrink(),
|
child: Text('Saved Apps', style: context.textTheme.headlineSmall),
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
|
||||||
data: (addressableIds) {
|
|
||||||
if (addressableIds.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
final identifiers = addressableIds
|
|
||||||
.map((id) {
|
|
||||||
final parts = id.split(':');
|
|
||||||
return parts.length >= 3 ? parts[2] : null;
|
|
||||||
})
|
|
||||||
.whereType<String>()
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
if (identifiers.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: Text('Saved Apps', style: context.textTheme.headlineSmall),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SavedAppsSection extends HookConsumerWidget {
|
class _SavedAppsSection extends ConsumerWidget {
|
||||||
const _SavedAppsSection();
|
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);
|
||||||
final hasLoadedOnce = useState(false);
|
if (signedInPubkey == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
if (signedInPubkey == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
final savedAppsAsync = ref.watch(bookmarksProvider);
|
||||||
|
|
||||||
return savedAppsAsync.when(
|
// Keep previous value during refresh, if available.
|
||||||
loading: () => _savedAppsLoadingCard(context),
|
final addressableIds = savedAppsAsync.valueOrNull;
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
|
||||||
data: (addressableIds) {
|
|
||||||
if (addressableIds.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
final identifiers = addressableIds
|
// Show loading only on first load (when no value exists yet).
|
||||||
.map((id) {
|
if (addressableIds == null) {
|
||||||
final parts = id.split(':');
|
return _savedAppsLoadingCard(context);
|
||||||
return parts.length >= 3 ? parts[2] : null;
|
}
|
||||||
})
|
|
||||||
.whereType<String>()
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
if (identifiers.isEmpty) {
|
final identifiers = _toIdentifiers(addressableIds);
|
||||||
return const SizedBox.shrink();
|
return _SavedAppsList(identifiers: identifiers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _SavedAppsList(
|
Set<String> _toIdentifiers(Set<String> addressableIds) {
|
||||||
identifiers: identifiers,
|
return addressableIds
|
||||||
hasLoadedOnce: hasLoadedOnce,
|
.map((id) => id.split(':'))
|
||||||
);
|
.where((parts) => parts.length >= 3)
|
||||||
},
|
.map((parts) => parts[2])
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _savedAppsLoadingCard(BuildContext context) => Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SavedAppsList extends ConsumerWidget {
|
||||||
|
const _SavedAppsList({required this.identifiers});
|
||||||
|
|
||||||
|
final Set<String> identifiers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// No bookmarks saved - show empty state without querying
|
||||||
|
if (identifiers.isEmpty) {
|
||||||
|
return _emptyState(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
final savedAppsState = ref.watch(
|
||||||
|
query<App>(
|
||||||
|
tags: {'#d': identifiers},
|
||||||
|
and: (app) => {app.latestRelease.query()},
|
||||||
|
source: const LocalSource(),
|
||||||
|
subscriptionPrefix: 'profile-saved-apps',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final isLoading = savedAppsState is StorageLoading;
|
||||||
|
|
||||||
|
final savedApps = savedAppsState.models.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
|
||||||
|
(b.name ?? b.identifier).toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show spinner only when we truly have nothing to render yet
|
||||||
|
// If we're refreshing but still have models, keep showing the list
|
||||||
|
if (isLoading && savedApps.isEmpty) {
|
||||||
|
return _loadingCard(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedApps.isEmpty) {
|
||||||
|
return _emptyState(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (final app in savedApps)
|
||||||
|
AppCard(app: app, showUpdateArrow: false, showDescription: false),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _savedAppsLoadingCard(BuildContext context) {
|
Widget _loadingCard(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -1562,75 +1596,15 @@ class _SavedAppsSection extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _SavedAppsList extends ConsumerWidget {
|
Widget _emptyState(BuildContext context) {
|
||||||
const _SavedAppsList({
|
final theme = Theme.of(context);
|
||||||
required this.identifiers,
|
return Padding(
|
||||||
required this.hasLoadedOnce,
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
});
|
child: Text(
|
||||||
|
'No saved apps yet',
|
||||||
final Set<String> identifiers;
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
final ValueNotifier<bool> hasLoadedOnce;
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final savedAppsState = ref.watch(
|
|
||||||
query<App>(
|
|
||||||
tags: {'#d': identifiers},
|
|
||||||
and: (app) => {app.latestRelease.query()},
|
|
||||||
source: const LocalSource(),
|
|
||||||
subscriptionPrefix: 'profile-saved-apps',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final savedApps = savedAppsState.models.toList()
|
|
||||||
..sort(
|
|
||||||
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
|
|
||||||
(b.name ?? b.identifier).toLowerCase(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if we have loaded at least once - defer state update to after build
|
|
||||||
if (savedApps.isNotEmpty && !hasLoadedOnce.value) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
hasLoadedOnce.value = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state only if we haven't loaded any apps yet
|
|
||||||
if (savedApps.isEmpty && !hasLoadedOnce.value) {
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide if no apps after loading
|
|
||||||
if (savedApps.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: savedApps
|
|
||||||
.map(
|
|
||||||
(app) => AppCard(
|
|
||||||
app: app,
|
|
||||||
showUpdateArrow: false,
|
|
||||||
showDescription: false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class _SearchResultsSection extends HookConsumerWidget {
|
|||||||
// Force the search to hit the default relay group (relay.zapstore.dev)
|
// Force the search to hit the default relay group (relay.zapstore.dev)
|
||||||
// so a connection appears in Debug Info when searching.
|
// so a connection appears in Debug Info when searching.
|
||||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
subscriptionPrefix: 'search-results',
|
subscriptionPrefix: 'app-search-results',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ class _ConnectionStatusIndicator extends ConsumerWidget {
|
|||||||
final poolState = ref.watch(poolStateProvider);
|
final poolState = ref.watch(poolStateProvider);
|
||||||
final subscriptions = poolState?.subscriptions ?? {};
|
final subscriptions = poolState?.subscriptions ?? {};
|
||||||
|
|
||||||
// Filter to only "updates" subscription
|
// Filter to only "app-updates" subscription
|
||||||
final updatesSubs = subscriptions.entries
|
final updatesSubs = subscriptions.entries
|
||||||
.where((e) => e.key.startsWith('updates'))
|
.where((e) => e.key.startsWith('app-updates'))
|
||||||
.map((e) => e.value);
|
.map((e) => e.value);
|
||||||
|
|
||||||
// Check relay status for updates subscription only
|
// Check relay status for updates subscription only
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -49,7 +50,7 @@ class UserScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
subscriptionPrefix: 'user-apps',
|
subscriptionPrefix: 'app-user-apps',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -382,34 +383,50 @@ class _NpubRow extends StatelessWidget {
|
|||||||
final abbreviatedNpub =
|
final abbreviatedNpub =
|
||||||
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
|
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
|
||||||
|
|
||||||
return GestureDetector(
|
return Row(
|
||||||
onTap: () => launchUrl(
|
mainAxisSize: MainAxisSize.min,
|
||||||
Uri.parse('https://npub.world/$npub'),
|
children: [
|
||||||
mode: LaunchMode.externalApplication,
|
GestureDetector(
|
||||||
),
|
onTap: () {
|
||||||
child: Row(
|
Clipboard.setData(ClipboardData(text: npub));
|
||||||
mainAxisSize: MainAxisSize.min,
|
},
|
||||||
children: [
|
child: Icon(
|
||||||
Icon(
|
Icons.copy,
|
||||||
Icons.key,
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
abbreviatedNpub,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(
|
|
||||||
Icons.open_in_new,
|
|
||||||
size: 12,
|
size: 12,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => launchUrl(
|
||||||
|
Uri.parse('https://npub.world/$npub'),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.key,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
abbreviatedNpub,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
Icons.open_in_new,
|
||||||
|
size: 12,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const _markerFileName = '.clear_on_restart';
|
|||||||
/// Checks if storage should be cleared on this launch, and clears the marker.
|
/// Checks if storage should be cleared on this launch, and clears the marker.
|
||||||
/// Call this BEFORE initializing storage.
|
/// Call this BEFORE initializing storage.
|
||||||
Future<void> maybeClearStorage(String dbPath) async {
|
Future<void> maybeClearStorage(String dbPath) async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final marker = File('${dir.path}/$_markerFileName');
|
final marker = File('${dir.path}/$_markerFileName');
|
||||||
if (await marker.exists()) {
|
if (await marker.exists()) {
|
||||||
final dbFile = File(dbPath);
|
final dbFile = File(dbPath);
|
||||||
@@ -22,7 +22,7 @@ Future<void> maybeClearStorage(String dbPath) async {
|
|||||||
|
|
||||||
/// Sets a marker file and triggers a native app restart.
|
/// Sets a marker file and triggers a native app restart.
|
||||||
Future<void> restartApp() async {
|
Future<void> restartApp() async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final marker = File('${dir.path}/$_markerFileName');
|
final marker = File('${dir.path}/$_markerFileName');
|
||||||
await marker.create();
|
await marker.create();
|
||||||
await _channel.invokeMethod('restart');
|
await _channel.invokeMethod('restart');
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:io' show Directory, Platform;
|
import 'dart:io' show Directory, File, Platform;
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart' hide Request;
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:flutter/widgets.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:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
@@ -9,7 +11,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:purplebase/purplebase.dart';
|
import 'package:purplebase/purplebase.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
import 'package:zapstore/services/package_manager/android_package_manager.dart';
|
import 'package:zapstore/services/package_manager/background_package_manager.dart';
|
||||||
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/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
@@ -42,6 +44,8 @@ const kAppCatalogRelaysKey = 'appCatalogRelays';
|
|||||||
/// This MUST be a top-level function (not a class method).
|
/// This MUST be a top-level function (not a class method).
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void callbackDispatcher() {
|
void callbackDispatcher() {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
ui.DartPluginRegistrant.ensureInitialized();
|
||||||
Workmanager().executeTask((task, inputData) async {
|
Workmanager().executeTask((task, inputData) async {
|
||||||
switch (task) {
|
switch (task) {
|
||||||
case kBackgroundUpdateTaskName:
|
case kBackgroundUpdateTaskName:
|
||||||
@@ -81,11 +85,8 @@ Future<bool> _performWeeklyCleanup() async {
|
|||||||
try {
|
try {
|
||||||
// Delete the file if it exists
|
// Delete the file if it exists
|
||||||
final filePath = await task.filePath();
|
final filePath = await task.filePath();
|
||||||
final file = Directory(filePath).parent.listSync().firstWhere(
|
final file = File(filePath);
|
||||||
(f) => f.path.endsWith(task.filename),
|
if (await file.exists()) {
|
||||||
orElse: () => Directory(''),
|
|
||||||
);
|
|
||||||
if (file.path.isNotEmpty) {
|
|
||||||
await file.delete();
|
await file.delete();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -105,14 +106,14 @@ Future<bool> _performWeeklyCleanup() async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (await downloadDir.exists()) {
|
if (await downloadDir.exists()) {
|
||||||
final files = downloadDir.listSync();
|
final entities = downloadDir.listSync();
|
||||||
final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
|
final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
|
||||||
|
|
||||||
for (final file in files) {
|
for (final entity in entities) {
|
||||||
if (file is! Directory) {
|
if (entity is File) {
|
||||||
final stat = await file.stat();
|
final stat = await entity.stat();
|
||||||
if (stat.modified.isBefore(cutoff)) {
|
if (stat.modified.isBefore(cutoff)) {
|
||||||
await file.delete();
|
await entity.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +146,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
|||||||
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
|
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
|
||||||
packageManagerProvider.overrideWith(
|
packageManagerProvider.overrideWith(
|
||||||
(ref) => Platform.isAndroid
|
(ref) => Platform.isAndroid
|
||||||
? AndroidPackageManager(ref)
|
? BackgroundPackageManager(ref)
|
||||||
: DummyPackageManager(ref),
|
: DummyPackageManager(ref),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -153,7 +154,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize Purplebase with same DB path as main app
|
// Initialize Purplebase with same DB path as main app
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final dbPath = path.join(dir.path, 'zapstore.db');
|
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||||
|
|
||||||
await container.read(
|
await container.read(
|
||||||
@@ -188,18 +189,40 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
|||||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load releases for all apps in a single query
|
// Load releases and their metadata/assets (required for hasUpdate)
|
||||||
if (apps.isNotEmpty) {
|
if (apps.isNotEmpty) {
|
||||||
final addressableIds = apps
|
final releaseFilters = apps
|
||||||
.map((app) => app.event.addressableId)
|
.map((app) => app.latestRelease.req?.filters.firstOrNull)
|
||||||
.toSet();
|
.nonNulls
|
||||||
await storage.query(
|
.toList();
|
||||||
RequestFilter<Release>(tags: {'#a': addressableIds}).toRequest(),
|
if (releaseFilters.isNotEmpty) {
|
||||||
source: const LocalAndRemoteSource(
|
final List<Release> releases = await storage.query(
|
||||||
relays: 'AppCatalog',
|
Request<Release>(releaseFilters),
|
||||||
stream: false,
|
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
|
final metadataFilters = releases
|
||||||
|
.map((r) => r.latestMetadata.req?.filters.firstOrNull)
|
||||||
|
.nonNulls
|
||||||
|
.toList();
|
||||||
|
if (metadataFilters.isNotEmpty) {
|
||||||
|
await storage.query(
|
||||||
|
Request<FileMetadata>(metadataFilters),
|
||||||
|
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final assetFilters = releases
|
||||||
|
.map((r) => r.latestAsset.req?.filters.firstOrNull)
|
||||||
|
.nonNulls
|
||||||
|
.toList();
|
||||||
|
if (assetFilters.isNotEmpty) {
|
||||||
|
await storage.query(
|
||||||
|
Request<SoftwareAsset>(assetFilters),
|
||||||
|
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-query apps from local to ensure relationships are loaded
|
// Re-query apps from local to ensure relationships are loaded
|
||||||
@@ -255,6 +278,7 @@ Future<void> _showUpdateNotification(
|
|||||||
android: initializationSettingsAndroid,
|
android: initializationSettingsAndroid,
|
||||||
);
|
);
|
||||||
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
||||||
|
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
|
||||||
|
|
||||||
// Build notification content
|
// Build notification content
|
||||||
final title = updateCount == 1
|
final title = updateCount == 1
|
||||||
@@ -366,23 +390,7 @@ class BackgroundUpdateService {
|
|||||||
// Navigation is handled by the app's normal launch flow
|
// Navigation is handled by the app's normal launch flow
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
|
||||||
// Create notification channel on Android
|
|
||||||
final androidPlugin = flutterLocalNotificationsPlugin
|
|
||||||
.resolvePlatformSpecificImplementation<
|
|
||||||
AndroidFlutterLocalNotificationsPlugin
|
|
||||||
>();
|
|
||||||
|
|
||||||
if (androidPlugin != null) {
|
|
||||||
await androidPlugin.createNotificationChannel(
|
|
||||||
const AndroidNotificationChannel(
|
|
||||||
kUpdateNotificationChannelId,
|
|
||||||
kUpdateNotificationChannelName,
|
|
||||||
description: kUpdateNotificationChannelDescription,
|
|
||||||
importance: Importance.defaultImportance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel background update checks
|
/// Cancel background update checks
|
||||||
@@ -405,6 +413,26 @@ class BackgroundUpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureUpdateNotificationChannel(
|
||||||
|
FlutterLocalNotificationsPlugin plugin,
|
||||||
|
) async {
|
||||||
|
final androidPlugin = plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
|
||||||
|
if (androidPlugin != null) {
|
||||||
|
await androidPlugin.createNotificationChannel(
|
||||||
|
const AndroidNotificationChannel(
|
||||||
|
kUpdateNotificationChannelId,
|
||||||
|
kUpdateNotificationChannelName,
|
||||||
|
description: kUpdateNotificationChannelDescription,
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Provider for the background update service
|
/// Provider for the background update service
|
||||||
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
|
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
|
||||||
BackgroundUpdateService.new,
|
BackgroundUpdateService.new,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
|
||||||
/// Install status values from native side.
|
/// Install status values from native side.
|
||||||
@@ -600,6 +601,7 @@ final class AndroidPackageManager extends PackageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(installed: {...packages, ...preserved});
|
state = state.copyWith(installed: {...packages, ...preserved});
|
||||||
|
await InstalledPackagesSnapshot.save(state.installed);
|
||||||
|
|
||||||
// Clear operations for apps where the installed version matches the target version
|
// Clear operations for apps where the installed version matches the target version
|
||||||
// This catches installs that succeeded but we missed the event
|
// This catches installs that succeeded but we missed the event
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
|
||||||
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
|
||||||
|
/// Background-safe PackageManager that avoids EventChannel usage.
|
||||||
|
final class BackgroundPackageManager extends PackageManager {
|
||||||
|
BackgroundPackageManager(super.ref);
|
||||||
|
|
||||||
|
// Zapstore currently targets arm64 APKs for background checks.
|
||||||
|
@override
|
||||||
|
String get platform => 'android-arm64-v8a';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get packageExtension => '.apk';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get supportsSilentInstall => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> install(
|
||||||
|
String appId,
|
||||||
|
String filePath, {
|
||||||
|
required String expectedHash,
|
||||||
|
required int expectedSize,
|
||||||
|
required FileMetadata target,
|
||||||
|
}) {
|
||||||
|
throw UnsupportedError('Install not supported in background');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> uninstall(String appId) {
|
||||||
|
throw UnsupportedError('Uninstall not supported in background');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> launchApp(String appId) {
|
||||||
|
throw UnsupportedError('Launch not supported in background');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestPermission() {
|
||||||
|
throw UnsupportedError('Permission not supported in background');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hasPermission() async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncInstalledPackages() async {
|
||||||
|
final installed = await InstalledPackagesSnapshot.load();
|
||||||
|
state = state.copyWith(installed: installed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
|
||||||
|
class InstalledPackagesSnapshot {
|
||||||
|
static const _fileName = 'installed_packages_snapshot.json';
|
||||||
|
|
||||||
|
static Future<File> _file() async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
return File(path.join(dir.path, _fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> save(Map<String, PackageInfo> installed) async {
|
||||||
|
try {
|
||||||
|
final file = await _file();
|
||||||
|
final tmp = File('${file.path}.tmp');
|
||||||
|
final list = installed.values
|
||||||
|
.map(
|
||||||
|
(p) => <String, dynamic>{
|
||||||
|
'appId': p.appId,
|
||||||
|
'name': p.name,
|
||||||
|
'version': p.version,
|
||||||
|
'versionCode': p.versionCode,
|
||||||
|
'signatureHash': p.signatureHash,
|
||||||
|
'canInstallSilently': p.canInstallSilently,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
final payload = jsonEncode({
|
||||||
|
'v': 1,
|
||||||
|
'savedAt': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'installed': list,
|
||||||
|
});
|
||||||
|
await tmp.writeAsString(payload, flush: true);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
await tmp.rename(file.path);
|
||||||
|
} catch (e) {
|
||||||
|
// Best-effort snapshot only.
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[InstalledPackagesSnapshot] Save failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, PackageInfo>> load() async {
|
||||||
|
try {
|
||||||
|
final file = await _file();
|
||||||
|
if (!await file.exists()) return {};
|
||||||
|
|
||||||
|
final raw = await file.readAsString();
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map) return {};
|
||||||
|
final version = decoded['v'];
|
||||||
|
if (version != null && version is! int && kDebugMode) {
|
||||||
|
debugPrint('[InstalledPackagesSnapshot] Unknown schema: $version');
|
||||||
|
}
|
||||||
|
final installed = decoded['installed'];
|
||||||
|
if (installed is! List) return {};
|
||||||
|
|
||||||
|
final result = <String, PackageInfo>{};
|
||||||
|
for (final item in installed) {
|
||||||
|
if (item is! Map) continue;
|
||||||
|
final map = Map<String, dynamic>.from(item);
|
||||||
|
final appId = map['appId'] as String?;
|
||||||
|
if (appId == null || appId.isEmpty) continue;
|
||||||
|
result[appId] = PackageInfo(
|
||||||
|
appId: appId,
|
||||||
|
name: map['name'] as String?,
|
||||||
|
version: (map['version'] as String?) ?? '0.0.0',
|
||||||
|
versionCode: map['versionCode'] as int?,
|
||||||
|
signatureHash: (map['signatureHash'] as String?) ?? '',
|
||||||
|
installTime: null,
|
||||||
|
canInstallSilently: (map['canInstallSilently'] as bool?) ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[InstalledPackagesSnapshot] Load failed: $e');
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||||
subscriptionPrefix: 'updates',
|
subscriptionPrefix: 'app-updates',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -735,7 +735,7 @@ class DebugVersionsSection extends HookConsumerWidget {
|
|||||||
tags: app.event.addressableIdTagMap,
|
tags: app.event.addressableIdTagMap,
|
||||||
and: (release) => {release.latestMetadata.query(), release.latestAsset.query()},
|
and: (release) => {release.latestMetadata.query(), release.latestAsset.query()},
|
||||||
source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
subscriptionPrefix: 'debug-releases',
|
subscriptionPrefix: 'app-debug-releases',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,7 @@ String? _extractIdentifier(String addressableId) {
|
|||||||
List<String> _getPreviewIdentifiers(AppStack stack) {
|
List<String> _getPreviewIdentifiers(AppStack stack) {
|
||||||
final rawTags = _getRawAppTagValues(stack).toList()
|
final rawTags = _getRawAppTagValues(stack).toList()
|
||||||
..shuffle(Random(stack.id.hashCode));
|
..shuffle(Random(stack.id.hashCode));
|
||||||
return rawTags
|
return rawTags.take(3).map(_extractIdentifier).whereType<String>().toList();
|
||||||
.take(3)
|
|
||||||
.map(_extractIdentifier)
|
|
||||||
.whereType<String>()
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort app stacks: franzap/following first, then by recency
|
/// Sort app stacks: franzap/following first, then by recency
|
||||||
@@ -67,10 +63,12 @@ List<AppStack> _sortStacks(
|
|||||||
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||||
return [...followed, ...others];
|
return [...followed, ...others];
|
||||||
} else {
|
} else {
|
||||||
final franzapStacks =
|
final franzapStacks = stacks
|
||||||
stacks.where((s) => s.pubkey == kFranzapPubkey).toList();
|
.where((s) => s.pubkey == kFranzapPubkey)
|
||||||
final otherStacks =
|
.toList();
|
||||||
stacks.where((s) => s.pubkey != kFranzapPubkey).toList();
|
final otherStacks = stacks
|
||||||
|
.where((s) => s.pubkey != kFranzapPubkey)
|
||||||
|
.toList();
|
||||||
|
|
||||||
franzapStacks.shuffle(random);
|
franzapStacks.shuffle(random);
|
||||||
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||||
@@ -198,8 +196,10 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
scrollController.position.maxScrollExtent - 200) {
|
scrollController.position.maxScrollExtent - 200) {
|
||||||
// Load more if we haven't shown all stacks yet
|
// Load more if we haven't shown all stacks yet
|
||||||
if (visibleCount.value < sortedStacks.length) {
|
if (visibleCount.value < sortedStacks.length) {
|
||||||
visibleCount.value = (visibleCount.value + _kBatchSize)
|
visibleCount.value = (visibleCount.value + _kBatchSize).clamp(
|
||||||
.clamp(0, sortedStacks.length);
|
0,
|
||||||
|
sortedStacks.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,9 +231,13 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
if (col * 2 < displayedStacks.length)
|
if (col * 2 < displayedStacks.length)
|
||||||
_StackCard(
|
_StackCard(
|
||||||
stack: displayedStacks[col * 2],
|
stack: displayedStacks[col * 2],
|
||||||
author: authorsMap[displayedStacks[col * 2].event.pubkey],
|
author:
|
||||||
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2].event.pubkey),
|
authorsMap[displayedStacks[col * 2].event.pubkey],
|
||||||
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2].id] ?? [],
|
isAuthorLoading: isAuthorLoading(
|
||||||
|
displayedStacks[col * 2].event.pubkey,
|
||||||
|
),
|
||||||
|
previewIdentifiers:
|
||||||
|
stackPreviewIds[displayedStacks[col * 2].id] ?? [],
|
||||||
appsMap: appsMap,
|
appsMap: appsMap,
|
||||||
),
|
),
|
||||||
// Bottom item
|
// Bottom item
|
||||||
@@ -241,9 +245,16 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_StackCard(
|
_StackCard(
|
||||||
stack: displayedStacks[col * 2 + 1],
|
stack: displayedStacks[col * 2 + 1],
|
||||||
author: authorsMap[displayedStacks[col * 2 + 1].event.pubkey],
|
author:
|
||||||
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2 + 1].event.pubkey),
|
authorsMap[displayedStacks[col * 2 + 1]
|
||||||
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? [],
|
.event
|
||||||
|
.pubkey],
|
||||||
|
isAuthorLoading: isAuthorLoading(
|
||||||
|
displayedStacks[col * 2 + 1].event.pubkey,
|
||||||
|
),
|
||||||
|
previewIdentifiers:
|
||||||
|
stackPreviewIds[displayedStacks[col * 2 + 1].id] ??
|
||||||
|
[],
|
||||||
appsMap: appsMap,
|
appsMap: appsMap,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -450,9 +461,7 @@ class _AppIconsRow extends StatelessWidget {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context).colorScheme.surfaceContainerHighest
|
||||||
.colorScheme
|
|
||||||
.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.8),
|
.withValues(alpha: 0.8),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
@@ -481,10 +490,9 @@ class _AppIconsRow extends StatelessWidget {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.surfaceContainerHighest
|
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -520,10 +528,14 @@ class _AppIconTile extends StatelessWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: const Duration(milliseconds: 200),
|
fadeInDuration: const Duration(milliseconds: 200),
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, __) => Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
errorWidget: (_, __, ___) => Container(
|
errorWidget: (_, __, ___) => Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.broken_image_outlined,
|
Icons.broken_image_outlined,
|
||||||
size: 16,
|
size: 16,
|
||||||
@@ -532,7 +544,9 @@ class _AppIconTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.apps_outlined,
|
Icons.apps_outlined,
|
||||||
size: 16,
|
size: 16,
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
|||||||
},
|
},
|
||||||
// NOTE: It must stream=true
|
// NOTE: It must stream=true
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||||
subscriptionPrefix: 'latest',
|
subscriptionPrefix: 'app-latest',
|
||||||
),
|
),
|
||||||
(previous, next) async {
|
(previous, next) async {
|
||||||
// Always mirror storage state and ensure olderApps don't duplicate the live head
|
// Always mirror storage state and ensure olderApps don't duplicate the live head
|
||||||
@@ -446,9 +446,9 @@ class _LatestReleasesStatusDot extends ConsumerWidget {
|
|||||||
final poolState = ref.watch(poolStateProvider);
|
final poolState = ref.watch(poolStateProvider);
|
||||||
final subscriptions = poolState?.subscriptions ?? {};
|
final subscriptions = poolState?.subscriptions ?? {};
|
||||||
|
|
||||||
// Filter to only "latest" subscription
|
// Filter to only "app-latest" subscription
|
||||||
final latestSubs = subscriptions.entries
|
final latestSubs = subscriptions.entries
|
||||||
.where((e) => e.key.startsWith('latest'))
|
.where((e) => e.key.startsWith('app-latest'))
|
||||||
.map((e) => e.value);
|
.map((e) => e.value);
|
||||||
|
|
||||||
// Check relay status for latest subscription
|
// Check relay status for latest subscription
|
||||||
|
|||||||
+12
-12
@@ -581,26 +581,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.9"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -653,10 +653,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1141,10 +1141,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.7"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1261,10 +1261,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -95,4 +95,8 @@ onPressed: () async {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Subscription prefix naming
|
||||||
|
|
||||||
|
All queries using the `AppCatalog` relay group MUST prefix their `subscriptionPrefix` with `app-`. This is used in the backend.
|
||||||
|
|
||||||
For detailed API, see models/purplebase READMEs in pub cache.
|
For detailed API, see models/purplebase READMEs in pub cache.
|
||||||
|
|||||||
Reference in New Issue
Block a user