Use app stacks, fix headlines

This commit is contained in:
franzap
2025-12-23 20:38:49 -03:00
parent 2e6b555149
commit 00fa2c173b
18 changed files with 370 additions and 723 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ const kFranzapPubkey =
/// Identifier for storing user saved apps /// Identifier for storing user saved apps
const kAppBookmarksIdentifier = 'zapstore-bookmarks'; const kAppBookmarksIdentifier = 'zapstore-bookmarks';
/// Event filter for app packs - excludes saved apps and packs with zero App references /// Event filter for app stacks - excludes saved apps and stacks with zero App references
bool appPackEventFilter(Map<String, dynamic> event) { bool appStackEventFilter(Map<String, dynamic> event) {
final tags = event['tags'] as List<dynamic>?; final tags = event['tags'] as List<dynamic>?;
if (tags == null) return false; if (tags == null) return false;
+36 -45
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
@@ -22,7 +23,6 @@ import 'package:zapstore/widgets/download_text_container.dart';
import 'package:zapstore/widgets/expandable_markdown.dart'; import 'package:zapstore/widgets/expandable_markdown.dart';
import 'package:zapstore/widgets/install_button.dart'; import 'package:zapstore/widgets/install_button.dart';
import 'package:zapstore/widgets/screenshots_gallery.dart'; import 'package:zapstore/widgets/screenshots_gallery.dart';
import 'package:zapstore/widgets/version_pill_widget.dart';
class AppDetailScreen extends HookConsumerWidget { class AppDetailScreen extends HookConsumerWidget {
const AppDetailScreen({super.key, required this.appId, this.authorPubkey}); const AppDetailScreen({super.key, required this.appId, this.authorPubkey});
@@ -309,51 +309,42 @@ class _AppDetailContent extends HookConsumerWidget {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'Version ', 'Version:',
style: context.textTheme.bodyMedium, style: context.textTheme.bodyMedium,
), ),
ColorFiltered( Gap(4),
colorFilter: const ColorFilter.matrix(<double>[ Text(
0.2126, latestMetadata.version,
0.7152, style: context.textTheme.bodyMedium?.copyWith(
0.0722, fontWeight: FontWeight.bold,
0,
0,
0.2126,
0.7152,
0.0722,
0,
0,
0.2126,
0.7152,
0.0722,
0,
0,
0,
0,
0,
1,
0,
]),
child: VersionPillWidget(
app: app,
forceVersion: latestMetadata.version,
), ),
), ),
const SizedBox(width: 12), Gap(4),
Text( Text(
'(${formatDate(latestMetadata.createdAt)})', '(${formatDate(latestMetadata.createdAt)})',
style: context.textTheme.bodyMedium?.copyWith( style: context.textTheme.bodyMedium?.copyWith(
color: Theme.of( color: Theme.of(context).colorScheme.onSurface
context, .withValues(alpha: 0.6),
).colorScheme.onSurface.withValues(alpha: 0.6),
), ),
), ),
], ],
), ),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20),
child: ReleaseNotes(release: latestRelease), child: ReleaseNotes(release: latestRelease),
@@ -586,9 +577,9 @@ class _AppDetailContent extends HookConsumerWidget {
return; return;
} }
// Query for existing pack // Query for existing stack
final existingPackState = await ref.storage.query( final existingStackState = await ref.storage.query(
RequestFilter<AppPack>( RequestFilter<AppStack>(
authors: {signedInPubkey}, authors: {signedInPubkey},
tags: { tags: {
'#d': {kAppBookmarksIdentifier}, '#d': {kAppBookmarksIdentifier},
@@ -596,14 +587,14 @@ class _AppDetailContent extends HookConsumerWidget {
).toRequest(), ).toRequest(),
source: const LocalSource(), source: const LocalSource(),
); );
final existingPack = existingPackState.firstOrNull; final existingStack = existingStackState.firstOrNull;
// Get existing app IDs by decrypting if pack exists // Get existing app IDs by decrypting if stack exists
List<String> existingAppIds = []; List<String> existingAppIds = [];
if (existingPack != null) { if (existingStack != null) {
try { try {
final decryptedContent = await signer.nip44Decrypt( final decryptedContent = await signer.nip44Decrypt(
existingPack.content, existingStack.content,
signedInPubkey, signedInPubkey,
); );
existingAppIds = (jsonDecode(decryptedContent) as List) existingAppIds = (jsonDecode(decryptedContent) as List)
@@ -631,19 +622,19 @@ class _AppDetailContent extends HookConsumerWidget {
} }
} }
// Create new partial pack with updated list // Create new partial stack with updated list
final partialPack = PartialAppPack.withEncryptedApps( final partialStack = PartialAppStack.withEncryptedApps(
name: 'Saved Apps', name: 'Saved Apps',
identifier: kAppBookmarksIdentifier, identifier: kAppBookmarksIdentifier,
apps: existingAppIds, apps: existingAppIds,
); );
// Sign (encrypts the content) // Sign (encrypts the content)
final signedPack = await partialPack.signWith(signer); final signedStack = await partialStack.signWith(signer);
// Save to local storage and publish to relays // Save to local storage and publish to relays
await ref.storage.save({signedPack}); await ref.storage.save({signedStack});
ref.storage.publish({signedPack}, source: RemoteSource(relays: 'social')); ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social'));
if (context.mounted) { if (context.mounted) {
context.showInfo( context.showInfo(
+6 -10
View File
@@ -22,7 +22,7 @@ class AppStackScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Query stack with apps relationship // Query stack with apps relationship
final stackState = ref.watch( final stackState = ref.watch(
query<AppPack>( query<AppStack>(
authors: authorPubkey != null ? {authorPubkey!} : null, authors: authorPubkey != null ? {authorPubkey!} : null,
tags: { tags: {
'#d': {stackId}, '#d': {stackId},
@@ -61,7 +61,7 @@ class AppStackScreen extends HookConsumerWidget {
/// Intermediate widget that loads apps with their release relationships /// Intermediate widget that loads apps with their release relationships
class _AppStackContentWithApps extends HookConsumerWidget { class _AppStackContentWithApps extends HookConsumerWidget {
final AppPack? stack; final AppStack? stack;
const _AppStackContentWithApps({required this.stack}); const _AppStackContentWithApps({required this.stack});
@@ -132,7 +132,7 @@ class _ErrorScaffold extends StatelessWidget {
/// Internal widget that displays stack details /// Internal widget that displays stack details
class _AppStackContent extends HookConsumerWidget { class _AppStackContent extends HookConsumerWidget {
final AppPack stack; final AppStack stack;
final List<App> apps; final List<App> apps;
const _AppStackContent({required this.stack, required this.apps}); const _AppStackContent({required this.stack, required this.apps});
@@ -179,9 +179,7 @@ class _AppStackContent extends HookConsumerWidget {
children: [ children: [
Text( Text(
'Apps in this stack', 'Apps in this stack',
style: context.textTheme.titleLarge?.copyWith( style: context.textTheme.titleLarge,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
CountBadge( CountBadge(
@@ -235,7 +233,7 @@ class _AppStackContent extends HookConsumerWidget {
class _StackHeader extends StatelessWidget { class _StackHeader extends StatelessWidget {
const _StackHeader({required this.stack, this.author}); const _StackHeader({required this.stack, this.author});
final AppPack stack; final AppStack stack;
final Profile? author; final Profile? author;
@override @override
@@ -246,9 +244,7 @@ class _StackHeader extends StatelessWidget {
// Stack name // Stack name
Text( Text(
stack.name ?? stack.identifier, stack.name ?? stack.identifier,
style: context.textTheme.headlineMedium?.copyWith( style: context.textTheme.headlineMedium,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Published by author (same style as app_detail_screen) // Published by author (same style as app_detail_screen)
+4 -12
View File
@@ -375,9 +375,7 @@ class _DebugMessagesSection extends HookConsumerWidget {
Expanded( Expanded(
child: Text( child: Text(
'Debug Info', 'Debug Info',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium,
fontWeight: FontWeight.bold,
),
), ),
), ),
], ],
@@ -975,9 +973,7 @@ class _DataManagementSection extends ConsumerWidget {
children: [ children: [
Text( Text(
'Data Management', 'Data Management',
style: Theme.of( style: Theme.of(context).textTheme.titleMedium,
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
@@ -1267,9 +1263,7 @@ class _AboutSection extends ConsumerWidget {
children: [ children: [
Text( Text(
'About', 'About',
style: Theme.of( style: Theme.of(context).textTheme.titleMedium,
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (isLoading) ...[ if (isLoading) ...[
@@ -1326,9 +1320,7 @@ class _AboutSection extends ConsumerWidget {
children: [ children: [
Text( Text(
'About', 'About',
style: Theme.of( style: Theme.of(context).textTheme.titleMedium,
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
+4 -12
View File
@@ -187,9 +187,7 @@ class _UpdatesListBody extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'No apps installed yet', 'No apps installed yet',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -313,9 +311,7 @@ class _InstallingHeader extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Installing', 'Installing',
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground), CountBadge(count: count, color: AppColors.darkPillBackground),
@@ -356,9 +352,7 @@ class _UpdatesSectionHeader extends ConsumerWidget {
Flexible( Flexible(
child: Text( child: Text(
title, title,
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium,
fontWeight: FontWeight.bold,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -485,9 +479,7 @@ class _UpToDateHeader extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Up to date', 'Up to date',
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground), CountBadge(count: count, color: AppColors.darkPillBackground),
+86 -33
View File
@@ -5,11 +5,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import '../constants/app_constants.dart';
import '../theme.dart'; import '../theme.dart';
import '../widgets/common/note_parser.dart'; import '../widgets/common/note_parser.dart';
import '../widgets/common/profile_avatar.dart'; import '../widgets/common/profile_avatar.dart';
import '../widgets/app_card.dart'; import '../widgets/app_card.dart';
import '../widgets/app_pack_container.dart';
import '../widgets/zap_widgets.dart'; import '../widgets/zap_widgets.dart';
/// User profile screen - shows any user/developer profile /// User profile screen - shows any user/developer profile
@@ -55,9 +55,9 @@ class UserScreen extends HookConsumerWidget {
? userAppsState.models.where((a) => a.isZapstoreApp).toList() ? userAppsState.models.where((a) => a.isZapstoreApp).toList()
: userAppsState.models; : userAppsState.models;
// Query user's app packs // Query user's app stacks
final appPacksState = ref.watch( final appStacksState = ref.watch(
query<AppPack>( query<AppStack>(
authors: {pubkey}, authors: {pubkey},
limit: 20, limit: 20,
and: (pack) => {pack.apps}, and: (pack) => {pack.apps},
@@ -66,11 +66,11 @@ class UserScreen extends HookConsumerWidget {
relays: 'AppCatalog', relays: 'AppCatalog',
stream: false, stream: false,
), ),
schemaFilter: appPackEventFilter, schemaFilter: appStackEventFilter,
subscriptionPrefix: 'user-packs', subscriptionPrefix: 'user-stacks',
), ),
); );
final packs = appPacksState.models final stacks = appStacksState.models
..sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); ..sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
return Scaffold( return Scaffold(
@@ -99,9 +99,7 @@ class UserScreen extends HookConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text( child: Text(
'Published Apps', 'Published Apps',
style: context.textTheme.titleLarge?.copyWith( style: context.textTheme.titleLarge,
fontWeight: FontWeight.bold,
),
), ),
), ),
), ),
@@ -113,33 +111,23 @@ class UserScreen extends HookConsumerWidget {
), ),
], ],
// App packs section - only show if packs exist // App stacks section - only show if stacks exist
if (packs.isNotEmpty) ...[ if (stacks.isNotEmpty) ...[
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 32, 16, 16), padding: const EdgeInsets.fromLTRB(16, 32, 16, 8),
child: Text( child: Text(
'App Packs', 'App Stacks',
style: context.textTheme.headlineMedium?.copyWith( style: context.textTheme.headlineMedium,
fontWeight: FontWeight.bold,
), ),
), ),
), ),
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final stack = stacks[index];
return _StackLinkCard(stack: stack, pubkey: pubkey);
}, childCount: stacks.length),
), ),
for (final pack in packs) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
pack.name ?? pack.identifier,
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
SliverToBoxAdapter(child: AppsGrid(apps: pack.apps.toList())),
],
], ],
// Bottom padding // Bottom padding
@@ -172,9 +160,7 @@ class _UserHeader extends StatelessWidget {
Text( Text(
profile?.nameOrNpub ?? profile?.nameOrNpub ??
'${Utils.encodeShareableFromString(pubkey, type: 'npub').substring(0, 12)}...', '${Utils.encodeShareableFromString(pubkey, type: 'npub').substring(0, 12)}...',
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall,
fontWeight: FontWeight.bold,
),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -272,6 +258,73 @@ class _UserZapsList extends HookConsumerWidget {
} }
} }
class _StackLinkCard extends StatelessWidget {
const _StackLinkCard({required this.stack, required this.pubkey});
final AppStack stack;
final String pubkey;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: InkWell(
onTap: () {
final segments = GoRouterState.of(context).uri.pathSegments;
final first = segments.isNotEmpty ? segments.first : 'search';
// Build naddr for the stack
final naddr = Utils.encodeShareableIdentifier(
AddressInput(
identifier: stack.identifier,
author: pubkey,
kind: stack.event.kind,
relays: const [],
),
);
context.push('/$first/stack/$naddr');
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stack.name ?? stack.identifier,
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
);
}
}
class _UserBio extends HookWidget { class _UserBio extends HookWidget {
const _UserBio({required this.profile}); const _UserBio({required this.profile});
+6 -6
View File
@@ -13,10 +13,10 @@ final bookmarksProvider = FutureProvider<Set<String>>((ref) async {
return {}; return {};
} }
// Query saved apps pack - stream enabled to auto-update when saved apps change locally // Query saved apps stack - stream enabled to auto-update when saved apps change locally
// Initial fetch from remote, then watches local storage for changes // Initial fetch from remote, then watches local storage for changes
final packState = ref.watch( final stackState = ref.watch(
query<AppPack>( query<AppStack>(
authors: {signedInPubkey}, authors: {signedInPubkey},
tags: { tags: {
'#d': {kAppBookmarksIdentifier}, '#d': {kAppBookmarksIdentifier},
@@ -30,20 +30,20 @@ final bookmarksProvider = FutureProvider<Set<String>>((ref) async {
); );
// Handle different storage states // Handle different storage states
final pack = switch (packState) { final stack = switch (stackState) {
StorageLoading() => null, StorageLoading() => null,
StorageError() => null, StorageError() => null,
StorageData(:final models) => models.firstOrNull, StorageData(:final models) => models.firstOrNull,
}; };
if (pack == null || pack.content.isEmpty) { if (stack == null || stack.content.isEmpty) {
return {}; return {};
} }
try { try {
// Content is always encrypted after signing - must explicitly decrypt // Content is always encrypted after signing - must explicitly decrypt
final decryptedContent = await signer.nip44Decrypt( final decryptedContent = await signer.nip44Decrypt(
pack.content, stack.content,
signedInPubkey, signedInPubkey,
); );
+5 -5
View File
@@ -115,7 +115,7 @@ TextTheme _createProfessionalTextTheme() {
headlineMedium: const TextStyle( headlineMedium: const TextStyle(
fontFamily: kHeadlineFontFamily, fontFamily: kHeadlineFontFamily,
fontSize: 31, fontSize: 31,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w800,
letterSpacing: 0.15, letterSpacing: 0.15,
color: baseColor, color: baseColor,
height: 1.3, height: 1.3,
@@ -123,7 +123,7 @@ TextTheme _createProfessionalTextTheme() {
headlineSmall: const TextStyle( headlineSmall: const TextStyle(
fontFamily: kHeadlineFontFamily, fontFamily: kHeadlineFontFamily,
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w800,
letterSpacing: 0.15, letterSpacing: 0.15,
color: baseColor, color: baseColor,
height: 1.35, height: 1.35,
@@ -133,7 +133,7 @@ TextTheme _createProfessionalTextTheme() {
titleLarge: const TextStyle( titleLarge: const TextStyle(
fontFamily: kHeadlineFontFamily, fontFamily: kHeadlineFontFamily,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w700,
letterSpacing: 0.1, letterSpacing: 0.1,
color: baseColor, color: baseColor,
height: 1.4, height: 1.4,
@@ -141,7 +141,7 @@ TextTheme _createProfessionalTextTheme() {
titleMedium: const TextStyle( titleMedium: const TextStyle(
fontFamily: kHeadlineFontFamily, fontFamily: kHeadlineFontFamily,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w700,
letterSpacing: 0.15, letterSpacing: 0.15,
color: baseColor, color: baseColor,
height: 1.45, height: 1.45,
@@ -149,7 +149,7 @@ TextTheme _createProfessionalTextTheme() {
titleSmall: const TextStyle( titleSmall: const TextStyle(
fontFamily: kHeadlineFontFamily, fontFamily: kHeadlineFontFamily,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w700,
letterSpacing: 0.15, letterSpacing: 0.15,
color: baseColor, color: baseColor,
height: 1.45, height: 1.45,
-1
View File
@@ -243,7 +243,6 @@ class AppCard extends HookConsumerWidget {
final appName = app!.name ?? app!.identifier; final appName = app!.name ?? app!.identifier;
final titleStyle = context.textTheme.titleMedium?.copyWith( final titleStyle = context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
letterSpacing: 0.1,
); );
// If no publisher or relay-signed, just show name // If no publisher or relay-signed, just show name
+18 -18
View File
@@ -64,7 +64,7 @@ class ReleaseNotes extends StatelessWidget {
} }
} }
/// Row of social action buttons (zap, bookmark, app pack, share) /// Row of social action buttons (zap, bookmark, app stack, share)
class SocialActionsRow extends HookConsumerWidget { class SocialActionsRow extends HookConsumerWidget {
const SocialActionsRow({super.key, required this.app, required this.author}); const SocialActionsRow({super.key, required this.app, required this.author});
@@ -92,7 +92,7 @@ class SocialActionsRow extends HookConsumerWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Zap + Save App + App Pack button row // Zap + Save App + App Stack button row
SizedBox( SizedBox(
height: 36, height: 36,
child: Row( child: Row(
@@ -141,11 +141,11 @@ class SocialActionsRow extends HookConsumerWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// App Pack button // App Stack button
Expanded( Expanded(
flex: 16, flex: 16,
child: FilledButton( child: FilledButton(
onPressed: () => _showAddToPackDialog(context, app), onPressed: () => _showAddToStackDialog(context, app),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
backgroundColor: backgroundColor:
@@ -193,9 +193,9 @@ class SocialActionsRow extends HookConsumerWidget {
if (signer == null || signedInPubkey == null) return; if (signer == null || signedInPubkey == null) return;
// Query for existing pack // Query for existing stack
final existingPackState = await ref.storage.query( final existingStackState = await ref.storage.query(
RequestFilter<AppPack>( RequestFilter<AppStack>(
authors: {signedInPubkey}, authors: {signedInPubkey},
tags: { tags: {
'#d': {kAppBookmarksIdentifier}, '#d': {kAppBookmarksIdentifier},
@@ -203,14 +203,14 @@ class SocialActionsRow extends HookConsumerWidget {
).toRequest(), ).toRequest(),
source: const LocalSource(), source: const LocalSource(),
); );
final existingPack = existingPackState.firstOrNull; final existingStack = existingStackState.firstOrNull;
// Get existing app IDs by decrypting if pack exists // Get existing app IDs by decrypting if stack exists
List<String> existingAppIds = []; List<String> existingAppIds = [];
if (existingPack != null) { if (existingStack != null) {
try { try {
final decryptedContent = await signer.nip44Decrypt( final decryptedContent = await signer.nip44Decrypt(
existingPack.content, existingStack.content,
signedInPubkey, signedInPubkey,
); );
existingAppIds = (jsonDecode(decryptedContent) as List) existingAppIds = (jsonDecode(decryptedContent) as List)
@@ -232,19 +232,19 @@ class SocialActionsRow extends HookConsumerWidget {
} }
} }
// Create new partial pack with updated list // Create new partial stack with updated list
final partialPack = PartialAppPack.withEncryptedApps( final partialStack = PartialAppStack.withEncryptedApps(
name: 'Saved Apps', name: 'Saved Apps',
identifier: kAppBookmarksIdentifier, identifier: kAppBookmarksIdentifier,
apps: existingAppIds, apps: existingAppIds,
); );
// Sign (encrypts the content) // Sign (encrypts the content)
final signedPack = await partialPack.signWith(signer); final signedStack = await partialStack.signWith(signer);
// Save to local storage and publish to relays // Save to local storage and publish to relays
await ref.storage.save({signedPack}); await ref.storage.save({signedStack});
ref.storage.publish({signedPack}, source: RemoteSource(relays: 'social')); ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social'));
if (context.mounted) { if (context.mounted) {
context.showInfo( context.showInfo(
@@ -258,10 +258,10 @@ class SocialActionsRow extends HookConsumerWidget {
} }
} }
Future<void> _showAddToPackDialog(BuildContext context, App app) async { Future<void> _showAddToStackDialog(BuildContext context, App app) async {
await showBaseDialog( await showBaseDialog(
context: context, context: context,
dialog: AddToPackDialog(app: app), dialog: AddToStackDialog(app: app),
); );
} }
} }
-2
View File
@@ -61,8 +61,6 @@ class AppHeader extends StatelessWidget {
AutoSizeText( AutoSizeText(
app.name ?? app.identifier, app.name ?? app.identifier,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0.1,
fontSize: fontSize:
(Theme.of( (Theme.of(
context, context,
-421
View File
@@ -1,421 +0,0 @@
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 'rounded_image.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:auto_size_text/auto_size_text.dart';
import '../utils/extensions.dart';
import '../utils/url_utils.dart';
import '../theme.dart';
/// App Pack Container - horizontally scrollable pills with app collections
class AppPackContainer extends HookConsumerWidget {
const AppPackContainer({super.key, this.showSkeleton = false});
final bool showSkeleton;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (showSkeleton) {
return _buildSkeleton(context);
}
final appPacksState = ref.watch(
query<AppPack>(
limit: 20,
and: (pack) => {pack.apps},
source: LocalAndRemoteSource(stream: true, relays: 'social'),
andSource: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
schemaFilter: appPackEventFilter,
subscriptionPrefix: 'app-pack',
),
);
final packs = switch (appPacksState) {
StorageData(:final models) =>
models..sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)),
_ => <AppPack>[],
};
if (packs.isEmpty) {
return _buildSkeleton(context);
}
final selectedId = useState(packs.first.id);
final selectedPack = packs.firstWhere(
(p) => p.id == selectedId.value,
orElse: () => packs.first,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 48,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: packs
.map(
(pack) => _PackPill(
pack: pack,
isSelected: selectedId.value == pack.id,
onTap: () => selectedId.value = pack.id,
),
)
.toList(),
),
),
),
const SizedBox(height: 16),
AppsGrid(apps: selectedPack.apps.toList()),
],
);
}
Widget _buildSkeleton(BuildContext context) {
return SkeletonizerConfig(
data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness),
child: Skeletonizer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(children: List.generate(3, (_) => _SkeletonPill())),
),
const SizedBox(height: 16),
const AppsGrid(apps: []),
],
),
),
);
}
}
/// Skeleton pill matching actual pill dimensions
class _SkeletonPill extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(18),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(height: 16, width: 50, color: AppColors.darkSkeletonBase),
const SizedBox(width: 4),
Container(height: 14, width: 16, color: AppColors.darkSkeletonBase),
const SizedBox(width: 6),
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(9),
),
),
const SizedBox(width: 6),
Container(height: 14, width: 50, color: AppColors.darkSkeletonBase),
],
),
),
);
}
}
/// Individual pack pill - watches its own author reactively
class _PackPill extends ConsumerWidget {
const _PackPill({
required this.pack,
required this.isSelected,
required this.onTap,
});
final AppPack pack;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final authorState = ref.watch(
query<Profile>(
authors: {pack.event.pubkey},
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
cachedFor: Duration(hours: 2),
),
),
);
final author = switch (authorState) {
StorageData(:final models) => models.firstOrNull,
_ => null,
};
return Padding(
padding: const EdgeInsets.only(right: 16),
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.15)
: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(18),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
pack.name ?? pack.identifier,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (author != null) ...[
Text(
' by ',
style: context.textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
GestureDetector(
onTap: isSelected
? () {
final segments = GoRouterState.of(
context,
).uri.pathSegments;
final first = segments.isNotEmpty
? segments.first
: 'search';
context.push('/$first/user/${pack.event.pubkey}');
}
: null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RoundedImage(url: author.pictureUrl, size: 18),
),
),
const SizedBox(width: 6),
Text(
author.nameOrNpub,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
],
),
),
),
);
}
}
/// Horizontally scrolling apps grid - reusable widget for displaying apps in a 2-row grid
class AppsGrid extends StatelessWidget {
const AppsGrid({super.key, required this.apps});
final List<App> apps;
@override
Widget build(BuildContext context) {
final items = apps.isEmpty ? List.generate(6, (_) => null) : apps;
return SizedBox(
height: 220,
child: GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
scrollDirection: Axis.horizontal,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.8,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: items.length,
itemBuilder: (context, index) => AppGridCard(app: items[index]),
),
);
}
}
/// Individual app card with original styling - reusable widget for grid display
class AppGridCard extends ConsumerWidget {
const AppGridCard({super.key, this.app});
final App? app;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (app == null) {
return Padding(
padding: const EdgeInsets.all(14),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 8),
Container(
height: 10,
width: 40,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(5),
),
),
],
),
);
}
final iconUrl = firstValidHttpUrl(app!.icons);
return GestureDetector(
onTap: () {
if (app!.latestRelease.value == null) {
final req = app!.latestRelease.req;
if (req != null) {
ref.storage.query(
req,
source: const LocalAndRemoteSource(stream: false),
);
}
}
final segments = GoRouterState.of(context).uri.pathSegments;
final first = segments.isNotEmpty ? segments.first : 'search';
// Prefer naddr so the detail screen can uniquely identify the app
// (identifier + author) and not accidentally resolve to a different
// publisher's app with the same identifier.
final naddr = Utils.encodeShareableIdentifier(
AddressInput(
identifier: app!.identifier,
author: app!.pubkey,
kind: app!.event.kind,
relays: const [],
),
);
context.push('/$first/app/$naddr');
},
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: iconUrl != null
? CachedNetworkImage(
imageUrl: iconUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 500),
fadeOutDuration: const Duration(milliseconds: 200),
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => Icon(
Icons.broken_image_outlined,
size: 16,
color: Colors.grey[400],
),
)
: Icon(
Icons.apps_outlined,
size: 16,
color: Colors.grey[400],
),
),
),
const SizedBox(height: 12),
AutoSizeText(
app!.name ?? app!.identifier,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
height: 1.2,
),
maxLines: 1,
minFontSize: 10,
maxFontSize: 12,
textAlign: TextAlign.center,
overflow: TextOverflow.fade,
softWrap: false,
),
],
),
),
),
),
);
}
}
+73 -18
View File
@@ -6,6 +6,7 @@ import 'package:models/models.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import '../constants/app_constants.dart';
import '../utils/extensions.dart'; import '../utils/extensions.dart';
import '../utils/url_utils.dart'; import '../utils/url_utils.dart';
import '../theme.dart'; import '../theme.dart';
@@ -28,7 +29,7 @@ class AppStackContainer extends HookConsumerWidget {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider); final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final appStacksState = ref.watch( final appStacksState = ref.watch(
query<AppPack>( query<AppStack>(
limit: 50, limit: 50,
and: (pack) => {pack.apps}, and: (pack) => {pack.apps},
source: LocalAndRemoteSource(stream: true, relays: 'social'), source: LocalAndRemoteSource(stream: true, relays: 'social'),
@@ -36,7 +37,7 @@ class AppStackContainer extends HookConsumerWidget {
relays: 'AppCatalog', relays: 'AppCatalog',
stream: false, stream: false,
), ),
schemaFilter: appPackEventFilter, schemaFilter: appStackEventFilter,
subscriptionPrefix: 'app-stack', subscriptionPrefix: 'app-stack',
), ),
); );
@@ -61,7 +62,7 @@ class AppStackContainer extends HookConsumerWidget {
final rawStacks = switch (appStacksState) { final rawStacks = switch (appStacksState) {
StorageData(:final models) => models, StorageData(:final models) => models,
_ => <AppPack>[], _ => <AppStack>[],
}; };
if (rawStacks.isEmpty) { if (rawStacks.isEmpty) {
@@ -87,7 +88,9 @@ class AppStackContainer extends HookConsumerWidget {
for (int i = 0; i < stacks.length; i += 2) for (int i = 0; i < stacks.length; i += 2)
Padding( Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
right: i == stacks.length - 2 || i == stacks.length - 1 ? 0 : 10, right: i == stacks.length - 2 || i == stacks.length - 1
? 0
: 10,
), ),
child: SizedBox( child: SizedBox(
width: 160, width: 160,
@@ -108,8 +111,8 @@ class AppStackContainer extends HookConsumerWidget {
); );
} }
List<AppPack> _sortStacks( List<AppStack> _sortStacks(
List<AppPack> stacks, { List<AppStack> stacks, {
String? signedInPubkey, String? signedInPubkey,
Set<String>? followingPubkeys, Set<String>? followingPubkeys,
}) { }) {
@@ -119,8 +122,8 @@ class AppStackContainer extends HookConsumerWidget {
followingPubkeys != null && followingPubkeys != null &&
followingPubkeys.isNotEmpty) { followingPubkeys.isNotEmpty) {
// Signed in: followed profiles first, then by recency // Signed in: followed profiles first, then by recency
final followed = <AppPack>[]; final followed = <AppStack>[];
final others = <AppPack>[]; final others = <AppStack>[];
for (final stack in stacks) { for (final stack in stacks) {
if (followingPubkeys.contains(stack.pubkey)) { if (followingPubkeys.contains(stack.pubkey)) {
@@ -278,7 +281,7 @@ class _SkeletonStackCard extends StatelessWidget {
class _StackCard extends HookConsumerWidget { class _StackCard extends HookConsumerWidget {
const _StackCard({required this.stack}); const _StackCard({required this.stack});
final AppPack stack; final AppStack stack;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -339,14 +342,13 @@ class _StackCard extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Stack name // Stack name with conditional fade out
Text( _FadingText(
stack.name ?? stack.identifier, stack.name ?? stack.identifier,
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontFamily: 'Inter',
fontSize: (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
// Author row: avatar + Name // Author row: avatar + Name
@@ -397,9 +399,7 @@ class _AppIconsRow extends StatelessWidget {
aspectRatio: 1, aspectRatio: 1,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(context).colorScheme.surfaceContainerHighest
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.8), .withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
@@ -482,7 +482,9 @@ class _AppIconTile extends StatelessWidget {
), ),
) )
: Container( : Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon( child: const Icon(
Icons.apps_outlined, Icons.apps_outlined,
size: 16, size: 16,
@@ -495,3 +497,56 @@ class _AppIconTile extends StatelessWidget {
); );
} }
} }
/// Text widget that only applies fade-out effect when text overflows
class _FadingText extends HookWidget {
const _FadingText(this.text, {required this.style});
final String text;
final TextStyle? style;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Measure if text would overflow
final textPainter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout(maxWidth: double.infinity);
final isOverflowing = textPainter.width > constraints.maxWidth;
final textWidget = Text(
text,
style: style,
maxLines: 1,
overflow: TextOverflow.clip,
);
if (!isOverflowing) {
return textWidget;
}
// Apply fade effect only when overflowing
return ShaderMask(
shaderCallback: (bounds) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Theme.of(context).colorScheme.onSurface,
Theme.of(context).colorScheme.onSurface,
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0),
],
stops: const [0.0, 0.8, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: textWidget,
);
},
);
}
}
+110 -103
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/auth_widgets.dart'; import 'package:zapstore/widgets/auth_widgets.dart';
@@ -104,9 +105,9 @@ class SaveAppDialog extends HookConsumerWidget {
return; return;
} }
// Query for existing pack // Query for existing stack
final existingPackState = await ref.storage.query( final existingStackState = await ref.storage.query(
RequestFilter<AppPack>( RequestFilter<AppStack>(
authors: {signedInPubkey}, authors: {signedInPubkey},
tags: { tags: {
'#d': {kAppBookmarksIdentifier}, '#d': {kAppBookmarksIdentifier},
@@ -114,14 +115,14 @@ class SaveAppDialog extends HookConsumerWidget {
).toRequest(), ).toRequest(),
source: const LocalSource(), source: const LocalSource(),
); );
final existingPack = existingPackState.firstOrNull; final existingStack = existingStackState.firstOrNull;
// Get existing app IDs by decrypting if pack exists // Get existing app IDs by decrypting if stack exists
List<String> existingAppIds = []; List<String> existingAppIds = [];
if (existingPack != null) { if (existingStack != null) {
try { try {
final decryptedContent = await signer.nip44Decrypt( final decryptedContent = await signer.nip44Decrypt(
existingPack.content, existingStack.content,
signedInPubkey, signedInPubkey,
); );
existingAppIds = (jsonDecode(decryptedContent) as List) existingAppIds = (jsonDecode(decryptedContent) as List)
@@ -149,19 +150,21 @@ class SaveAppDialog extends HookConsumerWidget {
} }
} }
// Create new partial pack with updated list // Create new partial stack with updated list
final partialPack = PartialAppPack.withEncryptedApps( final partialStack = PartialAppStack.withEncryptedApps(
name: 'Saved Apps', name: 'Saved Apps',
identifier: kAppBookmarksIdentifier, identifier: kAppBookmarksIdentifier,
apps: existingAppIds, apps: existingAppIds,
); );
// Sign (encrypts the content) // Sign (encrypts the content)
final signedPack = await partialPack.signWith(signer); final signedStack = await partialStack.signWith(signer);
// Save to local storage and publish to relays // Save to local storage and publish to relays
await ref.storage.save({signedPack}); await ref.storage.save({signedStack});
ref.storage.publish({signedPack}, source: RemoteSource(relays: 'social')); ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: 'social'));
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
@@ -177,9 +180,9 @@ class SaveAppDialog extends HookConsumerWidget {
} }
} }
/// Dialog for managing app packs (public collections) /// Dialog for managing app stacks (public collections)
class AddToPackDialog extends HookConsumerWidget { class AddToStackDialog extends HookConsumerWidget {
const AddToPackDialog({super.key, required this.app}); const AddToStackDialog({super.key, required this.app});
final App app; final App app;
@@ -187,14 +190,14 @@ class AddToPackDialog extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider); final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
if (signedInPubkey == null) { if (signedInPubkey == null) {
return _AddToPackDialogSignedOut(app: app); return _AddToStackDialogSignedOut(app: app);
} }
return _AddToPackDialogSignedIn(app: app, signedInPubkey: signedInPubkey); return _AddToStackDialogSignedIn(app: app, signedInPubkey: signedInPubkey);
} }
} }
class _AddToPackDialogSignedOut extends StatelessWidget { class _AddToStackDialogSignedOut extends StatelessWidget {
const _AddToPackDialogSignedOut({required this.app}); const _AddToStackDialogSignedOut({required this.app});
final App app; final App app;
@@ -203,7 +206,7 @@ class _AddToPackDialogSignedOut extends StatelessWidget {
return BaseDialog( return BaseDialog(
titleIcon: const Icon(Icons.apps), titleIcon: const Icon(Icons.apps),
title: Text( title: Text(
'Add to App Packs', 'Add to App Stacks',
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
content: BaseDialogContent( content: BaseDialogContent(
@@ -231,7 +234,7 @@ class _AddToPackDialogSignedOut extends StatelessWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
'Add or remove ${app.name} from public app packs that you share with others.', 'Add or remove ${app.name} from public app stacks that you share with others.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of( color: Theme.of(
context, context,
@@ -244,7 +247,7 @@ class _AddToPackDialogSignedOut extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const SignInPrompt( const SignInPrompt(
message: 'Sign in to create and manage your public app packs.', message: 'Sign in to create and manage your public app stacks.',
), ),
], ],
), ),
@@ -263,8 +266,8 @@ class _AddToPackDialogSignedOut extends StatelessWidget {
} }
} }
class _AddToPackDialogSignedIn extends HookConsumerWidget { class _AddToStackDialogSignedIn extends HookConsumerWidget {
const _AddToPackDialogSignedIn({ const _AddToStackDialogSignedIn({
required this.app, required this.app,
required this.signedInPubkey, required this.signedInPubkey,
}); });
@@ -274,37 +277,38 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final newPackNameController = useTextEditingController(); final newStackNameController = useTextEditingController();
final publicPacksState = ref.watch( final publicStacksState = ref.watch(
query<AppPack>( query<AppStack>(
authors: {signedInPubkey}, authors: {signedInPubkey},
and: (pack) => {pack.apps}, and: (stack) => {stack.apps},
source: const LocalAndRemoteSource(relays: 'social', stream: false), source: const LocalAndRemoteSource(relays: 'social', stream: false),
andSource: const LocalSource(), andSource: const LocalSource(),
subscriptionPrefix: 'user-packs-dialog', schemaFilter: appStackEventFilter,
subscriptionPrefix: 'user-stacks-dialog',
), ),
); );
final publicPacks = publicPacksState.models final publicStacks = publicStacksState.models
.where((pack) => pack.identifier != kAppBookmarksIdentifier) .where((stack) => stack.identifier != kAppBookmarksIdentifier)
.toList(); .toList();
// Check which packs contain this app // Check which stacks contain this app
final selectedCollections = useState<Set<String>>({}); final selectedCollections = useState<Set<String>>({});
// Track new packs with their names (identifier -> name) // Track new stacks with their names (identifier -> name)
final newPackNames = useState<Map<String, String>>({}); final newStackNames = useState<Map<String, String>>({});
useEffect(() { useEffect(() {
final selected = <String>{}; final selected = <String>{};
for (final pack in publicPacks) { for (final stack in publicStacks) {
if (pack.apps.toList().any((appModel) => appModel.id == app.id)) { if (stack.apps.toList().any((appModel) => appModel.id == app.id)) {
selected.add(pack.identifier); selected.add(stack.identifier);
} }
} }
selectedCollections.value = selected; selectedCollections.value = selected;
return null; return null;
}, [app.id, publicPacks.length]); }, [app.id, publicStacks.length]);
// Helper to slugify name to identifier // Helper to slugify name to identifier
String slugify(String text) { String slugify(String text) {
@@ -319,7 +323,7 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
return BaseDialog( return BaseDialog(
titleIcon: const Icon(Icons.apps), titleIcon: const Icon(Icons.apps),
title: Text( title: Text(
'Add to App Packs', 'Add to App Stacks',
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
content: BaseDialogContent( content: BaseDialogContent(
@@ -347,7 +351,7 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
'Add or remove ${app.name} from public app packs that you share with others.', 'Add or remove ${app.name} from public app stacks that you share with others.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of( color: Theme.of(
context, context,
@@ -360,18 +364,18 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Show existing packs and newly created packs as selectable chips // Show existing stacks and newly created stacks as selectable chips
if (publicPacks.isNotEmpty || newPackNames.value.isNotEmpty) ...[ if (publicStacks.isNotEmpty || newStackNames.value.isNotEmpty) ...[
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
// Existing packs // Existing stacks
...publicPacks.map((pack) { ...publicStacks.map((stack) {
final isSelected = selectedCollections.value.contains( final isSelected = selectedCollections.value.contains(
pack.identifier, stack.identifier,
); );
final displayName = pack.name ?? pack.identifier; final displayName = stack.name ?? stack.identifier;
return FilterChip( return FilterChip(
label: Text(displayName), label: Text(displayName),
selected: isSelected, selected: isSelected,
@@ -379,12 +383,12 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
if (selected) { if (selected) {
selectedCollections.value = { selectedCollections.value = {
...selectedCollections.value, ...selectedCollections.value,
pack.identifier, stack.identifier,
}; };
} else { } else {
selectedCollections.value = { selectedCollections.value = {
...selectedCollections.value, ...selectedCollections.value,
}..remove(pack.identifier); }..remove(stack.identifier);
} }
}, },
backgroundColor: isSelected backgroundColor: isSelected
@@ -408,11 +412,11 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
side: BorderSide.none, side: BorderSide.none,
); );
}), }),
// Newly created packs not yet in publicPacks // Newly created stacks not yet in publicStacks
...newPackNames.value.entries ...newStackNames.value.entries
.where( .where(
(entry) => !publicPacks.any( (entry) => !publicStacks.any(
(pack) => pack.identifier == entry.key, (stack) => stack.identifier == entry.key,
), ),
) )
.map((entry) { .map((entry) {
@@ -434,11 +438,11 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
selectedCollections.value = { selectedCollections.value = {
...selectedCollections.value, ...selectedCollections.value,
}..remove(identifier); }..remove(identifier);
// Also remove from newPackNames if deselected // Also remove from newStackNames if deselected
final updatedNames = Map<String, String>.from( final updatedNames = Map<String, String>.from(
newPackNames.value, newStackNames.value,
)..remove(identifier); )..remove(identifier);
newPackNames.value = updatedNames; newStackNames.value = updatedNames;
} }
}, },
backgroundColor: isSelected backgroundColor: isSelected
@@ -469,12 +473,12 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
// Input for new pack // Input for new stack
TextField( TextField(
controller: newPackNameController, controller: newStackNameController,
maxLength: 20, maxLength: 20,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Add to new app pack', labelText: 'Add to new app stack',
hintText: 'e.g., Favorite Apps', hintText: 'e.g., Favorite Apps',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
@@ -488,8 +492,11 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
...selectedCollections.value, ...selectedCollections.value,
identifier, identifier,
}; };
newPackNames.value = {...newPackNames.value, identifier: name}; newStackNames.value = {
newPackNameController.clear(); ...newStackNames.value,
identifier: name,
};
newStackNameController.clear();
} }
}, },
), ),
@@ -502,20 +509,20 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
), ),
AsyncButtonBuilder( AsyncButtonBuilder(
onPressed: () async { onPressed: () async {
// Check if there's a pending new pack name in the text field // Check if there's a pending new stack name in the text field
final pendingPackName = newPackNameController.text.trim(); final pendingStackName = newStackNameController.text.trim();
if (pendingPackName.isNotEmpty) { if (pendingStackName.isNotEmpty) {
final identifier = slugify(pendingPackName); final identifier = slugify(pendingStackName);
if (identifier.isNotEmpty && if (identifier.isNotEmpty &&
!selectedCollections.value.contains(identifier)) { !selectedCollections.value.contains(identifier)) {
// Add the pending pack to the collections // Add the pending stack to the collections
selectedCollections.value = { selectedCollections.value = {
...selectedCollections.value, ...selectedCollections.value,
identifier, identifier,
}; };
newPackNames.value = { newStackNames.value = {
...newPackNames.value, ...newStackNames.value,
identifier: pendingPackName, identifier: pendingStackName,
}; };
} }
} }
@@ -525,9 +532,9 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
context, context,
ref, ref,
app, app,
publicPacks, publicStacks,
selectedCollections.value, selectedCollections.value,
newPackNames.value, newStackNames.value,
); );
}, },
builder: (context, child, callback, buttonState) { builder: (context, child, callback, buttonState) {
@@ -557,9 +564,9 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
App app, App app,
List<AppPack> existingPacks, List<AppStack> existingStacks,
Set<String> selectedCollectionIds, Set<String> selectedCollectionIds,
Map<String, String> newPackNames, Map<String, String> newStackNames,
) async { ) async {
try { try {
final signer = ref.read(Signer.activeSignerProvider); final signer = ref.read(Signer.activeSignerProvider);
@@ -567,7 +574,7 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
if (context.mounted) { if (context.mounted) {
context.showError( context.showError(
'Sign in required', 'Sign in required',
description: 'You need to sign in to manage app packs.', description: 'You need to sign in to manage app stacks.',
); );
} }
return; return;
@@ -575,11 +582,11 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
// Get current state of which collections contain this app // Get current state of which collections contain this app
final currentCollectionsWithApp = <String>{}; final currentCollectionsWithApp = <String>{};
for (final pack in existingPacks) { for (final stack in existingStacks) {
// Get raw app IDs from event tags // Get raw app IDs from event tags
final appIds = pack.event.getTagSetValues('a'); final appIds = stack.event.getTagSetValues('a');
if (appIds.contains(app.id)) { if (appIds.contains(app.id)) {
currentCollectionsWithApp.add(pack.identifier); currentCollectionsWithApp.add(stack.identifier);
} }
} }
@@ -590,79 +597,79 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
// Update or create collections // Update or create collections
for (final collectionId in selectedCollectionIds) { for (final collectionId in selectedCollectionIds) {
final existingPack = existingPacks final existingStack = existingStacks
.where((pack) => pack.identifier == collectionId) .where((stack) => stack.identifier == collectionId)
.firstOrNull; .firstOrNull;
// Use the provided name from newPackNames, or existing pack name, or identifier // Use the provided name from newStackNames, or existing stack name, or identifier
final packName = final stackName =
newPackNames[collectionId] ?? existingPack?.name ?? collectionId; newStackNames[collectionId] ?? existingStack?.name ?? collectionId;
// Get existing app IDs as a list to preserve order // Get existing app IDs as a list to preserve order
final existingAppIds = final existingAppIds =
existingPack?.event.getTagSetValues('a').toList() ?? []; existingStack?.event.getTagSetValues('a').toList() ?? [];
// Check if app already exists in the pack // Check if app already exists in the stack
final appAlreadyExists = existingAppIds.contains(app.id); final appAlreadyExists = existingAppIds.contains(app.id);
// Only update if the app is not already in the pack // Only update if the app is not already in the stack
if (!appAlreadyExists) { if (!appAlreadyExists) {
final partialPack = PartialAppPack( final partialStack = PartialAppStack(
name: packName, name: stackName,
identifier: collectionId, identifier: collectionId,
); );
// Add existing apps in order // Add existing apps in order
for (final appId in existingAppIds) { for (final appId in existingAppIds) {
partialPack.addApp(appId); partialStack.addApp(appId);
} }
// Add this app at the end // Add this app at the end
partialPack.addApp(app.id); partialStack.addApp(app.id);
final signedPack = await partialPack.signWith(signer); final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedPack}); await ref.storage.save({signedStack});
await ref.storage.publish({ await ref.storage.publish({
signedPack, signedStack,
}, source: RemoteSource(relays: 'social')); }, source: RemoteSource(relays: 'social'));
} }
} }
// Remove from deselected collections // Remove from deselected collections
for (final collectionId in collectionsToRemoveFrom) { for (final collectionId in collectionsToRemoveFrom) {
final existingPack = existingPacks final existingStack = existingStacks
.where((pack) => pack.identifier == collectionId) .where((stack) => stack.identifier == collectionId)
.firstOrNull; .firstOrNull;
if (existingPack != null) { if (existingStack != null) {
final partialPack = PartialAppPack( final partialStack = PartialAppStack(
name: existingPack.name ?? collectionId, name: existingStack.name ?? collectionId,
identifier: collectionId, identifier: collectionId,
); );
// Re-add all apps except the one we're removing, preserving order // Re-add all apps except the one we're removing, preserving order
// Get raw app IDs from event tags as a list to preserve order // Get raw app IDs from event tags as a list to preserve order
final existingAppIds = existingPack.event final existingAppIds = existingStack.event
.getTagSetValues('a') .getTagSetValues('a')
.toList(); .toList();
for (final appId in existingAppIds) { for (final appId in existingAppIds) {
if (appId != app.id) { if (appId != app.id) {
partialPack.addApp(appId); partialStack.addApp(appId);
} }
} }
final signedPack = await partialPack.signWith(signer); final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedPack}); await ref.storage.save({signedStack});
await ref.storage.publish({ await ref.storage.publish({
signedPack, signedStack,
}, source: RemoteSource(relays: 'social')); }, source: RemoteSource(relays: 'social'));
} }
} }
if (context.mounted) { if (context.mounted) {
if (selectedCollectionIds.isEmpty) { if (selectedCollectionIds.isEmpty) {
context.showInfo('Removed from all app packs'); context.showInfo('Removed from all app stacks');
} else { } else {
context.showInfo('App packs updated'); context.showInfo('App stacks updated');
} }
Navigator.pop(context); Navigator.pop(context);
} }
+7 -17
View File
@@ -52,11 +52,11 @@ class CommentsSection extends HookConsumerWidget {
} }
} }
/// Comments section for AppPack/Stack detail screen /// Comments section for AppStack/Stack detail screen
class StackCommentsSection extends HookConsumerWidget { class StackCommentsSection extends HookConsumerWidget {
const StackCommentsSection({super.key, required this.stack}); const StackCommentsSection({super.key, required this.stack});
final AppPack stack; final AppStack stack;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -111,12 +111,7 @@ class _CommentsSectionLayout extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text('Comments', style: Theme.of(context).textTheme.titleLarge),
'Comments',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
if (comments.isNotEmpty) if (comments.isNotEmpty)
_CommentCountBadge(count: comments.length), _CommentCountBadge(count: comments.length),
], ],
@@ -251,7 +246,6 @@ class _CommentCard extends HookConsumerWidget {
Text( Text(
author?.nameOrNpub ?? '', author?.nameOrNpub ?? '',
style: context.textTheme.titleSmall?.copyWith( style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
fontSize: fontSize:
(context (context
.textTheme .textTheme
@@ -338,7 +332,7 @@ class _AddCommentButton extends ConsumerWidget {
class _AddStackCommentButton extends ConsumerWidget { class _AddStackCommentButton extends ConsumerWidget {
const _AddStackCommentButton({required this.stack}); const _AddStackCommentButton({required this.stack});
final AppPack stack; final AppStack stack;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -401,9 +395,7 @@ class _CommentComposer extends HookConsumerWidget {
children: [ children: [
Text( Text(
'Comment on $versionToComment', 'Comment on $versionToComment',
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium,
fontWeight: FontWeight.bold,
),
), ),
IconButton( IconButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -532,7 +524,7 @@ class _CommentComposer extends HookConsumerWidget {
class _StackCommentComposer extends HookConsumerWidget { class _StackCommentComposer extends HookConsumerWidget {
const _StackCommentComposer({required this.stack}); const _StackCommentComposer({required this.stack});
final AppPack stack; final AppStack stack;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -557,9 +549,7 @@ class _StackCommentComposer extends HookConsumerWidget {
children: [ children: [
Text( Text(
'Comment on $stackName', 'Comment on $stackName',
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium,
fontWeight: FontWeight.bold,
),
), ),
IconButton( IconButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
+1 -3
View File
@@ -31,9 +31,7 @@ class NWCConnectionCard extends HookConsumerWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Lightning Wallet', 'Lightning Wallet',
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium,
fontWeight: FontWeight.bold,
),
), ),
], ],
), ),
+1 -3
View File
@@ -38,9 +38,7 @@ class ProfileHeader extends StatelessWidget {
// Display name // Display name
Text( Text(
profile!.name ?? profile!.nameOrNpub, profile!.name ?? profile!.nameOrNpub,
style: Theme.of( style: Theme.of(context).textTheme.headlineSmall,
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
-1
View File
@@ -117,7 +117,6 @@ class ZappersHorizontalList extends StatelessWidget {
'⚡️${formatSatsCompact(totalSats)}', '⚡️${formatSatsCompact(totalSats)}',
style: theme.textTheme.titleLarge?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),