mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Centralize navigation logic
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
import 'package:zapstore/widgets/app_card.dart';
|
||||
import 'package:zapstore/widgets/author_container.dart';
|
||||
import 'package:zapstore/widgets/comments_section.dart';
|
||||
@@ -280,11 +280,7 @@ class _StackHeader extends StatelessWidget {
|
||||
oneLine: true,
|
||||
size: 14,
|
||||
isLoading: isAuthorLoading,
|
||||
onTap: () {
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
||||
context.push('/$first/user/${stack.pubkey}');
|
||||
},
|
||||
onTap: () => pushUser(context, stack.pubkey),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Last updated timestamp
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:zapstore/main.dart';
|
||||
import 'package:zapstore/services/bookmarks_service.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.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/app_card.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
@@ -285,11 +286,7 @@ class _StackMigrationWarning extends ConsumerWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
||||
context.push('/$first/stacks');
|
||||
},
|
||||
onTap: () => pushStacks(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
|
||||
import '../widgets/app_stack_container.dart';
|
||||
import '../widgets/latest_releases_container.dart';
|
||||
@@ -30,24 +30,11 @@ class SearchScreen extends HookConsumerWidget {
|
||||
|
||||
final performSearch = useCallback((String query) {
|
||||
final trimmed = query.trim();
|
||||
final cleaned = trimmed
|
||||
.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();
|
||||
searchQuery.value = '';
|
||||
context.push(path);
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (navigateToContent(context, trimmed, fallbackLaunch: false)) {
|
||||
searchController.clear();
|
||||
searchQuery.value = '';
|
||||
return;
|
||||
}
|
||||
if (trimmed.length < 3) {
|
||||
searchFocusNode.requestFocus();
|
||||
return;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/common/note_parser.dart';
|
||||
import '../widgets/common/profile_identity_row.dart';
|
||||
@@ -273,20 +273,12 @@ class _StackLinkCard extends StatelessWidget {
|
||||
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');
|
||||
},
|
||||
onTap: () => pushStack(
|
||||
context,
|
||||
stack.identifier,
|
||||
author: pubkey,
|
||||
kind: stack.event.kind,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -370,12 +362,6 @@ class _UserBio extends HookWidget {
|
||||
Theme.of(context).colorScheme.primary,
|
||||
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'),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -4,19 +4,24 @@ import 'package:flutter/foundation.dart';
|
||||
/// the URI is not a recognized deep link.
|
||||
///
|
||||
/// Handles:
|
||||
/// - `https://zapstore.dev/apps/{id}` (full URI from app_links)
|
||||
/// - `/apps/{id}` (path-only, as seen by GoRouter's onException)
|
||||
/// - `https://zapstore.dev/apps/{id}` and `.../stacks/{id}`
|
||||
/// - `/apps/{id}` and `/stacks/{id}` (path-only, as seen by GoRouter's onException)
|
||||
/// - `market://details?id=com.example.app`
|
||||
/// - `market://search?q=search+query`
|
||||
String? resolveDeepLinkPath(Uri uri) {
|
||||
// https://zapstore.dev/apps/<id> OR bare /apps/<id> from GoRouter
|
||||
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'apps') {
|
||||
final isFullUri = uri.scheme == 'https' && uri.host == 'zapstore.dev';
|
||||
final isBarePath = uri.host.isEmpty;
|
||||
if (isFullUri || isBarePath) {
|
||||
final id = uri.pathSegments[1];
|
||||
if (id.isNotEmpty) {
|
||||
return '/search/app/$id';
|
||||
// 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 isBarePath = uri.host.isEmpty;
|
||||
if (isFullUri || isBarePath) {
|
||||
final id = uri.pathSegments[1];
|
||||
if (id.isNotEmpty) {
|
||||
final route = section == 'apps' ? 'app' : 'stack';
|
||||
return '/search/$route/$id';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.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:skeletonizer/skeletonizer.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
import 'package:zapstore/utils/url_utils.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/widgets/zap_widgets.dart';
|
||||
@@ -55,22 +55,7 @@ class AppCard extends HookConsumerWidget {
|
||||
: 'No description available';
|
||||
|
||||
Widget buildCard(Profile? publisher, bool isPublisherLoading) => GestureDetector(
|
||||
onTap: () {
|
||||
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');
|
||||
},
|
||||
onTap: () => pushApp(context, app!.identifier, author: app!.pubkey, kind: app!.event.kind),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:zapstore/services/bookmarks_service.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
@@ -49,11 +48,6 @@ class ReleaseNotes extends StatelessWidget {
|
||||
}
|
||||
return ExpandableMarkdown(
|
||||
data: release.releaseNotes!,
|
||||
onTapLink: (text, url, title) {
|
||||
if (url != null) {
|
||||
launchUrl(Uri.parse(url));
|
||||
}
|
||||
},
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
color: const Color(0xFF1E3A5F), // Dark blue
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import '../utils/extensions.dart';
|
||||
@@ -268,11 +268,7 @@ class _SeeMoreCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
||||
context.push('/$first/stacks');
|
||||
},
|
||||
onTap: () => pushStacks(context),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
@@ -378,19 +374,12 @@ class StackCard extends StatelessWidget {
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
||||
final naddr = Utils.encodeShareableIdentifier(
|
||||
AddressInput(
|
||||
identifier: stack.identifier,
|
||||
author: stack.pubkey,
|
||||
kind: stack.event.kind,
|
||||
relays: const [],
|
||||
),
|
||||
);
|
||||
context.push('/$first/stack/$naddr');
|
||||
},
|
||||
onTap: () => pushStack(
|
||||
context,
|
||||
stack.identifier,
|
||||
author: stack.pubkey,
|
||||
kind: stack.event.kind,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:async_button_builder/async_button_builder.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:models/models.dart';
|
||||
@@ -34,7 +33,6 @@ class CommentsSection extends HookConsumerWidget {
|
||||
},
|
||||
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
||||
subscriptionPrefix: 'app-comments',
|
||||
and: (comment) => {comment.replies.query()},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -76,7 +74,6 @@ class StackCommentsSection extends HookConsumerWidget {
|
||||
},
|
||||
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
||||
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.secondary,
|
||||
],
|
||||
onProfileTap: (pubkey) {
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final branch = segments.isNotEmpty ? segments.first : 'search';
|
||||
context.push('/$branch/user/$pubkey');
|
||||
},
|
||||
),
|
||||
),
|
||||
if (isSignedIn) ...[
|
||||
|
||||
@@ -1,29 +1,9 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:url_launcher/url_launcher.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 (_) {}
|
||||
}
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
|
||||
class NoteParser {
|
||||
static final RegExp nip19Regex = RegExp(
|
||||
@@ -48,7 +28,6 @@ class NoteParser {
|
||||
Widget? Function(String httpUrl)? onHttpUrl,
|
||||
Widget? Function(String hashtag)? onHashtag,
|
||||
void Function(String hashtag)? onHashtagTap,
|
||||
void Function(String pubkey)? onProfileTap,
|
||||
TextStyle? textStyle,
|
||||
TextStyle? linkStyle,
|
||||
}) {
|
||||
@@ -152,7 +131,7 @@ class NoteParser {
|
||||
text: match.text,
|
||||
style: style,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => _launchUrlSafely(match.text),
|
||||
..onTap = () => navigateToContent(context, match.text),
|
||||
));
|
||||
} else {
|
||||
final style =
|
||||
@@ -202,15 +181,11 @@ enum _EntityType { nip19, http, hashtag }
|
||||
class NostrEntityWidget extends StatelessWidget {
|
||||
final String entity;
|
||||
final List<Color> colorPair;
|
||||
final void Function(String pubkey)? onProfileTap;
|
||||
final void Function(String hashtag)? onHashtagTap;
|
||||
|
||||
const NostrEntityWidget({
|
||||
super.key,
|
||||
required this.entity,
|
||||
required this.colorPair,
|
||||
this.onProfileTap,
|
||||
this.onHashtagTap,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -222,7 +197,6 @@ class NostrEntityWidget extends StatelessWidget {
|
||||
ProfileData() => ProfileEntityWidget(
|
||||
profileData: decoded,
|
||||
colorPair: colorPair,
|
||||
onProfileTap: onProfileTap,
|
||||
),
|
||||
EventData() => EventEntityWidget(
|
||||
eventData: decoded,
|
||||
@@ -242,13 +216,11 @@ class NostrEntityWidget extends StatelessWidget {
|
||||
class ProfileEntityWidget extends ConsumerWidget {
|
||||
final ProfileData profileData;
|
||||
final List<Color> colorPair;
|
||||
final void Function(String pubkey)? onProfileTap;
|
||||
|
||||
const ProfileEntityWidget({
|
||||
super.key,
|
||||
required this.profileData,
|
||||
required this.colorPair,
|
||||
this.onProfileTap,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -264,15 +236,7 @@ class ProfileEntityWidget extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
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}');
|
||||
}
|
||||
}
|
||||
void handleTap() => pushUser(context, profileData.pubkey);
|
||||
|
||||
return switch (profileState) {
|
||||
StorageLoading() => GestureDetector(
|
||||
@@ -294,20 +258,8 @@ class ProfileEntityWidget extends ConsumerWidget {
|
||||
}
|
||||
|
||||
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(
|
||||
onTap: handleTap,
|
||||
onTap: () => pushUser(context, profileData.pubkey),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colorPair[0].withValues(alpha: 0.1),
|
||||
@@ -316,7 +268,7 @@ class ProfileEntityWidget extends ConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Text(
|
||||
displayName,
|
||||
profile.nameOrNpub,
|
||||
style: context.textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorPair[0],
|
||||
@@ -411,10 +363,11 @@ class AddressEntityWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final segment = addressData.kind == 30267 ? 'stack' : 'app';
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final branch = segments.isNotEmpty ? segments.first : 'search';
|
||||
context.push('/$branch/$segment/${addressData.identifier}');
|
||||
if (addressData.kind == 30267) {
|
||||
pushStack(context, addressData.identifier);
|
||||
} else {
|
||||
pushApp(context, addressData.identifier);
|
||||
}
|
||||
},
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
|
||||
class ExpandableMarkdown extends HookWidget {
|
||||
const ExpandableMarkdown({
|
||||
@@ -21,8 +22,6 @@ class ExpandableMarkdown extends HookWidget {
|
||||
const maxHeight = 170.0;
|
||||
|
||||
bool isLikelyLong(String text) {
|
||||
// Simple heuristic to avoid expensive measurement
|
||||
// - Many words OR many newlines => likely long content
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isEmpty) return false;
|
||||
|
||||
@@ -30,16 +29,19 @@ class ExpandableMarkdown extends HookWidget {
|
||||
final newlineCount = '\n'.allMatches(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;
|
||||
}
|
||||
|
||||
final shouldCollapse = !expanded.value && isLikelyLong(data);
|
||||
|
||||
final effectiveTapLink = onTapLink ??
|
||||
(String text, String? href, String? title) {
|
||||
if (href != null) navigateToContent(context, href);
|
||||
};
|
||||
|
||||
final markdown = MarkdownBody(
|
||||
data: data,
|
||||
onTapLink: onTapLink,
|
||||
onTapLink: effectiveTapLink,
|
||||
styleSheet: styleSheet,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.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/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
|
||||
/// Floating three-dot overflow menu reusable across detail screens.
|
||||
///
|
||||
@@ -154,9 +154,7 @@ class FloatingOverflowMenu extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void _viewPublisher(BuildContext context) {
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
||||
context.push('/$first/user/$publisherPubkey');
|
||||
pushUser(context, publisherPubkey);
|
||||
}
|
||||
|
||||
Future<void> _openInBrowser(BuildContext context) async {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:models/models.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
import 'package:zapstore/utils/url_utils.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import '../theme.dart';
|
||||
@@ -31,19 +31,12 @@ class SearchAppCard extends ConsumerWidget {
|
||||
const iconSize = 48.0;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
||||
final naddr = Utils.encodeShareableIdentifier(
|
||||
AddressInput(
|
||||
identifier: app!.identifier,
|
||||
author: app!.pubkey,
|
||||
kind: app!.event.kind,
|
||||
relays: const [],
|
||||
),
|
||||
);
|
||||
context.push('/$first/app/$naddr');
|
||||
},
|
||||
onTap: () => pushApp(
|
||||
context,
|
||||
app!.identifier,
|
||||
author: app!.pubkey,
|
||||
kind: app!.event.kind,
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -164,8 +157,17 @@ class SearchAppCard extends ConsumerWidget {
|
||||
}
|
||||
if (node is md.Element) {
|
||||
final isBlock = const {
|
||||
'p', 'li', 'ul', 'ol', 'blockquote',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p',
|
||||
'li',
|
||||
'ul',
|
||||
'ol',
|
||||
'blockquote',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
}.contains(node.tag);
|
||||
if (node.tag == 'br') buffer.write('\n');
|
||||
for (final child in node.children ?? []) {
|
||||
@@ -193,8 +195,9 @@ class SearchAppCard extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
||||
border: Border.all(
|
||||
color:
|
||||
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user