From 7e2a5a49b56f3616825fbda1ca36656ef33bee46 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 23 Dec 2025 16:10:23 -0300 Subject: [PATCH] App stacks with new UX, other adjustments to search screen --- lib/router.dart | 28 +- lib/screens/app_stack_screen.dart | 369 +++++++++++++++ lib/screens/search_screen.dart | 28 +- lib/widgets/app_card.dart | 18 +- lib/widgets/app_stack_container.dart | 497 +++++++++++++++++++++ lib/widgets/comments_section.dart | 229 +++++++++- lib/widgets/latest_releases_container.dart | 76 +++- 7 files changed, 1199 insertions(+), 46 deletions(-) create mode 100644 lib/screens/app_stack_screen.dart create mode 100644 lib/widgets/app_stack_container.dart diff --git a/lib/router.dart b/lib/router.dart index 6debe74..27169c9 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/screens/main_scaffold.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/search_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) 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')) { try { final decoded = Utils.decodeShareableIdentifier(rawId); @@ -33,7 +34,7 @@ GoRoute _appDetailRoute() { path: 'app/:id', builder: (context, state) { final rawId = state.pathParameters['id']!; - final resolved = _resolveAppRouteId(rawId); + final resolved = _resolveNaddrRouteId(rawId); return AppDetailScreen( appId: resolved.identifier, 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 GoRoute _userRoute() { return GoRoute( @@ -80,7 +96,7 @@ final routerProvider = Provider((ref) { GoRoute( path: '/search', builder: (context, state) => const SearchScreen(), - routes: [_appDetailRoute(), _userRoute()], + routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], ), ], ), @@ -90,7 +106,7 @@ final routerProvider = Provider((ref) { GoRoute( path: '/updates', builder: (context, state) => const UpdatesScreen(), - routes: [_appDetailRoute(), _userRoute()], + routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], ), ], ), @@ -100,7 +116,7 @@ final routerProvider = Provider((ref) { GoRoute( path: '/profile', builder: (context, state) => const ProfileScreen(), - routes: [_appDetailRoute(), _userRoute()], + routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], ), ], ), diff --git a/lib/screens/app_stack_screen.dart b/lib/screens/app_stack_screen.dart new file mode 100644 index 0000000..2fff288 --- /dev/null +++ b/lib/screens/app_stack_screen.dart @@ -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( + 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( + 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 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( + 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 _sortAppsUninstalledFirst( + List apps, + PackageManager packageManager, + ) { + final uninstalled = []; + final installed = []; + + 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)), + ], + ), + ), + ); + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 46937df..e0cee53 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/app_card.dart'; import '../utils/extensions.dart'; @@ -116,10 +116,10 @@ class SearchScreen extends HookConsumerWidget { scrollController: scrollController, ), - // App Curation Container with professional spacing + // App Stacks Container Padding( - padding: const EdgeInsets.only(bottom: 16), - child: AppPackContainer( + padding: const EdgeInsets.only(bottom: 14), + child: AppStackContainer( showSkeleton: !(initState.hasValue || initState.hasError), ), ), @@ -199,7 +199,7 @@ class _SearchResultsSection extends HookConsumerWidget { children: [ if (isSearching) Column( - children: List.generate(3, (_) => const AppCard(isLoading: true)), + children: List.generate(2, (_) => const AppCard(isLoading: true)), ) else if (error != null) Padding( @@ -246,7 +246,9 @@ class _SearchResultsSection extends HookConsumerWidget { Column( children: [ // 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), @@ -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); - } -} diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index 13c501d..32c07bc 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -71,8 +71,8 @@ class AppCard extends HookConsumerWidget { context.push('/$first/app/$naddr'); }, child: Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - padding: const EdgeInsets.all(16), + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), 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) if (showDescription) ...[ - const SizedBox(height: 12), + const SizedBox(height: 10), Text( descriptionText, style: descriptionStyle, @@ -341,9 +341,9 @@ class AppCard extends HookConsumerWidget { child: Skeletonizer( enabled: true, child: Container( - // Match actual content: vertical: 6, not 8 - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - padding: const EdgeInsets.all(16), + // Match actual content: vertical: 4 + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), 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 if (showDescription) ...[ - const SizedBox(height: 12), + const SizedBox(height: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -454,7 +454,7 @@ class _AppCardUpdateButtonSection extends ConsumerWidget { return Column( children: [ - const Gap(12), + const Gap(10), ConstrainedBox( constraints: const BoxConstraints(minHeight: 38), child: _CompactInstallButton( @@ -489,7 +489,7 @@ class _AppCardZapEncouragementSection extends ConsumerWidget { return Column( children: [ - const Gap(12), + const Gap(10), _ZapEncouragementInCard(app: app, author: publisher), ], ); diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart new file mode 100644 index 0000000..611becd --- /dev/null +++ b/lib/widgets/app_stack_container.dart @@ -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( + 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( + 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, + _ => [], + }; + + 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 _sortStacks( + List stacks, { + String? signedInPubkey, + Set? followingPubkeys, + }) { + final random = Random(); + + if (signedInPubkey != null && + followingPubkeys != null && + followingPubkeys.isNotEmpty) { + // Signed in: followed profiles first, then by recency + final followed = []; + final others = []; + + 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( + 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.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 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, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/comments_section.dart b/lib/widgets/comments_section.dart index c8c06cd..d20fac4 100644 --- a/lib/widgets/comments_section.dart +++ b/lib/widgets/comments_section.dart @@ -11,6 +11,7 @@ import 'package:zapstore/widgets/auth_widgets.dart'; import 'package:zapstore/widgets/common/profile_avatar.dart'; import 'package:zapstore/widgets/pill_widget.dart'; +/// Comments section for App detail screen class CommentsSection extends HookConsumerWidget { const CommentsSection({super.key, required this.app, this.fileMetadata}); @@ -43,6 +44,64 @@ class CommentsSection extends HookConsumerWidget { _ => 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( + tags: { + '#A': {stack.id}, + }, + source: LocalAndRemoteSource(stream: true, relays: 'social'), + subscriptionPrefix: 'stack-comments', + ), + ); + + // Extract comments and error state + final List 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 comments; + final Object? errorException; + final Widget addCommentButton; + + @override + Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -64,11 +123,11 @@ class CommentsSection extends HookConsumerWidget { ), const SizedBox(height: 12), // Add Comment button - always visible - _AddCommentButton(fileMetadata: fileMetadata!), + addCommentButton, // Error message if any if (errorException != null) ...[ const SizedBox(height: 16), - _buildCommentsError(context, errorException), + _buildCommentsError(context, errorException!), ], // Comments list - only when there are comments 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 { 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( + Colors.white, + ), + ), + ), + orElse: () => child, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Future _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'); + } + } + } +} diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 34b5c4b..7511fd5 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -49,13 +49,42 @@ class LatestReleasesContainer extends HookConsumerWidget { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'Latest Releases', - style: context.textTheme.headlineSmall, + 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.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 || storage.models.isEmpty) Column( @@ -150,13 +179,42 @@ class LatestReleasesContainer extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'Latest Releases', - style: context.textTheme.headlineSmall, + 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: 12), + const SizedBox(height: 8), // Show 3 skeleton app cards Column(children: List.generate(3, (index) => AppCard(isLoading: true))), ],