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
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
/// Event filter for app packs - excludes saved apps and packs with zero App references
bool appPackEventFilter(Map<String, dynamic> event) {
/// Event filter for app stacks - excludes saved apps and stacks with zero App references
bool appStackEventFilter(Map<String, dynamic> event) {
final tags = event['tags'] as List<dynamic>?;
if (tags == null) return false;
+36 -45
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/install_button.dart';
import 'package:zapstore/widgets/screenshots_gallery.dart';
import 'package:zapstore/widgets/version_pill_widget.dart';
class AppDetailScreen extends HookConsumerWidget {
const AppDetailScreen({super.key, required this.appId, this.authorPubkey});
@@ -309,51 +309,42 @@ class _AppDetailContent extends HookConsumerWidget {
],
),
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: [
Text(
'Version ',
'Version:',
style: context.textTheme.bodyMedium,
),
ColorFiltered(
colorFilter: const ColorFilter.matrix(<double>[
0.2126,
0.7152,
0.0722,
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,
Gap(4),
Text(
latestMetadata.version,
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
Gap(4),
Text(
'(${formatDate(latestMetadata.createdAt)})',
style: context.textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: ReleaseNotes(release: latestRelease),
@@ -586,9 +577,9 @@ class _AppDetailContent extends HookConsumerWidget {
return;
}
// Query for existing pack
final existingPackState = await ref.storage.query(
RequestFilter<AppPack>(
// Query for existing stack
final existingStackState = await ref.storage.query(
RequestFilter<AppStack>(
authors: {signedInPubkey},
tags: {
'#d': {kAppBookmarksIdentifier},
@@ -596,14 +587,14 @@ class _AppDetailContent extends HookConsumerWidget {
).toRequest(),
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 = [];
if (existingPack != null) {
if (existingStack != null) {
try {
final decryptedContent = await signer.nip44Decrypt(
existingPack.content,
existingStack.content,
signedInPubkey,
);
existingAppIds = (jsonDecode(decryptedContent) as List)
@@ -631,19 +622,19 @@ class _AppDetailContent extends HookConsumerWidget {
}
}
// Create new partial pack with updated list
final partialPack = PartialAppPack.withEncryptedApps(
// Create new partial stack with updated list
final partialStack = PartialAppStack.withEncryptedApps(
name: 'Saved Apps',
identifier: kAppBookmarksIdentifier,
apps: existingAppIds,
);
// Sign (encrypts the content)
final signedPack = await partialPack.signWith(signer);
final signedStack = await partialStack.signWith(signer);
// Save to local storage and publish to relays
await ref.storage.save({signedPack});
ref.storage.publish({signedPack}, source: RemoteSource(relays: 'social'));
await ref.storage.save({signedStack});
ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social'));
if (context.mounted) {
context.showInfo(
+6 -10
View File
@@ -22,7 +22,7 @@ class AppStackScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// Query stack with apps relationship
final stackState = ref.watch(
query<AppPack>(
query<AppStack>(
authors: authorPubkey != null ? {authorPubkey!} : null,
tags: {
'#d': {stackId},
@@ -61,7 +61,7 @@ class AppStackScreen extends HookConsumerWidget {
/// Intermediate widget that loads apps with their release relationships
class _AppStackContentWithApps extends HookConsumerWidget {
final AppPack? stack;
final AppStack? stack;
const _AppStackContentWithApps({required this.stack});
@@ -132,7 +132,7 @@ class _ErrorScaffold extends StatelessWidget {
/// Internal widget that displays stack details
class _AppStackContent extends HookConsumerWidget {
final AppPack stack;
final AppStack stack;
final List<App> apps;
const _AppStackContent({required this.stack, required this.apps});
@@ -179,9 +179,7 @@ class _AppStackContent extends HookConsumerWidget {
children: [
Text(
'Apps in this stack',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
style: context.textTheme.titleLarge,
),
const SizedBox(width: 8),
CountBadge(
@@ -235,7 +233,7 @@ class _AppStackContent extends HookConsumerWidget {
class _StackHeader extends StatelessWidget {
const _StackHeader({required this.stack, this.author});
final AppPack stack;
final AppStack stack;
final Profile? author;
@override
@@ -246,9 +244,7 @@ class _StackHeader extends StatelessWidget {
// Stack name
Text(
stack.name ?? stack.identifier,
style: context.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: context.textTheme.headlineMedium,
),
const SizedBox(height: 8),
// Published by author (same style as app_detail_screen)
+4 -12
View File
@@ -375,9 +375,7 @@ class _DebugMessagesSection extends HookConsumerWidget {
Expanded(
child: Text(
'Debug Info',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.titleMedium,
),
),
],
@@ -975,9 +973,7 @@ class _DataManagementSection extends ConsumerWidget {
children: [
Text(
'Data Management',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
ListTile(
@@ -1267,9 +1263,7 @@ class _AboutSection extends ConsumerWidget {
children: [
Text(
'About',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
if (isLoading) ...[
@@ -1326,9 +1320,7 @@ class _AboutSection extends ConsumerWidget {
children: [
Text(
'About',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
ListTile(
+4 -12
View File
@@ -187,9 +187,7 @@ class _UpdatesListBody extends StatelessWidget {
const SizedBox(height: 12),
Text(
'No apps installed yet',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
@@ -313,9 +311,7 @@ class _InstallingHeader extends StatelessWidget {
const SizedBox(width: 8),
Text(
'Installing',
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: context.textTheme.titleMedium,
),
const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground),
@@ -356,9 +352,7 @@ class _UpdatesSectionHeader extends ConsumerWidget {
Flexible(
child: Text(
title,
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: context.textTheme.titleMedium,
),
),
const SizedBox(width: 8),
@@ -485,9 +479,7 @@ class _UpToDateHeader extends StatelessWidget {
const SizedBox(width: 8),
Text(
'Up to date',
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: context.textTheme.titleMedium,
),
const SizedBox(width: 8),
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:zapstore/utils/extensions.dart';
import '../constants/app_constants.dart';
import '../theme.dart';
import '../widgets/common/note_parser.dart';
import '../widgets/common/profile_avatar.dart';
import '../widgets/app_card.dart';
import '../widgets/app_pack_container.dart';
import '../widgets/zap_widgets.dart';
/// 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;
// Query user's app packs
final appPacksState = ref.watch(
query<AppPack>(
// Query user's app stacks
final appStacksState = ref.watch(
query<AppStack>(
authors: {pubkey},
limit: 20,
and: (pack) => {pack.apps},
@@ -66,11 +66,11 @@ class UserScreen extends HookConsumerWidget {
relays: 'AppCatalog',
stream: false,
),
schemaFilter: appPackEventFilter,
subscriptionPrefix: 'user-packs',
schemaFilter: appStackEventFilter,
subscriptionPrefix: 'user-stacks',
),
);
final packs = appPacksState.models
final stacks = appStacksState.models
..sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
return Scaffold(
@@ -99,9 +99,7 @@ class UserScreen extends HookConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
'Published Apps',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
style: context.textTheme.titleLarge,
),
),
),
@@ -113,33 +111,23 @@ class UserScreen extends HookConsumerWidget {
),
],
// App packs section - only show if packs exist
if (packs.isNotEmpty) ...[
// App stacks section - only show if stacks exist
if (stacks.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 32, 16, 16),
padding: const EdgeInsets.fromLTRB(16, 32, 16, 8),
child: Text(
'App Packs',
style: context.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
'App Stacks',
style: context.textTheme.headlineMedium,
),
),
),
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
@@ -172,9 +160,7 @@ class _UserHeader extends StatelessWidget {
Text(
profile?.nameOrNpub ??
'${Utils.encodeShareableFromString(pubkey, type: 'npub').substring(0, 12)}...',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
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 {
const _UserBio({required this.profile});
+6 -6
View File
@@ -13,10 +13,10 @@ final bookmarksProvider = FutureProvider<Set<String>>((ref) async {
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
final packState = ref.watch(
query<AppPack>(
final stackState = ref.watch(
query<AppStack>(
authors: {signedInPubkey},
tags: {
'#d': {kAppBookmarksIdentifier},
@@ -30,20 +30,20 @@ final bookmarksProvider = FutureProvider<Set<String>>((ref) async {
);
// Handle different storage states
final pack = switch (packState) {
final stack = switch (stackState) {
StorageLoading() => null,
StorageError() => null,
StorageData(:final models) => models.firstOrNull,
};
if (pack == null || pack.content.isEmpty) {
if (stack == null || stack.content.isEmpty) {
return {};
}
try {
// Content is always encrypted after signing - must explicitly decrypt
final decryptedContent = await signer.nip44Decrypt(
pack.content,
stack.content,
signedInPubkey,
);
+5 -5
View File
@@ -115,7 +115,7 @@ TextTheme _createProfessionalTextTheme() {
headlineMedium: const TextStyle(
fontFamily: kHeadlineFontFamily,
fontSize: 31,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w800,
letterSpacing: 0.15,
color: baseColor,
height: 1.3,
@@ -123,7 +123,7 @@ TextTheme _createProfessionalTextTheme() {
headlineSmall: const TextStyle(
fontFamily: kHeadlineFontFamily,
fontSize: 26,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w800,
letterSpacing: 0.15,
color: baseColor,
height: 1.35,
@@ -133,7 +133,7 @@ TextTheme _createProfessionalTextTheme() {
titleLarge: const TextStyle(
fontFamily: kHeadlineFontFamily,
fontSize: 24,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w700,
letterSpacing: 0.1,
color: baseColor,
height: 1.4,
@@ -141,7 +141,7 @@ TextTheme _createProfessionalTextTheme() {
titleMedium: const TextStyle(
fontFamily: kHeadlineFontFamily,
fontSize: 20,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
color: baseColor,
height: 1.45,
@@ -149,7 +149,7 @@ TextTheme _createProfessionalTextTheme() {
titleSmall: const TextStyle(
fontFamily: kHeadlineFontFamily,
fontSize: 18,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
color: baseColor,
height: 1.45,
-1
View File
@@ -243,7 +243,6 @@ class AppCard extends HookConsumerWidget {
final appName = app!.name ?? app!.identifier;
final titleStyle = context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0.1,
);
// 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 {
const SocialActionsRow({super.key, required this.app, required this.author});
@@ -92,7 +92,7 @@ class SocialActionsRow extends HookConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Zap + Save App + App Pack button row
// Zap + Save App + App Stack button row
SizedBox(
height: 36,
child: Row(
@@ -141,11 +141,11 @@ class SocialActionsRow extends HookConsumerWidget {
),
),
const SizedBox(width: 8),
// App Pack button
// App Stack button
Expanded(
flex: 16,
child: FilledButton(
onPressed: () => _showAddToPackDialog(context, app),
onPressed: () => _showAddToStackDialog(context, app),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor:
@@ -193,9 +193,9 @@ class SocialActionsRow extends HookConsumerWidget {
if (signer == null || signedInPubkey == null) return;
// Query for existing pack
final existingPackState = await ref.storage.query(
RequestFilter<AppPack>(
// Query for existing stack
final existingStackState = await ref.storage.query(
RequestFilter<AppStack>(
authors: {signedInPubkey},
tags: {
'#d': {kAppBookmarksIdentifier},
@@ -203,14 +203,14 @@ class SocialActionsRow extends HookConsumerWidget {
).toRequest(),
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 = [];
if (existingPack != null) {
if (existingStack != null) {
try {
final decryptedContent = await signer.nip44Decrypt(
existingPack.content,
existingStack.content,
signedInPubkey,
);
existingAppIds = (jsonDecode(decryptedContent) as List)
@@ -232,19 +232,19 @@ class SocialActionsRow extends HookConsumerWidget {
}
}
// Create new partial pack with updated list
final partialPack = PartialAppPack.withEncryptedApps(
// Create new partial stack with updated list
final partialStack = PartialAppStack.withEncryptedApps(
name: 'Saved Apps',
identifier: kAppBookmarksIdentifier,
apps: existingAppIds,
);
// Sign (encrypts the content)
final signedPack = await partialPack.signWith(signer);
final signedStack = await partialStack.signWith(signer);
// Save to local storage and publish to relays
await ref.storage.save({signedPack});
ref.storage.publish({signedPack}, source: RemoteSource(relays: 'social'));
await ref.storage.save({signedStack});
ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social'));
if (context.mounted) {
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(
context: context,
dialog: AddToPackDialog(app: app),
dialog: AddToStackDialog(app: app),
);
}
}
-2
View File
@@ -61,8 +61,6 @@ class AppHeader extends StatelessWidget {
AutoSizeText(
app.name ?? app.identifier,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0.1,
fontSize:
(Theme.of(
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:cached_network_image/cached_network_image.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../constants/app_constants.dart';
import '../utils/extensions.dart';
import '../utils/url_utils.dart';
import '../theme.dart';
@@ -28,7 +29,7 @@ class AppStackContainer extends HookConsumerWidget {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final appStacksState = ref.watch(
query<AppPack>(
query<AppStack>(
limit: 50,
and: (pack) => {pack.apps},
source: LocalAndRemoteSource(stream: true, relays: 'social'),
@@ -36,7 +37,7 @@ class AppStackContainer extends HookConsumerWidget {
relays: 'AppCatalog',
stream: false,
),
schemaFilter: appPackEventFilter,
schemaFilter: appStackEventFilter,
subscriptionPrefix: 'app-stack',
),
);
@@ -61,7 +62,7 @@ class AppStackContainer extends HookConsumerWidget {
final rawStacks = switch (appStacksState) {
StorageData(:final models) => models,
_ => <AppPack>[],
_ => <AppStack>[],
};
if (rawStacks.isEmpty) {
@@ -87,7 +88,9 @@ class AppStackContainer extends HookConsumerWidget {
for (int i = 0; i < stacks.length; i += 2)
Padding(
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(
width: 160,
@@ -108,8 +111,8 @@ class AppStackContainer extends HookConsumerWidget {
);
}
List<AppPack> _sortStacks(
List<AppPack> stacks, {
List<AppStack> _sortStacks(
List<AppStack> stacks, {
String? signedInPubkey,
Set<String>? followingPubkeys,
}) {
@@ -119,8 +122,8 @@ class AppStackContainer extends HookConsumerWidget {
followingPubkeys != null &&
followingPubkeys.isNotEmpty) {
// Signed in: followed profiles first, then by recency
final followed = <AppPack>[];
final others = <AppPack>[];
final followed = <AppStack>[];
final others = <AppStack>[];
for (final stack in stacks) {
if (followingPubkeys.contains(stack.pubkey)) {
@@ -278,7 +281,7 @@ class _SkeletonStackCard extends StatelessWidget {
class _StackCard extends HookConsumerWidget {
const _StackCard({required this.stack});
final AppPack stack;
final AppStack stack;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -339,14 +342,13 @@ class _StackCard extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stack name
Text(
// Stack name with conditional fade out
_FadingText(
stack.name ?? stack.identifier,
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),
// Author row: avatar + Name
@@ -397,9 +399,7 @@ class _AppIconsRow extends StatelessWidget {
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(10),
),
@@ -482,7 +482,9 @@ class _AppIconTile extends StatelessWidget {
),
)
: Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(
Icons.apps_outlined,
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/auth_widgets.dart';
@@ -104,9 +105,9 @@ class SaveAppDialog extends HookConsumerWidget {
return;
}
// Query for existing pack
final existingPackState = await ref.storage.query(
RequestFilter<AppPack>(
// Query for existing stack
final existingStackState = await ref.storage.query(
RequestFilter<AppStack>(
authors: {signedInPubkey},
tags: {
'#d': {kAppBookmarksIdentifier},
@@ -114,14 +115,14 @@ class SaveAppDialog extends HookConsumerWidget {
).toRequest(),
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 = [];
if (existingPack != null) {
if (existingStack != null) {
try {
final decryptedContent = await signer.nip44Decrypt(
existingPack.content,
existingStack.content,
signedInPubkey,
);
existingAppIds = (jsonDecode(decryptedContent) as List)
@@ -149,19 +150,21 @@ class SaveAppDialog extends HookConsumerWidget {
}
}
// Create new partial pack with updated list
final partialPack = PartialAppPack.withEncryptedApps(
// Create new partial stack with updated list
final partialStack = PartialAppStack.withEncryptedApps(
name: 'Saved Apps',
identifier: kAppBookmarksIdentifier,
apps: existingAppIds,
);
// Sign (encrypts the content)
final signedPack = await partialPack.signWith(signer);
final signedStack = await partialStack.signWith(signer);
// Save to local storage and publish to relays
await ref.storage.save({signedPack});
ref.storage.publish({signedPack}, source: RemoteSource(relays: 'social'));
await ref.storage.save({signedStack});
ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: 'social'));
if (context.mounted) {
Navigator.pop(context);
@@ -177,9 +180,9 @@ class SaveAppDialog extends HookConsumerWidget {
}
}
/// Dialog for managing app packs (public collections)
class AddToPackDialog extends HookConsumerWidget {
const AddToPackDialog({super.key, required this.app});
/// Dialog for managing app stacks (public collections)
class AddToStackDialog extends HookConsumerWidget {
const AddToStackDialog({super.key, required this.app});
final App app;
@@ -187,14 +190,14 @@ class AddToPackDialog extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
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 {
const _AddToPackDialogSignedOut({required this.app});
class _AddToStackDialogSignedOut extends StatelessWidget {
const _AddToStackDialogSignedOut({required this.app});
final App app;
@@ -203,7 +206,7 @@ class _AddToPackDialogSignedOut extends StatelessWidget {
return BaseDialog(
titleIcon: const Icon(Icons.apps),
title: Text(
'Add to App Packs',
'Add to App Stacks',
style: Theme.of(context).textTheme.headlineSmall,
),
content: BaseDialogContent(
@@ -231,7 +234,7 @@ class _AddToPackDialogSignedOut extends StatelessWidget {
const SizedBox(width: 10),
Expanded(
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(
color: Theme.of(
context,
@@ -244,7 +247,7 @@ class _AddToPackDialogSignedOut extends StatelessWidget {
),
const SizedBox(height: 16),
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 {
const _AddToPackDialogSignedIn({
class _AddToStackDialogSignedIn extends HookConsumerWidget {
const _AddToStackDialogSignedIn({
required this.app,
required this.signedInPubkey,
});
@@ -274,37 +277,38 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final newPackNameController = useTextEditingController();
final newStackNameController = useTextEditingController();
final publicPacksState = ref.watch(
query<AppPack>(
final publicStacksState = ref.watch(
query<AppStack>(
authors: {signedInPubkey},
and: (pack) => {pack.apps},
and: (stack) => {stack.apps},
source: const LocalAndRemoteSource(relays: 'social', stream: false),
andSource: const LocalSource(),
subscriptionPrefix: 'user-packs-dialog',
schemaFilter: appStackEventFilter,
subscriptionPrefix: 'user-stacks-dialog',
),
);
final publicPacks = publicPacksState.models
.where((pack) => pack.identifier != kAppBookmarksIdentifier)
final publicStacks = publicStacksState.models
.where((stack) => stack.identifier != kAppBookmarksIdentifier)
.toList();
// Check which packs contain this app
// Check which stacks contain this app
final selectedCollections = useState<Set<String>>({});
// Track new packs with their names (identifier -> name)
final newPackNames = useState<Map<String, String>>({});
// Track new stacks with their names (identifier -> name)
final newStackNames = useState<Map<String, String>>({});
useEffect(() {
final selected = <String>{};
for (final pack in publicPacks) {
if (pack.apps.toList().any((appModel) => appModel.id == app.id)) {
selected.add(pack.identifier);
for (final stack in publicStacks) {
if (stack.apps.toList().any((appModel) => appModel.id == app.id)) {
selected.add(stack.identifier);
}
}
selectedCollections.value = selected;
return null;
}, [app.id, publicPacks.length]);
}, [app.id, publicStacks.length]);
// Helper to slugify name to identifier
String slugify(String text) {
@@ -319,7 +323,7 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
return BaseDialog(
titleIcon: const Icon(Icons.apps),
title: Text(
'Add to App Packs',
'Add to App Stacks',
style: Theme.of(context).textTheme.headlineSmall,
),
content: BaseDialogContent(
@@ -347,7 +351,7 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
const SizedBox(width: 10),
Expanded(
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(
color: Theme.of(
context,
@@ -360,18 +364,18 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
),
const SizedBox(height: 16),
// Show existing packs and newly created packs as selectable chips
if (publicPacks.isNotEmpty || newPackNames.value.isNotEmpty) ...[
// Show existing stacks and newly created stacks as selectable chips
if (publicStacks.isNotEmpty || newStackNames.value.isNotEmpty) ...[
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// Existing packs
...publicPacks.map((pack) {
// Existing stacks
...publicStacks.map((stack) {
final isSelected = selectedCollections.value.contains(
pack.identifier,
stack.identifier,
);
final displayName = pack.name ?? pack.identifier;
final displayName = stack.name ?? stack.identifier;
return FilterChip(
label: Text(displayName),
selected: isSelected,
@@ -379,12 +383,12 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
if (selected) {
selectedCollections.value = {
...selectedCollections.value,
pack.identifier,
stack.identifier,
};
} else {
selectedCollections.value = {
...selectedCollections.value,
}..remove(pack.identifier);
}..remove(stack.identifier);
}
},
backgroundColor: isSelected
@@ -408,11 +412,11 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
side: BorderSide.none,
);
}),
// Newly created packs not yet in publicPacks
...newPackNames.value.entries
// Newly created stacks not yet in publicStacks
...newStackNames.value.entries
.where(
(entry) => !publicPacks.any(
(pack) => pack.identifier == entry.key,
(entry) => !publicStacks.any(
(stack) => stack.identifier == entry.key,
),
)
.map((entry) {
@@ -434,11 +438,11 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
selectedCollections.value = {
...selectedCollections.value,
}..remove(identifier);
// Also remove from newPackNames if deselected
// Also remove from newStackNames if deselected
final updatedNames = Map<String, String>.from(
newPackNames.value,
newStackNames.value,
)..remove(identifier);
newPackNames.value = updatedNames;
newStackNames.value = updatedNames;
}
},
backgroundColor: isSelected
@@ -469,12 +473,12 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
const SizedBox(height: 16),
],
// Input for new pack
// Input for new stack
TextField(
controller: newPackNameController,
controller: newStackNameController,
maxLength: 20,
decoration: const InputDecoration(
labelText: 'Add to new app pack',
labelText: 'Add to new app stack',
hintText: 'e.g., Favorite Apps',
border: OutlineInputBorder(),
),
@@ -488,8 +492,11 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
...selectedCollections.value,
identifier,
};
newPackNames.value = {...newPackNames.value, identifier: name};
newPackNameController.clear();
newStackNames.value = {
...newStackNames.value,
identifier: name,
};
newStackNameController.clear();
}
},
),
@@ -502,20 +509,20 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
),
AsyncButtonBuilder(
onPressed: () async {
// Check if there's a pending new pack name in the text field
final pendingPackName = newPackNameController.text.trim();
if (pendingPackName.isNotEmpty) {
final identifier = slugify(pendingPackName);
// Check if there's a pending new stack name in the text field
final pendingStackName = newStackNameController.text.trim();
if (pendingStackName.isNotEmpty) {
final identifier = slugify(pendingStackName);
if (identifier.isNotEmpty &&
!selectedCollections.value.contains(identifier)) {
// Add the pending pack to the collections
// Add the pending stack to the collections
selectedCollections.value = {
...selectedCollections.value,
identifier,
};
newPackNames.value = {
...newPackNames.value,
identifier: pendingPackName,
newStackNames.value = {
...newStackNames.value,
identifier: pendingStackName,
};
}
}
@@ -525,9 +532,9 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
context,
ref,
app,
publicPacks,
publicStacks,
selectedCollections.value,
newPackNames.value,
newStackNames.value,
);
},
builder: (context, child, callback, buttonState) {
@@ -557,9 +564,9 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
BuildContext context,
WidgetRef ref,
App app,
List<AppPack> existingPacks,
List<AppStack> existingStacks,
Set<String> selectedCollectionIds,
Map<String, String> newPackNames,
Map<String, String> newStackNames,
) async {
try {
final signer = ref.read(Signer.activeSignerProvider);
@@ -567,7 +574,7 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
if (context.mounted) {
context.showError(
'Sign in required',
description: 'You need to sign in to manage app packs.',
description: 'You need to sign in to manage app stacks.',
);
}
return;
@@ -575,11 +582,11 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
// Get current state of which collections contain this app
final currentCollectionsWithApp = <String>{};
for (final pack in existingPacks) {
for (final stack in existingStacks) {
// Get raw app IDs from event tags
final appIds = pack.event.getTagSetValues('a');
final appIds = stack.event.getTagSetValues('a');
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
for (final collectionId in selectedCollectionIds) {
final existingPack = existingPacks
.where((pack) => pack.identifier == collectionId)
final existingStack = existingStacks
.where((stack) => stack.identifier == collectionId)
.firstOrNull;
// Use the provided name from newPackNames, or existing pack name, or identifier
final packName =
newPackNames[collectionId] ?? existingPack?.name ?? collectionId;
// Use the provided name from newStackNames, or existing stack name, or identifier
final stackName =
newStackNames[collectionId] ?? existingStack?.name ?? collectionId;
// Get existing app IDs as a list to preserve order
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);
// Only update if the app is not already in the pack
// Only update if the app is not already in the stack
if (!appAlreadyExists) {
final partialPack = PartialAppPack(
name: packName,
final partialStack = PartialAppStack(
name: stackName,
identifier: collectionId,
);
// Add existing apps in order
for (final appId in existingAppIds) {
partialPack.addApp(appId);
partialStack.addApp(appId);
}
// Add this app at the end
partialPack.addApp(app.id);
partialStack.addApp(app.id);
final signedPack = await partialPack.signWith(signer);
await ref.storage.save({signedPack});
final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedStack});
await ref.storage.publish({
signedPack,
signedStack,
}, source: RemoteSource(relays: 'social'));
}
}
// Remove from deselected collections
for (final collectionId in collectionsToRemoveFrom) {
final existingPack = existingPacks
.where((pack) => pack.identifier == collectionId)
final existingStack = existingStacks
.where((stack) => stack.identifier == collectionId)
.firstOrNull;
if (existingPack != null) {
final partialPack = PartialAppPack(
name: existingPack.name ?? collectionId,
if (existingStack != null) {
final partialStack = PartialAppStack(
name: existingStack.name ?? collectionId,
identifier: collectionId,
);
// 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
final existingAppIds = existingPack.event
final existingAppIds = existingStack.event
.getTagSetValues('a')
.toList();
for (final appId in existingAppIds) {
if (appId != app.id) {
partialPack.addApp(appId);
partialStack.addApp(appId);
}
}
final signedPack = await partialPack.signWith(signer);
await ref.storage.save({signedPack});
final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedStack});
await ref.storage.publish({
signedPack,
signedStack,
}, source: RemoteSource(relays: 'social'));
}
}
if (context.mounted) {
if (selectedCollectionIds.isEmpty) {
context.showInfo('Removed from all app packs');
context.showInfo('Removed from all app stacks');
} else {
context.showInfo('App packs updated');
context.showInfo('App stacks updated');
}
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 {
const StackCommentsSection({super.key, required this.stack});
final AppPack stack;
final AppStack stack;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -111,12 +111,7 @@ class _CommentsSectionLayout extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Comments',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
Text('Comments', style: Theme.of(context).textTheme.titleLarge),
if (comments.isNotEmpty)
_CommentCountBadge(count: comments.length),
],
@@ -251,7 +246,6 @@ class _CommentCard extends HookConsumerWidget {
Text(
author?.nameOrNpub ?? '',
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
fontSize:
(context
.textTheme
@@ -338,7 +332,7 @@ class _AddCommentButton extends ConsumerWidget {
class _AddStackCommentButton extends ConsumerWidget {
const _AddStackCommentButton({required this.stack});
final AppPack stack;
final AppStack stack;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -401,9 +395,7 @@ class _CommentComposer extends HookConsumerWidget {
children: [
Text(
'Comment on $versionToComment',
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: context.textTheme.titleMedium,
),
IconButton(
onPressed: () => Navigator.pop(context),
@@ -532,7 +524,7 @@ class _CommentComposer extends HookConsumerWidget {
class _StackCommentComposer extends HookConsumerWidget {
const _StackCommentComposer({required this.stack});
final AppPack stack;
final AppStack stack;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -557,9 +549,7 @@ class _StackCommentComposer extends HookConsumerWidget {
children: [
Text(
'Comment on $stackName',
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: context.textTheme.titleMedium,
),
IconButton(
onPressed: () => Navigator.pop(context),
+1 -3
View File
@@ -31,9 +31,7 @@ class NWCConnectionCard extends HookConsumerWidget {
const SizedBox(width: 8),
Text(
'Lightning Wallet',
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: context.textTheme.titleMedium,
),
],
),
+1 -3
View File
@@ -38,9 +38,7 @@ class ProfileHeader extends StatelessWidget {
// Display name
Text(
profile!.name ?? profile!.nameOrNpub,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
-1
View File
@@ -117,7 +117,6 @@ class ZappersHorizontalList extends StatelessWidget {
'⚡️${formatSatsCompact(totalSats)}',
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 6),