Pin post

This commit is contained in:
2025-08-25 16:55:06 +08:00
parent 4dca6189cb
commit f9b2a96c7c
10 changed files with 357 additions and 145 deletions

View File

@@ -18,6 +18,7 @@ import 'package:island/screens/posts/compose.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/post_item_screenshot.dart';
import 'package:island/widgets/post/post_pin_sheet.dart';
import 'package:island/widgets/post/post_shared.dart';
import 'package:island/widgets/safety/abuse_report_helper.dart';
import 'package:island/widgets/share/share_sheet.dart';
@@ -202,6 +203,45 @@ class PostActionableItem extends HookConsumerWidget {
);
},
),
if (isAuthor && item.pinMode == null)
MenuAction(
title: 'pinPost'.tr(),
image: MenuImage.icon(Symbols.keep),
callback: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => PostPinSheet(post: item),
).then((value) {
if (value is int) {
onUpdate?.call(item.copyWith(pinMode: value));
}
});
},
)
else if (isAuthor && item.pinMode != null)
MenuAction(
title: 'unpinPost'.tr(),
image: MenuImage.icon(Symbols.keep_off),
callback: () {
showConfirmAlert('unpinPostHint'.tr(), 'unpinPost'.tr()).then(
(confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
try {
if (context.mounted) showLoadingModal(context);
await client.delete('/sphere/posts/${item.id}/pin');
onUpdate?.call(item.copyWith(pinMode: null));
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
},
);
},
),
MenuSeparator(),
MenuAction(
title: 'share'.tr(),

View File

@@ -22,6 +22,7 @@ class PostListNotifier extends _$PostListNotifier
List<String>? categories,
List<String>? tags,
bool shuffle = false,
bool pinned = false,
}) {
return fetch(cursor: null);
}
@@ -40,6 +41,7 @@ class PostListNotifier extends _$PostListNotifier
if (tags != null) 'tags': tags,
if (categories != null) 'categories': categories,
if (shuffle) 'shuffle': true,
if (pinned) 'pinned': true,
};
final response = await client.get(
@@ -77,6 +79,7 @@ class SliverPostList extends HookConsumerWidget {
final List<String>? categories;
final List<String>? tags;
final bool shuffle;
final bool pinned;
final PostItemType itemType;
final Color? backgroundColor;
final EdgeInsets? padding;
@@ -93,6 +96,7 @@ class SliverPostList extends HookConsumerWidget {
this.categories,
this.tags,
this.shuffle = false,
this.pinned = false,
this.itemType = PostItemType.regular,
this.backgroundColor,
this.padding,
@@ -104,33 +108,19 @@ class SliverPostList extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = postListNotifierProvider(
pubName: pubName,
realm: realm,
type: type,
categories: categories,
tags: tags,
shuffle: shuffle,
pinned: pinned,
);
return PagingHelperSliverView(
provider: postListNotifierProvider(
pubName: pubName,
realm: realm,
type: type,
categories: categories,
tags: tags,
shuffle: shuffle,
),
futureRefreshable:
postListNotifierProvider(
pubName: pubName,
realm: realm,
type: type,
categories: categories,
tags: tags,
shuffle: shuffle,
).future,
notifierRefreshable:
postListNotifierProvider(
pubName: pubName,
realm: realm,
type: type,
categories: categories,
tags: tags,
shuffle: shuffle,
).notifier,
provider: provider,
futureRefreshable: provider.future,
notifierRefreshable: provider.notifier,
contentBuilder:
(data, widgetCount, endItemView) => SliverList.builder(
itemCount: widgetCount,

View File

@@ -6,7 +6,7 @@ part of 'post_list.dart';
// RiverpodGenerator
// **************************************************************************
String _$postListNotifierHash() => r'faa0b939fae56367ff120ce63d9deb17b1995c9c';
String _$postListNotifierHash() => r'7be076e6cee1c52c258d0fad2cd9fe9ac5e100ac';
/// Copied from Dart SDK
class _SystemHash {
@@ -37,6 +37,7 @@ abstract class _$PostListNotifier
late final List<String>? categories;
late final List<String>? tags;
late final bool shuffle;
late final bool pinned;
FutureOr<CursorPagingData<SnPost>> build({
String? pubName,
@@ -45,6 +46,7 @@ abstract class _$PostListNotifier
List<String>? categories,
List<String>? tags,
bool shuffle = false,
bool pinned = false,
});
}
@@ -66,6 +68,7 @@ class PostListNotifierFamily
List<String>? categories,
List<String>? tags,
bool shuffle = false,
bool pinned = false,
}) {
return PostListNotifierProvider(
pubName: pubName,
@@ -74,6 +77,7 @@ class PostListNotifierFamily
categories: categories,
tags: tags,
shuffle: shuffle,
pinned: pinned,
);
}
@@ -88,6 +92,7 @@ class PostListNotifierFamily
categories: provider.categories,
tags: provider.tags,
shuffle: provider.shuffle,
pinned: provider.pinned,
);
}
@@ -121,6 +126,7 @@ class PostListNotifierProvider
List<String>? categories,
List<String>? tags,
bool shuffle = false,
bool pinned = false,
}) : this._internal(
() =>
PostListNotifier()
@@ -129,7 +135,8 @@ class PostListNotifierProvider
..type = type
..categories = categories
..tags = tags
..shuffle = shuffle,
..shuffle = shuffle
..pinned = pinned,
from: postListNotifierProvider,
name: r'postListNotifierProvider',
debugGetCreateSourceHash:
@@ -145,6 +152,7 @@ class PostListNotifierProvider
categories: categories,
tags: tags,
shuffle: shuffle,
pinned: pinned,
);
PostListNotifierProvider._internal(
@@ -160,6 +168,7 @@ class PostListNotifierProvider
required this.categories,
required this.tags,
required this.shuffle,
required this.pinned,
}) : super.internal();
final String? pubName;
@@ -168,6 +177,7 @@ class PostListNotifierProvider
final List<String>? categories;
final List<String>? tags;
final bool shuffle;
final bool pinned;
@override
FutureOr<CursorPagingData<SnPost>> runNotifierBuild(
@@ -180,6 +190,7 @@ class PostListNotifierProvider
categories: categories,
tags: tags,
shuffle: shuffle,
pinned: pinned,
);
}
@@ -195,7 +206,8 @@ class PostListNotifierProvider
..type = type
..categories = categories
..tags = tags
..shuffle = shuffle,
..shuffle = shuffle
..pinned = pinned,
from: from,
name: null,
dependencies: null,
@@ -207,6 +219,7 @@ class PostListNotifierProvider
categories: categories,
tags: tags,
shuffle: shuffle,
pinned: pinned,
),
);
}
@@ -228,7 +241,8 @@ class PostListNotifierProvider
other.type == type &&
other.categories == categories &&
other.tags == tags &&
other.shuffle == shuffle;
other.shuffle == shuffle &&
other.pinned == pinned;
}
@override
@@ -240,6 +254,7 @@ class PostListNotifierProvider
hash = _SystemHash.combine(hash, categories.hashCode);
hash = _SystemHash.combine(hash, tags.hashCode);
hash = _SystemHash.combine(hash, shuffle.hashCode);
hash = _SystemHash.combine(hash, pinned.hashCode);
return _SystemHash.finish(hash);
}
@@ -266,6 +281,9 @@ mixin PostListNotifierRef
/// The parameter `shuffle` of this provider.
bool get shuffle;
/// The parameter `pinned` of this provider.
bool get pinned;
}
class _PostListNotifierProviderElement
@@ -290,6 +308,8 @@ class _PostListNotifierProviderElement
List<String>? get tags => (origin as PostListNotifierProvider).tags;
@override
bool get shuffle => (origin as PostListNotifierProvider).shuffle;
@override
bool get pinned => (origin as PostListNotifierProvider).pinned;
}
// ignore_for_file: type=lint

