mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Use app stacks, fix headlines
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,50 +309,41 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'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,
|
||||
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:',
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'(${formatDate(latestMetadata.createdAt)})',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
Gap(4),
|
||||
Text(
|
||||
latestMetadata.version,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Gap(4),
|
||||
Text(
|
||||
'(${formatDate(latestMetadata.createdAt)})',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
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())),
|
||||
],
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final stack = stacks[index];
|
||||
return _StackLinkCard(stack: stack, pubkey: pubkey);
|
||||
}, childCount: stacks.length),
|
||||
),
|
||||
],
|
||||
|
||||
// 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});
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user