mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
App stacks with new UX, other adjustments to search screen
This commit is contained in:
+22
-6
@@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/screens/main_scaffold.dart';
|
import 'package:zapstore/screens/main_scaffold.dart';
|
||||||
import 'package:zapstore/screens/app_detail_screen.dart';
|
import 'package:zapstore/screens/app_detail_screen.dart';
|
||||||
|
import 'package:zapstore/screens/app_stack_screen.dart';
|
||||||
import 'package:zapstore/screens/user_screen.dart';
|
import 'package:zapstore/screens/user_screen.dart';
|
||||||
import 'package:zapstore/screens/search_screen.dart';
|
import 'package:zapstore/screens/search_screen.dart';
|
||||||
import 'package:zapstore/screens/updates_screen.dart';
|
import 'package:zapstore/screens/updates_screen.dart';
|
||||||
@@ -11,9 +12,9 @@ import 'package:zapstore/screens/profile_screen.dart';
|
|||||||
/// Root paths for each navigation branch (used for back navigation handling)
|
/// Root paths for each navigation branch (used for back navigation handling)
|
||||||
const kBranchRoots = ['/search', '/updates', '/profile'];
|
const kBranchRoots = ['/search', '/updates', '/profile'];
|
||||||
|
|
||||||
typedef _ResolvedAppRoute = ({String identifier, String? author});
|
typedef _ResolvedRoute = ({String identifier, String? author});
|
||||||
|
|
||||||
_ResolvedAppRoute _resolveAppRouteId(String rawId) {
|
_ResolvedRoute _resolveNaddrRouteId(String rawId) {
|
||||||
if (rawId.startsWith('naddr1')) {
|
if (rawId.startsWith('naddr1')) {
|
||||||
try {
|
try {
|
||||||
final decoded = Utils.decodeShareableIdentifier(rawId);
|
final decoded = Utils.decodeShareableIdentifier(rawId);
|
||||||
@@ -33,7 +34,7 @@ GoRoute _appDetailRoute() {
|
|||||||
path: 'app/:id',
|
path: 'app/:id',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final rawId = state.pathParameters['id']!;
|
final rawId = state.pathParameters['id']!;
|
||||||
final resolved = _resolveAppRouteId(rawId);
|
final resolved = _resolveNaddrRouteId(rawId);
|
||||||
return AppDetailScreen(
|
return AppDetailScreen(
|
||||||
appId: resolved.identifier,
|
appId: resolved.identifier,
|
||||||
authorPubkey: resolved.author,
|
authorPubkey: resolved.author,
|
||||||
@@ -42,6 +43,21 @@ GoRoute _appDetailRoute() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper to build stack detail route
|
||||||
|
GoRoute _stackDetailRoute() {
|
||||||
|
return GoRoute(
|
||||||
|
path: 'stack/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final rawId = state.pathParameters['id']!;
|
||||||
|
final resolved = _resolveNaddrRouteId(rawId);
|
||||||
|
return AppStackScreen(
|
||||||
|
stackId: resolved.identifier,
|
||||||
|
authorPubkey: resolved.author,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper to build user route
|
/// Helper to build user route
|
||||||
GoRoute _userRoute() {
|
GoRoute _userRoute() {
|
||||||
return GoRoute(
|
return GoRoute(
|
||||||
@@ -80,7 +96,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/search',
|
path: '/search',
|
||||||
builder: (context, state) => const SearchScreen(),
|
builder: (context, state) => const SearchScreen(),
|
||||||
routes: [_appDetailRoute(), _userRoute()],
|
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -90,7 +106,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/updates',
|
path: '/updates',
|
||||||
builder: (context, state) => const UpdatesScreen(),
|
builder: (context, state) => const UpdatesScreen(),
|
||||||
routes: [_appDetailRoute(), _userRoute()],
|
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -100,7 +116,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
builder: (context, state) => const ProfileScreen(),
|
builder: (context, state) => const ProfileScreen(),
|
||||||
routes: [_appDetailRoute(), _userRoute()],
|
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:models/models.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
import 'package:zapstore/widgets/app_card.dart';
|
||||||
|
import 'package:zapstore/widgets/author_container.dart';
|
||||||
|
import 'package:zapstore/widgets/comments_section.dart';
|
||||||
|
import 'package:zapstore/widgets/common/badges.dart';
|
||||||
|
import 'package:zapstore/theme.dart';
|
||||||
|
|
||||||
|
class AppStackScreen extends HookConsumerWidget {
|
||||||
|
const AppStackScreen({super.key, required this.stackId, this.authorPubkey});
|
||||||
|
|
||||||
|
final String stackId;
|
||||||
|
final String? authorPubkey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Query stack with apps relationship
|
||||||
|
final stackState = ref.watch(
|
||||||
|
query<AppPack>(
|
||||||
|
authors: authorPubkey != null ? {authorPubkey!} : null,
|
||||||
|
tags: {
|
||||||
|
'#d': {stackId},
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
and: (pack) => {pack.apps},
|
||||||
|
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
||||||
|
andSource: const LocalAndRemoteSource(
|
||||||
|
relays: 'AppCatalog',
|
||||||
|
stream: false,
|
||||||
|
),
|
||||||
|
subscriptionPrefix: authorPubkey != null
|
||||||
|
? 'app-stack-${authorPubkey!}-$stackId'
|
||||||
|
: 'app-stack-$stackId',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return switch (stackState) {
|
||||||
|
StorageError(:final exception) => _ErrorScaffold(
|
||||||
|
message: exception.toString(),
|
||||||
|
),
|
||||||
|
StorageData(:final models) => _AppStackContentWithApps(
|
||||||
|
stack: models.firstOrNull,
|
||||||
|
),
|
||||||
|
StorageLoading() => Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _AppStackSkeleton(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Intermediate widget that loads apps with their release relationships
|
||||||
|
class _AppStackContentWithApps extends HookConsumerWidget {
|
||||||
|
final AppPack? stack;
|
||||||
|
|
||||||
|
const _AppStackContentWithApps({required this.stack});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
if (stack == null) {
|
||||||
|
return const _ErrorScaffold(message: 'Stack not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get app identifiers from stack's apps relationship
|
||||||
|
final stackApps = stack!.apps.toList();
|
||||||
|
final appIdentifiers = stackApps.map((app) => app.identifier).toSet();
|
||||||
|
|
||||||
|
if (appIdentifiers.isEmpty) {
|
||||||
|
return _AppStackContent(stack: stack!, apps: const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query apps with release and metadata relationships (same pattern as search/user screens)
|
||||||
|
final appsState = ref.watch(
|
||||||
|
query<App>(
|
||||||
|
tags: {'#d': appIdentifiers},
|
||||||
|
and: (app) => {
|
||||||
|
app.latestRelease,
|
||||||
|
app.latestRelease.value?.latestMetadata,
|
||||||
|
},
|
||||||
|
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
|
||||||
|
subscriptionPrefix: 'app-stack-apps-${stack!.identifier}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map loaded apps by identifier for ordering
|
||||||
|
final appsMap = {for (final app in appsState.models) app.identifier: app};
|
||||||
|
|
||||||
|
// Preserve stack order, using loaded apps with their relationships
|
||||||
|
final orderedApps = stackApps
|
||||||
|
.map((app) => appsMap[app.identifier] ?? app)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return _AppStackContent(stack: stack!, apps: orderedApps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorScaffold extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
const _ErrorScaffold({required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('App Stack')),
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 64),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Text(message, textAlign: TextAlign.center),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal widget that displays stack details
|
||||||
|
class _AppStackContent extends HookConsumerWidget {
|
||||||
|
final AppPack stack;
|
||||||
|
final List<App> apps;
|
||||||
|
|
||||||
|
const _AppStackContent({required this.stack, required this.apps});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Query author profile from social relays
|
||||||
|
final authorState = ref.watch(
|
||||||
|
query<Profile>(
|
||||||
|
authors: {stack.pubkey},
|
||||||
|
source: const LocalAndRemoteSource(
|
||||||
|
relays: {'social', 'vertex'},
|
||||||
|
cachedFor: Duration(hours: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final author = switch (authorState) {
|
||||||
|
StorageData(:final models) => models.firstOrNull,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort apps: uninstalled first, keeping original order otherwise
|
||||||
|
final packageManager = ref.watch(packageManagerProvider.notifier);
|
||||||
|
final sortedApps = _sortAppsUninstalledFirst(apps, packageManager);
|
||||||
|
final totalApps = sortedApps.length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.only(top: 16, bottom: 32),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Stack header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: _StackHeader(stack: stack, author: author),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// Apps section header with count badge
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Apps in this stack',
|
||||||
|
style: context.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
CountBadge(
|
||||||
|
count: totalApps,
|
||||||
|
color: AppColors.darkPillBackground,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Apps list
|
||||||
|
if (sortedApps.isEmpty)
|
||||||
|
_EmptyAppsPlaceholder()
|
||||||
|
else
|
||||||
|
...sortedApps.map(
|
||||||
|
(app) => AppCard(app: app, showUpdateArrow: app.hasUpdate),
|
||||||
|
),
|
||||||
|
// Comments section
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 24),
|
||||||
|
child: StackCommentsSection(stack: stack),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort apps so uninstalled ones come first, keeping original order otherwise
|
||||||
|
List<App> _sortAppsUninstalledFirst(
|
||||||
|
List<App> apps,
|
||||||
|
PackageManager packageManager,
|
||||||
|
) {
|
||||||
|
final uninstalled = <App>[];
|
||||||
|
final installed = <App>[];
|
||||||
|
|
||||||
|
for (final app in apps) {
|
||||||
|
if (packageManager.isInstalled(app.identifier)) {
|
||||||
|
installed.add(app);
|
||||||
|
} else {
|
||||||
|
uninstalled.add(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...uninstalled, ...installed];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stack header with name and author
|
||||||
|
class _StackHeader extends StatelessWidget {
|
||||||
|
const _StackHeader({required this.stack, this.author});
|
||||||
|
|
||||||
|
final AppPack stack;
|
||||||
|
final Profile? author;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Stack name
|
||||||
|
Text(
|
||||||
|
stack.name ?? stack.identifier,
|
||||||
|
style: context.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Published by author (same style as app_detail_screen)
|
||||||
|
if (author != null)
|
||||||
|
AuthorContainer(
|
||||||
|
profile: author!,
|
||||||
|
beforeText: 'Published by',
|
||||||
|
oneLine: true,
|
||||||
|
size: 14,
|
||||||
|
onTap: () {
|
||||||
|
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||||
|
final first = segments.isNotEmpty ? segments.first : 'search';
|
||||||
|
context.push('/$first/user/${stack.pubkey}');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty state when no apps in stack
|
||||||
|
class _EmptyAppsPlaceholder extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.apps_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'No apps in this stack',
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skeleton loading state for the stack screen
|
||||||
|
class _AppStackSkeleton extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SkeletonizerConfig(
|
||||||
|
data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness),
|
||||||
|
child: Skeletonizer(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header skeleton
|
||||||
|
Container(
|
||||||
|
height: 28,
|
||||||
|
width: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.darkSkeletonBase,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.darkSkeletonBase,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
height: 16,
|
||||||
|
width: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.darkSkeletonBase,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// Section header with count badge
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 20,
|
||||||
|
width: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.darkSkeletonBase,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
height: 20,
|
||||||
|
width: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.darkSkeletonBase,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// App cards skeleton (using AppCard skeleton)
|
||||||
|
...List.generate(3, (_) => const AppCard(isLoading: true)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ 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 '../widgets/app_pack_container.dart';
|
import '../widgets/app_stack_container.dart';
|
||||||
import '../widgets/latest_releases_container.dart';
|
import '../widgets/latest_releases_container.dart';
|
||||||
import '../widgets/app_card.dart';
|
import '../widgets/app_card.dart';
|
||||||
import '../utils/extensions.dart';
|
import '../utils/extensions.dart';
|
||||||
@@ -116,10 +116,10 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
),
|
),
|
||||||
|
|
||||||
// App Curation Container with professional spacing
|
// App Stacks Container
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
child: AppPackContainer(
|
child: AppStackContainer(
|
||||||
showSkeleton: !(initState.hasValue || initState.hasError),
|
showSkeleton: !(initState.hasValue || initState.hasError),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -199,7 +199,7 @@ class _SearchResultsSection extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (isSearching)
|
if (isSearching)
|
||||||
Column(
|
Column(
|
||||||
children: List.generate(3, (_) => const AppCard(isLoading: true)),
|
children: List.generate(2, (_) => const AppCard(isLoading: true)),
|
||||||
)
|
)
|
||||||
else if (error != null)
|
else if (error != null)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -246,7 +246,9 @@ class _SearchResultsSection extends HookConsumerWidget {
|
|||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
// App Cards - authors loaded via profileProvider in AppCard
|
// App Cards - authors loaded via profileProvider in AppCard
|
||||||
...results.map((app) => _SearchResultCard(app: app)),
|
...results.map(
|
||||||
|
(app) => AppCard(app: app, showUpdateArrow: app.hasUpdate),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -254,17 +256,3 @@ class _SearchResultsSection extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper widget for search result cards with version information
|
|
||||||
class _SearchResultCard extends ConsumerWidget {
|
|
||||||
const _SearchResultCard({required this.app});
|
|
||||||
|
|
||||||
final App app;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
// Check if app has updates
|
|
||||||
// Author loaded via profileProvider in AppCard
|
|
||||||
return AppCard(app: app, showUpdateArrow: app.hasUpdate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ class AppCard extends HookConsumerWidget {
|
|||||||
context.push('/$first/app/$naddr');
|
context.push('/$first/app/$naddr');
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
||||||
@@ -127,7 +127,7 @@ class AppCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
// App Description rendered as plain text (markdown stripped)
|
// App Description rendered as plain text (markdown stripped)
|
||||||
if (showDescription) ...[
|
if (showDescription) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
descriptionText,
|
descriptionText,
|
||||||
style: descriptionStyle,
|
style: descriptionStyle,
|
||||||
@@ -341,9 +341,9 @@ class AppCard extends HookConsumerWidget {
|
|||||||
child: Skeletonizer(
|
child: Skeletonizer(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
child: Container(
|
child: Container(
|
||||||
// Match actual content: vertical: 6, not 8
|
// Match actual content: vertical: 4
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
||||||
@@ -405,7 +405,7 @@ class AppCard extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
// Description skeleton - 2 lines to match maxLines: 2
|
// Description skeleton - 2 lines to match maxLines: 2
|
||||||
if (showDescription) ...[
|
if (showDescription) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -454,7 +454,7 @@ class _AppCardUpdateButtonSection extends ConsumerWidget {
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const Gap(12),
|
const Gap(10),
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minHeight: 38),
|
constraints: const BoxConstraints(minHeight: 38),
|
||||||
child: _CompactInstallButton(
|
child: _CompactInstallButton(
|
||||||
@@ -489,7 +489,7 @@ class _AppCardZapEncouragementSection extends ConsumerWidget {
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const Gap(12),
|
const Gap(10),
|
||||||
_ZapEncouragementInCard(app: app, author: publisher),
|
_ZapEncouragementInCard(app: app, author: publisher),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
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:go_router/go_router.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
import '../utils/extensions.dart';
|
||||||
|
import '../utils/url_utils.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import 'common/profile_avatar.dart';
|
||||||
|
|
||||||
|
/// App Stack Container - horizontally scrollable 2-row grid of stack cards
|
||||||
|
class AppStackContainer extends HookConsumerWidget {
|
||||||
|
const AppStackContainer({super.key, this.showSkeleton = false});
|
||||||
|
|
||||||
|
final bool showSkeleton;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
if (showSkeleton) {
|
||||||
|
return _buildSkeleton(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
||||||
|
|
||||||
|
final appStacksState = ref.watch(
|
||||||
|
query<AppPack>(
|
||||||
|
limit: 50,
|
||||||
|
and: (pack) => {pack.apps},
|
||||||
|
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
||||||
|
andSource: const LocalAndRemoteSource(
|
||||||
|
relays: 'AppCatalog',
|
||||||
|
stream: false,
|
||||||
|
),
|
||||||
|
schemaFilter: appPackEventFilter,
|
||||||
|
subscriptionPrefix: 'app-stack',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Query contact list for signed-in user
|
||||||
|
final contactListState = signedInPubkey != null
|
||||||
|
? ref.watch(
|
||||||
|
query<ContactList>(
|
||||||
|
authors: {signedInPubkey},
|
||||||
|
limit: 1,
|
||||||
|
source: const LocalAndRemoteSource(
|
||||||
|
relays: 'social',
|
||||||
|
stream: false,
|
||||||
|
),
|
||||||
|
subscriptionPrefix: 'user-contacts-stacks',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final followingPubkeys =
|
||||||
|
contactListState?.models.firstOrNull?.followingPubkeys;
|
||||||
|
|
||||||
|
final rawStacks = switch (appStacksState) {
|
||||||
|
StorageData(:final models) => models,
|
||||||
|
_ => <AppPack>[],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rawStacks.isEmpty) {
|
||||||
|
return _buildSkeleton(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort stacks: followed profiles first (or franzap if not signed in), then by recency
|
||||||
|
final stacks = _sortStacks(
|
||||||
|
rawStacks,
|
||||||
|
signedInPubkey: signedInPubkey,
|
||||||
|
followingPubkeys: followingPubkeys,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2-row horizontal scroll list with partial next card visible (Android UX pattern)
|
||||||
|
return SingleChildScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.only(left: 12, right: 12),
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < stacks.length; i += 2)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
right: i == stacks.length - 2 || i == stacks.length - 1 ? 0 : 10,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 160,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_StackCard(stack: stacks[i]),
|
||||||
|
if (i + 1 < stacks.length) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_StackCard(stack: stacks[i + 1]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AppPack> _sortStacks(
|
||||||
|
List<AppPack> stacks, {
|
||||||
|
String? signedInPubkey,
|
||||||
|
Set<String>? followingPubkeys,
|
||||||
|
}) {
|
||||||
|
final random = Random();
|
||||||
|
|
||||||
|
if (signedInPubkey != null &&
|
||||||
|
followingPubkeys != null &&
|
||||||
|
followingPubkeys.isNotEmpty) {
|
||||||
|
// Signed in: followed profiles first, then by recency
|
||||||
|
final followed = <AppPack>[];
|
||||||
|
final others = <AppPack>[];
|
||||||
|
|
||||||
|
for (final stack in stacks) {
|
||||||
|
if (followingPubkeys.contains(stack.pubkey)) {
|
||||||
|
followed.add(stack);
|
||||||
|
} else {
|
||||||
|
others.add(stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort both lists by recency
|
||||||
|
followed.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||||
|
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||||
|
|
||||||
|
return [...followed, ...others];
|
||||||
|
} else {
|
||||||
|
// Not signed in: put one random franzap stack first, then by recency
|
||||||
|
final franzapStacks = stacks
|
||||||
|
.where((s) => s.pubkey == kFranzapPubkey)
|
||||||
|
.toList();
|
||||||
|
final otherStacks = stacks
|
||||||
|
.where((s) => s.pubkey != kFranzapPubkey)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Sort others by recency
|
||||||
|
otherStacks.sort(
|
||||||
|
(a, b) => b.event.createdAt.compareTo(a.event.createdAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (franzapStacks.isNotEmpty) {
|
||||||
|
// Pick one random franzap stack
|
||||||
|
final randomFranzap =
|
||||||
|
franzapStacks[random.nextInt(franzapStacks.length)];
|
||||||
|
return [randomFranzap, ...otherStacks];
|
||||||
|
}
|
||||||
|
|
||||||
|
return otherStacks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSkeleton(BuildContext context) {
|
||||||
|
return SkeletonizerConfig(
|
||||||
|
data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness),
|
||||||
|
child: Skeletonizer(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.only(left: 12, right: 12),
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < 6; i += 2)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(right: i == 4 ? 0 : 10),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 160,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const _SkeletonStackCard(),
|
||||||
|
if (i + 1 < 6) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const _SkeletonStackCard(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skeleton card for loading state - matches vertical layout
|
||||||
|
class _SkeletonStackCard extends StatelessWidget {
|
||||||
|
const _SkeletonStackCard();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Stack name placeholder - increased to match titleMedium size
|
||||||
|
Container(
|
||||||
|
height: 22,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.darkSkeletonBase,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Author row placeholder
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.darkSkeletonBase,
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Container(
|
||||||
|
height: 16,
|
||||||
|
width: 70,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.darkSkeletonBase,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Horizontal icons row placeholder - ensure proper sizing
|
||||||
|
SizedBox(
|
||||||
|
height: 36, // Explicit height to match actual icon row
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(
|
||||||
|
4,
|
||||||
|
(index) => Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.darkSkeletonBase,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual stack card with vertical layout:
|
||||||
|
/// Stack Name, by (profile), horizontal app icons row, total apps
|
||||||
|
class _StackCard extends HookConsumerWidget {
|
||||||
|
const _StackCard({required this.stack});
|
||||||
|
|
||||||
|
final AppPack stack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Query author profile
|
||||||
|
final authorState = ref.watch(
|
||||||
|
query<Profile>(
|
||||||
|
authors: {stack.event.pubkey},
|
||||||
|
source: const LocalAndRemoteSource(
|
||||||
|
relays: {'social', 'vertex'},
|
||||||
|
cachedFor: Duration(hours: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final author = switch (authorState) {
|
||||||
|
StorageData(:final models) => models.firstOrNull,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get shuffled apps for the icons row - shuffle only once
|
||||||
|
final apps = stack.apps.toList();
|
||||||
|
final totalApps = apps.length;
|
||||||
|
final previewApps = useMemoized(() {
|
||||||
|
final shuffled = List<App>.from(apps)..shuffle(Random());
|
||||||
|
// Show 3 if there are more than 4, otherwise show up to 4
|
||||||
|
return shuffled.take(totalApps > 4 ? 3 : 4).toList();
|
||||||
|
}, [stack.id]);
|
||||||
|
|
||||||
|
// Styling like app_card.dart (slightly larger for visibility)
|
||||||
|
final profileStyle = context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.darkOnSurfaceSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||||
|
final first = segments.isNotEmpty ? segments.first : 'search';
|
||||||
|
final naddr = Utils.encodeShareableIdentifier(
|
||||||
|
AddressInput(
|
||||||
|
identifier: stack.identifier,
|
||||||
|
author: stack.pubkey,
|
||||||
|
kind: stack.event.kind,
|
||||||
|
relays: const [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.push('/$first/stack/$naddr');
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Stack name
|
||||||
|
Text(
|
||||||
|
stack.name ?? stack.identifier,
|
||||||
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Author row: avatar + Name
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
ProfileAvatar(profile: author, radius: 9),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
author?.nameOrNpub ?? '',
|
||||||
|
style: profileStyle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Horizontal row of app icons
|
||||||
|
_AppIconsRow(apps: previewApps, totalApps: totalApps),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Horizontal row of app icons for stack preview
|
||||||
|
class _AppIconsRow extends StatelessWidget {
|
||||||
|
const _AppIconsRow({required this.apps, required this.totalApps});
|
||||||
|
|
||||||
|
final List<App> apps;
|
||||||
|
final int totalApps;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasMore = totalApps > 4;
|
||||||
|
final extraCount = totalApps - 3;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: List.generate(4, (index) {
|
||||||
|
// Show "+X" indicator in the 4th slot if there are more than 4 apps
|
||||||
|
if (hasMore && index == 3) {
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.8),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'+$extraCount',
|
||||||
|
style: context.textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (index < apps.length) {
|
||||||
|
return Expanded(child: _AppIconTile(app: apps[index]));
|
||||||
|
}
|
||||||
|
// Empty placeholder for missing apps
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual app icon tile for the horizontal row
|
||||||
|
class _AppIconTile extends StatelessWidget {
|
||||||
|
const _AppIconTile({required this.app});
|
||||||
|
|
||||||
|
final App app;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final iconUrl = firstValidHttpUrl(app.icons);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.87,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: iconUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: iconUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 200),
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.apps_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:zapstore/widgets/auth_widgets.dart';
|
|||||||
import 'package:zapstore/widgets/common/profile_avatar.dart';
|
import 'package:zapstore/widgets/common/profile_avatar.dart';
|
||||||
import 'package:zapstore/widgets/pill_widget.dart';
|
import 'package:zapstore/widgets/pill_widget.dart';
|
||||||
|
|
||||||
|
/// Comments section for App detail screen
|
||||||
class CommentsSection extends HookConsumerWidget {
|
class CommentsSection extends HookConsumerWidget {
|
||||||
const CommentsSection({super.key, required this.app, this.fileMetadata});
|
const CommentsSection({super.key, required this.app, this.fileMetadata});
|
||||||
|
|
||||||
@@ -43,6 +44,64 @@ class CommentsSection extends HookConsumerWidget {
|
|||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return _CommentsSectionLayout(
|
||||||
|
comments: comments,
|
||||||
|
errorException: errorException,
|
||||||
|
addCommentButton: _AddCommentButton(fileMetadata: fileMetadata!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comments section for AppPack/Stack detail screen
|
||||||
|
class StackCommentsSection extends HookConsumerWidget {
|
||||||
|
const StackCommentsSection({super.key, required this.stack});
|
||||||
|
|
||||||
|
final AppPack stack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final commentsState = ref.watch(
|
||||||
|
query<Comment>(
|
||||||
|
tags: {
|
||||||
|
'#A': {stack.id},
|
||||||
|
},
|
||||||
|
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
||||||
|
subscriptionPrefix: 'stack-comments',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract comments and error state
|
||||||
|
final List<Comment> comments = switch (commentsState) {
|
||||||
|
StorageData(:final models) => models,
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
final errorException = switch (commentsState) {
|
||||||
|
StorageError(:final exception) => exception,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return _CommentsSectionLayout(
|
||||||
|
comments: comments,
|
||||||
|
errorException: errorException,
|
||||||
|
addCommentButton: _AddStackCommentButton(stack: stack),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared layout for both App and Stack comments
|
||||||
|
class _CommentsSectionLayout extends StatelessWidget {
|
||||||
|
const _CommentsSectionLayout({
|
||||||
|
required this.comments,
|
||||||
|
required this.errorException,
|
||||||
|
required this.addCommentButton,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<Comment> comments;
|
||||||
|
final Object? errorException;
|
||||||
|
final Widget addCommentButton;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -64,11 +123,11 @@ class CommentsSection extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Add Comment button - always visible
|
// Add Comment button - always visible
|
||||||
_AddCommentButton(fileMetadata: fileMetadata!),
|
addCommentButton,
|
||||||
// Error message if any
|
// Error message if any
|
||||||
if (errorException != null) ...[
|
if (errorException != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildCommentsError(context, errorException),
|
_buildCommentsError(context, errorException!),
|
||||||
],
|
],
|
||||||
// Comments list - only when there are comments
|
// Comments list - only when there are comments
|
||||||
if (comments.isNotEmpty) ...[
|
if (comments.isNotEmpty) ...[
|
||||||
@@ -276,6 +335,40 @@ class _AddCommentButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AddStackCommentButton extends ConsumerWidget {
|
||||||
|
const _AddStackCommentButton({required this.stack});
|
||||||
|
|
||||||
|
final AppPack stack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () => _showStackCommentComposer(context),
|
||||||
|
icon: const Icon(Icons.add_comment),
|
||||||
|
label: const Text('Add Comment'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showStackCommentComposer(BuildContext context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => _StackCommentComposer(stack: stack),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _CommentComposer extends HookConsumerWidget {
|
class _CommentComposer extends HookConsumerWidget {
|
||||||
const _CommentComposer({required this.fileMetadata});
|
const _CommentComposer({required this.fileMetadata});
|
||||||
|
|
||||||
@@ -434,3 +527,135 @@ class _CommentComposer extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Comment composer for Stack comments
|
||||||
|
class _StackCommentComposer extends HookConsumerWidget {
|
||||||
|
const _StackCommentComposer({required this.stack});
|
||||||
|
|
||||||
|
final AppPack stack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isSignedIn = ref.watch(Signer.activePubkeyProvider) != null;
|
||||||
|
final textController = useTextEditingController();
|
||||||
|
useListenable(textController);
|
||||||
|
|
||||||
|
final stackName = stack.name ?? stack.identifier;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Comment on $stackName',
|
||||||
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (!isSignedIn) ...[
|
||||||
|
const SignInPrompt(
|
||||||
|
message: 'Sign in to share your thoughts about this stack.',
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
TextField(
|
||||||
|
controller: textController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Share your thoughts about $stackName...',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 4,
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: AsyncButtonBuilder(
|
||||||
|
child: const Text('Post Comment'),
|
||||||
|
onPressed: () =>
|
||||||
|
_publishComment(ref, textController.text, context),
|
||||||
|
builder: (context, child, callback, buttonState) {
|
||||||
|
return FilledButton(
|
||||||
|
onPressed: !isSignedIn
|
||||||
|
? null
|
||||||
|
: buttonState.maybeWhen(
|
||||||
|
loading: () => null,
|
||||||
|
orElse: () => textController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: callback,
|
||||||
|
),
|
||||||
|
child: buttonState.maybeWhen(
|
||||||
|
loading: () => const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
orElse: () => child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _publishComment(
|
||||||
|
WidgetRef ref,
|
||||||
|
String content,
|
||||||
|
BuildContext context,
|
||||||
|
) async {
|
||||||
|
if (content.trim().isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final signer = ref.read(Signer.activeSignerProvider);
|
||||||
|
if (signer == null) {
|
||||||
|
if (context.mounted) {
|
||||||
|
context.showError(
|
||||||
|
'Sign in required',
|
||||||
|
description: 'You need to sign in with Amber to post comments.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final comment = PartialComment(content: content.trim(), rootModel: stack);
|
||||||
|
|
||||||
|
final signedComment = await comment.signWith(signer);
|
||||||
|
|
||||||
|
await signedComment.save();
|
||||||
|
await signedComment.publish(source: RemoteSource(relays: 'social'));
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
context.showError('Failed to post comment', description: '$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,13 +49,42 @@ class LatestReleasesContainer extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
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),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Latest Releases',
|
'LATEST RELEASES',
|
||||||
style: context.textTheme.headlineSmall,
|
style: context.textTheme.labelLarge?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.85),
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 1,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
if (storage is StorageLoading<App> || storage.models.isEmpty)
|
if (storage is StorageLoading<App> || storage.models.isEmpty)
|
||||||
Column(
|
Column(
|
||||||
@@ -150,13 +179,42 @@ class LatestReleasesContainer extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
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(
|
child: Text(
|
||||||
'Latest Releases',
|
'LATEST RELEASES',
|
||||||
style: context.textTheme.headlineSmall,
|
style: context.textTheme.labelLarge?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.35),
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 1,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
// Show 3 skeleton app cards
|
// Show 3 skeleton app cards
|
||||||
Column(children: List.generate(3, (index) => AppCard(isLoading: true))),
|
Column(children: List.generate(3, (index) => AppCard(isLoading: true))),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user