View File

@@ -0,0 +1,124 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class PostPinSheet extends HookConsumerWidget {
final SnPost post;
const PostPinSheet({super.key, required this.post});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mode = useState(0);
Future<void> pinPost() async {
try {
showLoadingModal(context);
final client = ref.watch(apiClientProvider);
await client.post(
'/sphere/posts/${post.id}/pin',
data: {'mode': mode.value},
);
if (context.mounted) Navigator.of(context).pop(mode.value);
} catch (e) {
showErrorAlert(e);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return SheetScaffold(
titleText: 'pinPost'.tr(),
heightFactor: 0.6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Publisher page pin option (always available)
ListTile(
leading: Radio<int>(
value: 0,
groupValue: mode.value,
onChanged: (value) {
mode.value = value!;
},
),
title: Text('publisherPage'.tr()),
subtitle: Text('pinPostPublisherHint'.tr()),
onTap: () {
mode.value = 0;
},
),
// Realm page pin option (show always, but disabled when not available)
ListTile(
leading: Radio<int>(
value: 1,
groupValue: mode.value,
onChanged:
post.realmId != null && post.realmId!.isNotEmpty
? (value) {
mode.value = value!;
}
: null,
),
title: Text('realmPage'.tr()),
subtitle:
post.realmId != null && post.realmId!.isNotEmpty
? Text('pinPostRealmHint'.tr())
: Text('pinPostRealmDisabledHint'.tr()),
onTap:
post.realmId != null && post.realmId!.isNotEmpty
? () {
mode.value = 1;
}
: null,
enabled: post.realmId != null && post.realmId!.isNotEmpty,
),
// Reply page pin option (show always, but disabled when not available)
ListTile(
leading: Radio<int>(
value: 2,
groupValue: mode.value,
onChanged:
post.repliedPostId != null && post.repliedPostId!.isNotEmpty
? (value) {
mode.value = value!;
}
: null,
),
title: Text('replyPage'.tr()),
subtitle:
post.repliedPostId != null && post.repliedPostId!.isNotEmpty
? Text('pinPostReplyHint'.tr())
: Text('pinPostReplyDisabledHint'.tr()),
onTap:
post.repliedPostId != null && post.repliedPostId!.isNotEmpty
? () {
mode.value = 2;
}
: null,
enabled:
post.repliedPostId != null && post.repliedPostId!.isNotEmpty,
),
const SizedBox(height: 16),
// Pin button
FilledButton.icon(
onPressed: pinPost,
icon: const Icon(Symbols.keep),
label: Text('pin'.tr()),
).padding(horizontal: 24),
],
),
);
}
}

