Centralize navigation logic

This commit is contained in:
franzap
2026-04-08 15:59:01 -03:00
parent ffdff068c8
commit 2e10edf850
14 changed files with 229 additions and 194 deletions
+2 -6
View File
@@ -1,11 +1,11 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nostr_route.dart';
import 'package:zapstore/widgets/app_card.dart'; import 'package:zapstore/widgets/app_card.dart';
import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/author_container.dart';
import 'package:zapstore/widgets/comments_section.dart'; import 'package:zapstore/widgets/comments_section.dart';
@@ -280,11 +280,7 @@ class _StackHeader extends StatelessWidget {
oneLine: true, oneLine: true,
size: 14, size: 14,
isLoading: isAuthorLoading, isLoading: isAuthorLoading,
onTap: () { onTap: () => pushUser(context, stack.pubkey),
final segments = GoRouterState.of(context).uri.pathSegments;
final first = segments.isNotEmpty ? segments.first : 'search';
context.push('/$first/user/${stack.pubkey}');
},
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// Last updated timestamp // Last updated timestamp
+2 -5
View File
@@ -17,6 +17,7 @@ import 'package:zapstore/main.dart';
import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nostr_route.dart';
import 'package:zapstore/widgets/common/profile_identity_row.dart'; import 'package:zapstore/widgets/common/profile_identity_row.dart';
import 'package:zapstore/widgets/app_card.dart'; import 'package:zapstore/widgets/app_card.dart';
import 'package:zapstore/theme.dart'; import 'package:zapstore/theme.dart';
@@ -285,11 +286,7 @@ class _StackMigrationWarning extends ConsumerWidget {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
child: InkWell( child: InkWell(
onTap: () { onTap: () => pushStacks(context),
final segments = GoRouterState.of(context).uri.pathSegments;
final first = segments.isNotEmpty ? segments.first : 'search';
context.push('/$first/stacks');
},
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
+2 -15
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/utils/nostr_route.dart';
import '../widgets/app_stack_container.dart'; import '../widgets/app_stack_container.dart';
import '../widgets/latest_releases_container.dart'; import '../widgets/latest_releases_container.dart';
@@ -30,24 +30,11 @@ class SearchScreen extends HookConsumerWidget {
final performSearch = useCallback((String query) { final performSearch = useCallback((String query) {
final trimmed = query.trim(); final trimmed = query.trim();
final cleaned = trimmed if (navigateToContent(context, trimmed, fallbackLaunch: false)) {
.replaceFirst('nostr:', '')
.replaceFirst(RegExp(r'https?://zapstore\.dev/(?:apps|stacks)/'), '');
try {
final decoded = Utils.decodeShareableIdentifier(cleaned);
final path = switch (decoded) {
AddressData(:final kind) =>
'/search/${kind == 30267 ? 'stack' : 'app'}/$cleaned',
ProfileData(:final pubkey) => '/search/user/$pubkey',
_ => null,
};
if (path != null) {
searchController.clear(); searchController.clear();
searchQuery.value = ''; searchQuery.value = '';
context.push(path);
return; return;
} }
} catch (_) {}
if (trimmed.length < 3) { if (trimmed.length < 3) {
searchFocusNode.requestFocus(); searchFocusNode.requestFocus();
return; return;
+4 -18
View File
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nostr_route.dart';
import '../theme.dart'; import '../theme.dart';
import '../widgets/common/note_parser.dart'; import '../widgets/common/note_parser.dart';
import '../widgets/common/profile_identity_row.dart'; import '../widgets/common/profile_identity_row.dart';
@@ -273,20 +273,12 @@ class _StackLinkCard extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: InkWell( child: InkWell(
onTap: () { onTap: () => pushStack(
final segments = GoRouterState.of(context).uri.pathSegments; context,
final first = segments.isNotEmpty ? segments.first : 'search'; stack.identifier,
// Build naddr for the stack
final naddr = Utils.encodeShareableIdentifier(
AddressInput(
identifier: stack.identifier,
author: pubkey, author: pubkey,
kind: stack.event.kind, kind: stack.event.kind,
relays: const [],
), ),
);
context.push('/$first/stack/$naddr');
},
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -370,12 +362,6 @@ class _UserBio extends HookWidget {
Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary, Theme.of(context).colorScheme.secondary,
], ],
onProfileTap: (pubkey) {
final segments = GoRouterState.of(context).uri.pathSegments;
final first = segments.isNotEmpty ? segments.first : 'search';
context.push('/$first/user/$pubkey');
},
onHashtagTap: (hashtag) => context.push('/search?q=$hashtag'),
), ),
); );
+9 -4
View File
@@ -4,19 +4,24 @@ import 'package:flutter/foundation.dart';
/// the URI is not a recognized deep link. /// the URI is not a recognized deep link.
/// ///
/// Handles: /// Handles:
/// - `https://zapstore.dev/apps/{id}` (full URI from app_links) /// - `https://zapstore.dev/apps/{id}` and `.../stacks/{id}`
/// - `/apps/{id}` (path-only, as seen by GoRouter's onException) /// - `/apps/{id}` and `/stacks/{id}` (path-only, as seen by GoRouter's onException)
/// - `market://details?id=com.example.app` /// - `market://details?id=com.example.app`
/// - `market://search?q=search+query` /// - `market://search?q=search+query`
String? resolveDeepLinkPath(Uri uri) { String? resolveDeepLinkPath(Uri uri) {
// https://zapstore.dev/apps/<id> OR bare /apps/<id> from GoRouter // https://zapstore.dev/apps/<id> OR bare /apps/<id> from GoRouter
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'apps') { // https://zapstore.dev/stacks/<id> OR bare /stacks/<id>
if (uri.pathSegments.length == 2) {
final section = uri.pathSegments[0];
if (section == 'apps' || section == 'stacks') {
final isFullUri = uri.scheme == 'https' && uri.host == 'zapstore.dev'; final isFullUri = uri.scheme == 'https' && uri.host == 'zapstore.dev';
final isBarePath = uri.host.isEmpty; final isBarePath = uri.host.isEmpty;
if (isFullUri || isBarePath) { if (isFullUri || isBarePath) {
final id = uri.pathSegments[1]; final id = uri.pathSegments[1];
if (id.isNotEmpty) { if (id.isNotEmpty) {
return '/search/app/$id'; final route = section == 'apps' ? 'app' : 'stack';
return '/search/$route/$id';
}
} }
} }
} }
+148
View File
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:models/models.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:zapstore/constants/app_constants.dart';
/// Regex for zapstore.dev app/stack deep links.
final _zapstoreUrlPattern = RegExp(
r'https?://zapstore\.dev/(apps|stacks)/(.+)',
caseSensitive: false,
);
/// Resolves the current navigation branch from the GoRouter state so that
/// pushed routes stay within the active tab (search / updates / profile).
String _currentBranch(BuildContext context) {
final segments = GoRouterState.of(context).uri.pathSegments;
return segments.isNotEmpty ? segments.first : 'search';
}
/// Push to a user profile screen within the current branch.
void pushUser(BuildContext context, String pubkey) {
context.push('/${_currentBranch(context)}/user/$pubkey');
}
/// Push to an app detail screen within the current branch.
///
/// When [author] is provided an naddr is built so the detail screen can
/// uniquely identify the app (identifier + author).
void pushApp(BuildContext context, String identifier, {String? author, int kind = 32267}) {
final id = author != null
? Utils.encodeShareableIdentifier(
AddressInput(
identifier: identifier,
author: author,
kind: kind,
relays: const [kDefaultRelay],
),
)
: identifier;
context.push('/${_currentBranch(context)}/app/$id');
}
/// Push to a stack detail screen within the current branch.
void pushStack(BuildContext context, String identifier, {String? author, int kind = 30267}) {
final id = author != null
? Utils.encodeShareableIdentifier(
AddressInput(
identifier: identifier,
author: author,
kind: kind,
relays: const [],
),
)
: identifier;
context.push('/${_currentBranch(context)}/stack/$id');
}
/// Push to the all-stacks screen within the current branch.
void pushStacks(BuildContext context) {
context.push('/${_currentBranch(context)}/stacks');
}
/// Attempt to navigate in-app for a URL or Nostr identifier.
///
/// Handles:
/// - `https://zapstore.dev/apps/<id>` and `.../stacks/<id>`
/// - `nostr:naddr1...`, `nostr:npub1...`, bare NIP-19 tokens
///
/// Returns `true` if navigation was handled in-app, `false` otherwise.
/// When [fallbackLaunch] is true (the default), unrecognised URLs are opened
/// in an external browser.
bool navigateToContent(
BuildContext context,
String input, {
bool fallbackLaunch = true,
}) {
final cleaned = input.trim();
// 1. zapstore.dev deep links
final zapstoreMatch = _zapstoreUrlPattern.firstMatch(cleaned);
if (zapstoreMatch != null) {
final type = zapstoreMatch.group(1)!; // "apps" or "stacks"
final id = zapstoreMatch.group(2)!;
if (type == 'apps') {
_navigateNip19OrIdentifier(context, id, fallbackKind: 'app');
} else {
_navigateNip19OrIdentifier(context, id, fallbackKind: 'stack');
}
return true;
}
// 2. nostr: prefix or bare NIP-19 token
final nip19 = cleaned.replaceFirst('nostr:', '');
if (_tryNavigateNip19(context, nip19)) return true;
// 3. Not a recognised in-app link
if (fallbackLaunch) {
_launchExternal(cleaned);
}
return false;
}
/// Try to decode a NIP-19 token and navigate. Returns true on success.
bool _tryNavigateNip19(BuildContext context, String token) {
try {
final decoded = Utils.decodeShareableIdentifier(token);
switch (decoded) {
case AddressData(:final kind):
if (kind == 30267) {
pushStack(context, token);
} else {
pushApp(context, token);
}
return true;
case ProfileData(:final pubkey):
pushUser(context, pubkey);
return true;
case EventData():
return false;
}
} catch (_) {}
return false;
}
/// The id might be an naddr or a plain identifier (e.g. `com.example.app`).
void _navigateNip19OrIdentifier(BuildContext context, String id, {required String fallbackKind}) {
if (_tryNavigateNip19(context, id)) return;
final branch = _currentBranch(context);
context.push('/$branch/$fallbackKind/$id');
}
void _launchExternal(String url) async {
try {
var cleanUrl = url;
if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) {
cleanUrl = 'https://$cleanUrl';
}
final uri = Uri.parse(cleanUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
try {
await launchUrl(uri, mode: LaunchMode.platformDefault);
} catch (_) {}
}
} catch (_) {}
}
+2 -17
View File
@@ -3,10 +3,10 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import 'package:markdown/markdown.dart' as md; import 'package:markdown/markdown.dart' as md;
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nostr_route.dart';
import 'package:zapstore/utils/url_utils.dart'; import 'package:zapstore/utils/url_utils.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/widgets/zap_widgets.dart'; import 'package:zapstore/widgets/zap_widgets.dart';
@@ -55,22 +55,7 @@ class AppCard extends HookConsumerWidget {
: 'No description available'; : 'No description available';
Widget buildCard(Profile? publisher, bool isPublisherLoading) => GestureDetector( Widget buildCard(Profile? publisher, bool isPublisherLoading) => GestureDetector(
onTap: () { onTap: () => pushApp(context, app!.identifier, author: app!.pubkey, kind: app!.event.kind),
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 [kDefaultRelay],
),
);
context.push('/$first/app/$naddr');
},
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
-6
View File
@@ -7,7 +7,6 @@ import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/notification_service.dart';
@@ -49,11 +48,6 @@ class ReleaseNotes extends StatelessWidget {
} }
return ExpandableMarkdown( return ExpandableMarkdown(
data: release.releaseNotes!, data: release.releaseNotes!,
onTapLink: (text, url, title) {
if (url != null) {
launchUrl(Uri.parse(url));
}
},
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
blockquoteDecoration: BoxDecoration( blockquoteDecoration: BoxDecoration(
color: const Color(0xFF1E3A5F), // Dark blue color: const Color(0xFF1E3A5F), // Dark blue
+5 -16
View File
@@ -1,9 +1,9 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/utils/nostr_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import '../utils/extensions.dart'; import '../utils/extensions.dart';
@@ -268,11 +268,7 @@ class _SeeMoreCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () => pushStacks(context),
final segments = GoRouterState.of(context).uri.pathSegments;
final first = segments.isNotEmpty ? segments.first : 'search';
context.push('/$first/stacks');
},
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
@@ -378,19 +374,12 @@ class StackCard extends StatelessWidget {
); );
return GestureDetector( return GestureDetector(
onTap: () { onTap: () => pushStack(
final segments = GoRouterState.of(context).uri.pathSegments; context,
final first = segments.isNotEmpty ? segments.first : 'search'; stack.identifier,
final naddr = Utils.encodeShareableIdentifier(
AddressInput(
identifier: stack.identifier,
author: stack.pubkey, author: stack.pubkey,
kind: stack.event.kind, kind: stack.event.kind,
relays: const [],
), ),
);
context.push('/$first/stack/$naddr');
},
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
-8
View File
@@ -1,7 +1,6 @@
import 'package:async_button_builder/async_button_builder.dart'; import 'package:async_button_builder/async_button_builder.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
@@ -34,7 +33,6 @@ class CommentsSection extends HookConsumerWidget {
}, },
source: LocalAndRemoteSource(stream: true, relays: 'social'), source: LocalAndRemoteSource(stream: true, relays: 'social'),
subscriptionPrefix: 'app-comments', subscriptionPrefix: 'app-comments',
and: (comment) => {comment.replies.query()},
), ),
); );
@@ -76,7 +74,6 @@ class StackCommentsSection extends HookConsumerWidget {
}, },
source: LocalAndRemoteSource(stream: true, relays: 'social'), source: LocalAndRemoteSource(stream: true, relays: 'social'),
subscriptionPrefix: 'app-stack-comments', subscriptionPrefix: 'app-stack-comments',
and: (comment) => {comment.replies.query()},
), ),
); );
@@ -401,11 +398,6 @@ class _ThreadedCommentCard extends HookConsumerWidget {
Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary, Theme.of(context).colorScheme.secondary,
], ],
onProfileTap: (pubkey) {
final segments = GoRouterState.of(context).uri.pathSegments;
final branch = segments.isNotEmpty ? segments.first : 'search';
context.push('/$branch/user/$pubkey');
},
), ),
), ),
if (isSignedIn) ...[ if (isSignedIn) ...[
+10 -57
View File
@@ -1,29 +1,9 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:zapstore/utils/nostr_route.dart';
Future<void> _launchUrlSafely(String url) async {
try {
String cleanUrl = url.trim();
if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) {
cleanUrl = 'https://$cleanUrl';
}
final uri = Uri.parse(cleanUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
try {
await launchUrl(uri, mode: LaunchMode.platformDefault);
} catch (_) {}
}
} catch (_) {}
}
class NoteParser { class NoteParser {
static final RegExp nip19Regex = RegExp( static final RegExp nip19Regex = RegExp(
@@ -48,7 +28,6 @@ class NoteParser {
Widget? Function(String httpUrl)? onHttpUrl, Widget? Function(String httpUrl)? onHttpUrl,
Widget? Function(String hashtag)? onHashtag, Widget? Function(String hashtag)? onHashtag,
void Function(String hashtag)? onHashtagTap, void Function(String hashtag)? onHashtagTap,
void Function(String pubkey)? onProfileTap,
TextStyle? textStyle, TextStyle? textStyle,
TextStyle? linkStyle, TextStyle? linkStyle,
}) { }) {
@@ -152,7 +131,7 @@ class NoteParser {
text: match.text, text: match.text,
style: style, style: style,
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () => _launchUrlSafely(match.text), ..onTap = () => navigateToContent(context, match.text),
)); ));
} else { } else {
final style = final style =
@@ -202,15 +181,11 @@ enum _EntityType { nip19, http, hashtag }
class NostrEntityWidget extends StatelessWidget { class NostrEntityWidget extends StatelessWidget {
final String entity; final String entity;
final List<Color> colorPair; final List<Color> colorPair;
final void Function(String pubkey)? onProfileTap;
final void Function(String hashtag)? onHashtagTap;
const NostrEntityWidget({ const NostrEntityWidget({
super.key, super.key,
required this.entity, required this.entity,
required this.colorPair, required this.colorPair,
this.onProfileTap,
this.onHashtagTap,
}); });
@override @override
@@ -222,7 +197,6 @@ class NostrEntityWidget extends StatelessWidget {
ProfileData() => ProfileEntityWidget( ProfileData() => ProfileEntityWidget(
profileData: decoded, profileData: decoded,
colorPair: colorPair, colorPair: colorPair,
onProfileTap: onProfileTap,
), ),
EventData() => EventEntityWidget( EventData() => EventEntityWidget(
eventData: decoded, eventData: decoded,
@@ -242,13 +216,11 @@ class NostrEntityWidget extends StatelessWidget {
class ProfileEntityWidget extends ConsumerWidget { class ProfileEntityWidget extends ConsumerWidget {
final ProfileData profileData; final ProfileData profileData;
final List<Color> colorPair; final List<Color> colorPair;
final void Function(String pubkey)? onProfileTap;
const ProfileEntityWidget({ const ProfileEntityWidget({
super.key, super.key,
required this.profileData, required this.profileData,
required this.colorPair, required this.colorPair,
this.onProfileTap,
}); });
@override @override
@@ -264,15 +236,7 @@ class ProfileEntityWidget extends ConsumerWidget {
), ),
); );
void handleTap() { void handleTap() => pushUser(context, profileData.pubkey);
if (onProfileTap != null) {
onProfileTap!(profileData.pubkey);
} else {
final segments = GoRouterState.of(context).uri.pathSegments;
final branch = segments.isNotEmpty ? segments.first : 'search';
context.push('/$branch/user/${profileData.pubkey}');
}
}
return switch (profileState) { return switch (profileState) {
StorageLoading() => GestureDetector( StorageLoading() => GestureDetector(
@@ -294,20 +258,8 @@ class ProfileEntityWidget extends ConsumerWidget {
} }
Widget _buildProfileWidget(BuildContext context, Profile profile) { Widget _buildProfileWidget(BuildContext context, Profile profile) {
final displayName = profile.nameOrNpub;
void handleTap() {
if (onProfileTap != null) {
onProfileTap!(profileData.pubkey);
} else {
final segments = GoRouterState.of(context).uri.pathSegments;
final branch = segments.isNotEmpty ? segments.first : 'search';
context.push('/$branch/user/${profileData.pubkey}');
}
}
return GestureDetector( return GestureDetector(
onTap: handleTap, onTap: () => pushUser(context, profileData.pubkey),
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorPair[0].withValues(alpha: 0.1), color: colorPair[0].withValues(alpha: 0.1),
@@ -316,7 +268,7 @@ class ProfileEntityWidget extends ConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0), padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Text( child: Text(
displayName, profile.nameOrNpub,
style: context.textTheme.bodyMedium!.copyWith( style: context.textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: colorPair[0], color: colorPair[0],
@@ -411,10 +363,11 @@ class AddressEntityWidget extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
final segment = addressData.kind == 30267 ? 'stack' : 'app'; if (addressData.kind == 30267) {
final segments = GoRouterState.of(context).uri.pathSegments; pushStack(context, addressData.identifier);
final branch = segments.isNotEmpty ? segments.first : 'search'; } else {
context.push('/$branch/$segment/${addressData.identifier}'); pushApp(context, addressData.identifier);
}
}, },
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
+7 -5
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nostr_route.dart';
class ExpandableMarkdown extends HookWidget { class ExpandableMarkdown extends HookWidget {
const ExpandableMarkdown({ const ExpandableMarkdown({
@@ -21,8 +22,6 @@ class ExpandableMarkdown extends HookWidget {
const maxHeight = 170.0; const maxHeight = 170.0;
bool isLikelyLong(String text) { bool isLikelyLong(String text) {
// Simple heuristic to avoid expensive measurement
// - Many words OR many newlines => likely long content
final trimmed = text.trim(); final trimmed = text.trim();
if (trimmed.isEmpty) return false; if (trimmed.isEmpty) return false;
@@ -30,16 +29,19 @@ class ExpandableMarkdown extends HookWidget {
final newlineCount = '\n'.allMatches(trimmed).length; final newlineCount = '\n'.allMatches(trimmed).length;
final charCount = trimmed.length; final charCount = trimmed.length;
// Thresholds tuned for typical article blurbs
// Show collapse if any of these exceed limits
return wordCount > 90 || newlineCount > 6 || charCount > 600; return wordCount > 90 || newlineCount > 6 || charCount > 600;
} }
final shouldCollapse = !expanded.value && isLikelyLong(data); final shouldCollapse = !expanded.value && isLikelyLong(data);
final effectiveTapLink = onTapLink ??
(String text, String? href, String? title) {
if (href != null) navigateToContent(context, href);
};
final markdown = MarkdownBody( final markdown = MarkdownBody(
data: data, data: data,
onTapLink: onTapLink, onTapLink: effectiveTapLink,
styleSheet: styleSheet, styleSheet: styleSheet,
); );
+2 -4
View File
@@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -10,6 +9,7 @@ import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nostr_route.dart';
/// Floating three-dot overflow menu reusable across detail screens. /// Floating three-dot overflow menu reusable across detail screens.
/// ///
@@ -154,9 +154,7 @@ class FloatingOverflowMenu extends HookConsumerWidget {
} }
void _viewPublisher(BuildContext context) { void _viewPublisher(BuildContext context) {
final segments = GoRouterState.of(context).uri.pathSegments; pushUser(context, publisherPubkey);
final first = segments.isNotEmpty ? segments.first : 'search';
context.push('/$first/user/$publisherPubkey');
} }
Future<void> _openInBrowser(BuildContext context) async { Future<void> _openInBrowser(BuildContext context) async {
+18 -15
View File
@@ -1,11 +1,11 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:markdown/markdown.dart' as md; import 'package:markdown/markdown.dart' as md;
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nostr_route.dart';
import 'package:zapstore/utils/url_utils.dart'; import 'package:zapstore/utils/url_utils.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import '../theme.dart'; import '../theme.dart';
@@ -31,19 +31,12 @@ class SearchAppCard extends ConsumerWidget {
const iconSize = 48.0; const iconSize = 48.0;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () => pushApp(
final segments = GoRouterState.of(context).uri.pathSegments; context,
final first = segments.isNotEmpty ? segments.first : 'search'; app!.identifier,
final naddr = Utils.encodeShareableIdentifier(
AddressInput(
identifier: app!.identifier,
author: app!.pubkey, author: app!.pubkey,
kind: app!.event.kind, kind: app!.event.kind,
relays: const [],
), ),
);
context.push('/$first/app/$naddr');
},
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -164,8 +157,17 @@ class SearchAppCard extends ConsumerWidget {
} }
if (node is md.Element) { if (node is md.Element) {
final isBlock = const { final isBlock = const {
'p', 'li', 'ul', 'ol', 'blockquote', 'p',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li',
'ul',
'ol',
'blockquote',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
}.contains(node.tag); }.contains(node.tag);
if (node.tag == 'br') buffer.write('\n'); if (node.tag == 'br') buffer.write('\n');
for (final child in node.children ?? []) { for (final child in node.children ?? []) {
@@ -193,8 +195,9 @@ class SearchAppCard extends ConsumerWidget {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
border: Border.all( border: Border.all(
color: color: Theme.of(
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), context,
).colorScheme.outline.withValues(alpha: 0.2),
width: 1, width: 1,
), ),
), ),