mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
460 lines
13 KiB
Dart
460 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:models/models.dart';
|
|
import 'package:purplebase/purplebase.dart';
|
|
import 'package:zapstore/services/updates_service.dart';
|
|
import 'package:zapstore/utils/app_query.dart';
|
|
import 'package:zapstore/utils/extensions.dart';
|
|
import 'app_card.dart';
|
|
|
|
class LatestReleasesContainer extends HookConsumerWidget {
|
|
const LatestReleasesContainer({
|
|
super.key,
|
|
required this.scrollController,
|
|
this.showSkeleton = false,
|
|
});
|
|
|
|
final ScrollController scrollController;
|
|
final bool showSkeleton;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
if (showSkeleton) {
|
|
return _buildSkeletonState(context);
|
|
}
|
|
|
|
// Infinite scroll: trigger loadMore when near bottom
|
|
useEffect(() {
|
|
void onScroll() {
|
|
final state = ref.read(latestReleasesProvider);
|
|
if (state.isLoadingMore || !state.hasMore) return;
|
|
|
|
// Trigger load when 300px from bottom
|
|
if (scrollController.position.pixels >=
|
|
scrollController.position.maxScrollExtent - 300) {
|
|
ref.read(latestReleasesProvider.notifier).loadMore();
|
|
}
|
|
}
|
|
|
|
scrollController.addListener(onScroll);
|
|
return () => scrollController.removeListener(onScroll);
|
|
}, [scrollController]);
|
|
|
|
final state = ref.watch(latestReleasesProvider);
|
|
final storage = state.storage;
|
|
|
|
// Get pinned apps with zapstore updates to inject at top
|
|
final categorized = ref.watch(categorizedUpdatesProvider);
|
|
final pinnedAppsWithUpdates = [
|
|
...categorized.automaticUpdates,
|
|
...categorized.manualUpdates,
|
|
].where((a) => a.isZapstoreApp).toList();
|
|
final pinnedIds = pinnedAppsWithUpdates.map((a) => a.id).toSet();
|
|
|
|
// Combine live storage models (newest) with paged older apps, excluding pinned
|
|
final allApps = [
|
|
...storage.models,
|
|
...state.olderApps,
|
|
].where((a) => !pinnedIds.contains(a.id)).toList();
|
|
final combinedApps = [...pinnedAppsWithUpdates, ...allApps];
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
height: 1,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 6,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'LATEST RELEASES',
|
|
style: context.textTheme.labelLarge?.copyWith(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.85),
|
|
letterSpacing: 1.5,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
const _LatestReleasesStatusDot(),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Container(
|
|
height: 1,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
if (storage is StorageLoading<App> && storage.models.isEmpty)
|
|
Column(
|
|
children: List.generate(
|
|
3,
|
|
(index) => const AppCard(isLoading: true),
|
|
),
|
|
)
|
|
else if (storage is StorageError<App>)
|
|
_buildErrorState(context, storage.exception.toString())
|
|
else
|
|
_buildAppsList(
|
|
context,
|
|
combinedApps,
|
|
state.isLoadingMore,
|
|
state.hasMore,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildErrorState(BuildContext context, String error) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Center(
|
|
child: Column(
|
|
children: [
|
|
Icon(Icons.error_outline, size: 48, color: Colors.red[400]),
|
|
const SizedBox(height: 16),
|
|
Text('Error loading apps', style: context.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
error,
|
|
style: context.textTheme.bodySmall,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAppsList(
|
|
BuildContext context,
|
|
List<App> apps,
|
|
bool isLoadingMore,
|
|
bool hasMoreApps,
|
|
) {
|
|
return Column(
|
|
children: [
|
|
...apps.map((app) {
|
|
return AppCard(app: app, showUpdateArrow: app.hasUpdate);
|
|
}),
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
// Show loading indicator when fetching more
|
|
if (isLoadingMore)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 3,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else if (!hasMoreApps && apps.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Center(
|
|
child: Text(
|
|
'No more apps to load',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]),
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
],
|
|
);
|
|
}
|
|
|
|
static Widget _buildSkeletonState(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
height: 1,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Text(
|
|
'LATEST RELEASES',
|
|
style: context.textTheme.labelLarge?.copyWith(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.35),
|
|
letterSpacing: 1.5,
|
|
fontWeight: FontWeight.w300,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Container(
|
|
height: 1,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Show 3 skeleton app cards
|
|
Column(children: List.generate(3, (index) => AppCard(isLoading: true))),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Provider and Notifier for latest releases pagination and relationship loading
|
|
|
|
class LatestReleasesState {
|
|
final StorageState<App> storage;
|
|
final List<App> olderApps;
|
|
final bool isLoadingMore;
|
|
final bool hasMore;
|
|
|
|
const LatestReleasesState({
|
|
required this.storage,
|
|
required this.olderApps,
|
|
required this.isLoadingMore,
|
|
required this.hasMore,
|
|
});
|
|
|
|
factory LatestReleasesState.initial() => LatestReleasesState(
|
|
storage: StorageLoading<App>(const []),
|
|
olderApps: const [],
|
|
isLoadingMore: false,
|
|
hasMore: true,
|
|
);
|
|
|
|
LatestReleasesState copyWith({
|
|
StorageState<App>? storage,
|
|
List<App>? olderApps,
|
|
bool? isLoadingMore,
|
|
bool? hasMore,
|
|
}) {
|
|
return LatestReleasesState(
|
|
storage: storage ?? this.storage,
|
|
olderApps: olderApps ?? this.olderApps,
|
|
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
|
hasMore: hasMore ?? this.hasMore,
|
|
);
|
|
}
|
|
}
|
|
|
|
final latestReleasesProvider =
|
|
StateNotifierProvider<LatestReleasesNotifier, LatestReleasesState>(
|
|
(ref) => LatestReleasesNotifier(ref),
|
|
);
|
|
|
|
class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
|
LatestReleasesNotifier(this.ref) : super(LatestReleasesState.initial()) {
|
|
_startQuery();
|
|
}
|
|
|
|
final Ref ref;
|
|
static const int _pageSize = 5;
|
|
ProviderSubscription<StorageState<SoftwareAsset>>? _sub;
|
|
|
|
void _startQuery() {
|
|
_sub?.close();
|
|
|
|
_sub = ref.listen<StorageState<SoftwareAsset>>(
|
|
appAssetsQuery(
|
|
limit: _pageSize,
|
|
tags: {
|
|
'#f': {'android-arm64-v8a'},
|
|
},
|
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
|
subscriptionPrefix: 'app-latest',
|
|
),
|
|
(previous, next) {
|
|
final appState = _appsFromAssetState(next);
|
|
|
|
if (appState is StorageData<App>) {
|
|
final liveIds = appState.models.map((a) => a.id).toSet();
|
|
final filteredOlder = state.olderApps
|
|
.where((a) => !liveIds.contains(a.id))
|
|
.toList();
|
|
state = state.copyWith(storage: appState, olderApps: filteredOlder);
|
|
} else {
|
|
state = state.copyWith(storage: appState);
|
|
}
|
|
|
|
if (appState is StorageError<App>) {
|
|
state = state.copyWith(isLoadingMore: false);
|
|
}
|
|
},
|
|
fireImmediately: true,
|
|
);
|
|
}
|
|
|
|
/// Derive a `StorageState<App>` from the SoftwareAsset query results.
|
|
StorageState<App> _appsFromAssetState(StorageState<SoftwareAsset> assetState) {
|
|
return switch (assetState) {
|
|
StorageLoading<SoftwareAsset>() => StorageLoading<App>(
|
|
_uniqueAppsFromAssets(assetState.models),
|
|
),
|
|
StorageData<SoftwareAsset>(:final models) => StorageData<App>(
|
|
_uniqueAppsFromAssets(models),
|
|
),
|
|
StorageError<SoftwareAsset>(:final exception) =>
|
|
StorageError<App>(const [], exception: exception),
|
|
};
|
|
}
|
|
|
|
/// Extract unique Apps from assets, preserving order (newest first).
|
|
List<App> _uniqueAppsFromAssets(List<SoftwareAsset> assets) {
|
|
final seen = <String>{};
|
|
final apps = <App>[];
|
|
for (final asset in assets) {
|
|
final app = asset.app.value;
|
|
if (app != null && seen.add(app.identifier)) {
|
|
apps.add(app);
|
|
}
|
|
}
|
|
return apps;
|
|
}
|
|
|
|
Future<void> loadMore() async {
|
|
final live = state.storage.models;
|
|
final combined = [...live, ...state.olderApps];
|
|
if (state.isLoadingMore || !state.hasMore || combined.isEmpty) return;
|
|
|
|
final oldest = combined
|
|
.map((a) => a.event.createdAt)
|
|
.reduce((a, b) => a.isBefore(b) ? a : b)
|
|
.subtract(const Duration(milliseconds: 1));
|
|
|
|
state = state.copyWith(isLoadingMore: true);
|
|
|
|
try {
|
|
final result = await fetchAppsByAsset(
|
|
ref.storage,
|
|
tags: {
|
|
'#f': {'android-arm64-v8a'},
|
|
},
|
|
until: oldest,
|
|
limit: _pageSize,
|
|
source: const LocalAndRemoteSource(stream: false),
|
|
subscriptionPrefix: 'app-latest-older',
|
|
);
|
|
|
|
if (result.apps.isNotEmpty) {
|
|
final existingIds = combined.map((a) => a.id).toSet();
|
|
final uniqueOlder = result.apps
|
|
.where((a) => !existingIds.contains(a.id))
|
|
.toList();
|
|
state = state.copyWith(
|
|
olderApps: [...state.olderApps, ...uniqueOlder],
|
|
isLoadingMore: false,
|
|
hasMore: result.assetCount >= _pageSize,
|
|
);
|
|
} else {
|
|
state = state.copyWith(isLoadingMore: false, hasMore: false);
|
|
}
|
|
} catch (e) {
|
|
state = state.copyWith(isLoadingMore: false);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sub?.close();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
/// Status dot showing connection state for 'latest' subscription
|
|
class _LatestReleasesStatusDot extends ConsumerWidget {
|
|
const _LatestReleasesStatusDot();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final poolState = ref.watch(poolStateProvider);
|
|
final subscriptions = poolState?.subscriptions ?? {};
|
|
|
|
// Filter to only "app-latest" subscription
|
|
final latestSubs = subscriptions.entries
|
|
.where((e) => e.key.startsWith('app-latest'))
|
|
.map((e) => e.value);
|
|
|
|
// Check relay status for latest subscription
|
|
bool hasActiveConnection = false;
|
|
|
|
for (final sub in latestSubs) {
|
|
for (final relay in sub.relays.values) {
|
|
if (relay.phase == RelaySubPhase.streaming ||
|
|
relay.phase == RelaySubPhase.loading) {
|
|
hasActiveConnection = true;
|
|
break;
|
|
}
|
|
}
|
|
if (hasActiveConnection) break;
|
|
}
|
|
|
|
final statusColor = hasActiveConnection ? Colors.green : Colors.red;
|
|
|
|
return Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: statusColor,
|
|
boxShadow: hasActiveConnection
|
|
? [
|
|
BoxShadow(
|
|
color: statusColor.withValues(alpha: 0.4),
|
|
blurRadius: 4,
|
|
spreadRadius: 1,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
}
|