View File

@@ -545,107 +545,119 @@ class PostHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 12,
return Column(
children: [
GestureDetector(
onTap:
isInteractive
? () {
context.pushNamed(
'publisherProfile',
pathParameters: {'name': item.publisher.name},
);
}
: null,
child: ProfilePictureWidget(
file: item.publisher.picture,
radius: 16,
borderRadius: item.publisher.type == 0 ? null : 6,
),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
if (item.pinMode != null)
Row(
spacing: 4,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 4,
const Icon(Symbols.keep, size: 15, fill: 1),
Text('pinnedPost').tr().fontSize(13),
],
).opacity(0.8).padding(horizontal: 8, bottom: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 12,
children: [
GestureDetector(
onTap:
isInteractive
? () {
context.pushNamed(
'publisherProfile',
pathParameters: {'name': item.publisher.name},
);
}
: null,
child: ProfilePictureWidget(
file: item.publisher.picture,
radius: 16,
borderRadius: item.publisher.type == 0 ? null : 6,
),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.publisher.nick).bold(),
if (item.publisher.verification != null)
VerificationMark(mark: item.publisher.verification!),
if (item.realm == null)
Text('@${item.publisher.name}').fontSize(11)
else
...([
const Icon(Symbols.arrow_right, size: 14),
Flexible(
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 5,
children: [
Flexible(
child: Text(
item.realm!.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
ProfilePictureWidget(
file: item.realm!.picture,
fallbackIcon: Symbols.group,
radius: 9,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 4,
children: [
Text(item.publisher.nick).bold(),
if (item.publisher.verification != null)
VerificationMark(mark: item.publisher.verification!),
if (item.realm == null)
Text('@${item.publisher.name}').fontSize(11)
else
...([
const Icon(Symbols.arrow_right, size: 14),
Flexible(
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 5,
children: [
Flexible(
child: Text(
item.realm!.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
ProfilePictureWidget(
file: item.realm!.picture,
fallbackIcon: Symbols.group,
radius: 9,
),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'realmDetail',
pathParameters: {'slug': item.realm!.slug},
);
},
),
),
]),
],
),
Row(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
!isFullPost && isRelativeTime
? (item.publishedAt ?? item.createdAt)!
.formatRelative(context)
: (item.publishedAt ?? item.createdAt)!
.formatSystem(),
).fontSize(10),
if (item.editedAt != null)
Text(
'editedAt'.tr(
args: [
!isFullPost && isRelativeTime
? item.editedAt!.formatRelative(context)
: item.editedAt!.formatSystem(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'realmDetail',
pathParameters: {'slug': item.realm!.slug},
);
},
),
),
]),
).fontSize(10),
if (item.visibility != 0)
Text(
PostVisibilityHelpers.getVisibilityText(
item.visibility,
).tr(),
).fontSize(10),
],
),
],
),
Row(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
!isFullPost && isRelativeTime
? (item.publishedAt ?? item.createdAt)!.formatRelative(
context,
)
: (item.publishedAt ?? item.createdAt)!.formatSystem(),
).fontSize(10),
if (item.editedAt != null)
Text(
'editedAt'.tr(
args: [
!isFullPost && isRelativeTime
? item.editedAt!.formatRelative(context)
: item.editedAt!.formatSystem(),
],
),
).fontSize(10),
if (item.visibility != 0)
Text(
PostVisibilityHelpers.getVisibilityText(
item.visibility,
).tr(),
).fontSize(10),
],
),
],
),
),
if (trailing != null) trailing!,
],
),
if (trailing != null) trailing!,
],
).padding(horizontal: renderingPadding.horizontal, bottom: 4);
}