Post tags

This commit is contained in:
LittleSheep 2025-06-27 02:31:21 +08:00
parent 0361f031db
commit 4deff5a920
7 changed files with 114 additions and 118 deletions

View File

@ -103,30 +103,32 @@ class PostComposeScreen extends HookConsumerWidget {
originalPost: originalPost, originalPost: originalPost,
forwardedPost: effectiveForwardedPost, forwardedPost: effectiveForwardedPost,
repliedPost: effectiveRepliedPost, repliedPost: effectiveRepliedPost,
postType: 0, // Regular post type
), ),
[originalPost, effectiveForwardedPost, effectiveRepliedPost], [originalPost, effectiveForwardedPost, effectiveRepliedPost],
); );
// Add a listener to the entire state to trigger rebuilds // Add a listener to the entire state to trigger rebuilds
final stateNotifier = useMemoized( final stateNotifier = useMemoized(
() => Listenable.merge([ () => Listenable.merge([
state.titleController, state.titleController,
state.descriptionController, state.descriptionController,
state.contentController, state.contentController,
state.visibility, state.visibility,
state.attachments, state.attachments,
state.attachmentProgress, state.attachmentProgress,
state.currentPublisher, state.currentPublisher,
state.submitting, state.submitting,
]), ]),
[state]); [state],
);
useListenable(stateNotifier); useListenable(stateNotifier);
// Start auto-save when component mounts // Start auto-save when component mounts
useEffect(() { useEffect(() {
if (originalPost == null) { if (originalPost == null) {
// Only auto-save for new posts, not edits // Only auto-save for new posts, not edits
state.startAutoSave(ref, postType: 0); state.startAutoSave(ref);
} }
return () => state.stopAutoSave(); return () => state.stopAutoSave();
}, [state]); }, [state]);
@ -165,13 +167,18 @@ class PostComposeScreen extends HookConsumerWidget {
final drafts = ref.read(composeStorageNotifierProvider); final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) { if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce( final mostRecentDraft = drafts.values.reduce(
(a, b) => (a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0)) ? a : b, (a, b) =>
(a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0))
? a
: b,
); );
// Only load if the draft has meaningful content // Only load if the draft has meaningful content
if (mostRecentDraft.content?.isNotEmpty == true || mostRecentDraft.title?.isNotEmpty == true) { if (mostRecentDraft.content?.isNotEmpty == true ||
mostRecentDraft.title?.isNotEmpty == true) {
state.titleController.text = mostRecentDraft.title ?? ''; state.titleController.text = mostRecentDraft.title ?? '';
state.descriptionController.text = mostRecentDraft.description ?? ''; state.descriptionController.text =
mostRecentDraft.description ?? '';
state.contentController.text = mostRecentDraft.content ?? ''; state.contentController.text = mostRecentDraft.content ?? '';
state.visibility.value = mostRecentDraft.visibility; state.visibility.value = mostRecentDraft.visibility;
} }
@ -298,7 +305,8 @@ class PostComposeScreen extends HookConsumerWidget {
state.titleController.text = draft.title ?? ''; state.titleController.text = draft.title ?? '';
state.descriptionController.text = state.descriptionController.text =
draft.description ?? ''; draft.description ?? '';
state.contentController.text = draft.content ?? ''; state.contentController.text =
draft.content ?? '';
state.visibility.value = draft.visibility; state.visibility.value = draft.visibility;
} }
}, },
@ -322,14 +330,13 @@ class PostComposeScreen extends HookConsumerWidget {
state.submitting.value state.submitting.value
? null ? null
: () => ComposeLogic.performAction( : () => ComposeLogic.performAction(
ref, ref,
state, state,
context, context,
originalPost: originalPost, originalPost: originalPost,
repliedPost: repliedPost, repliedPost: repliedPost,
forwardedPost: forwardedPost, forwardedPost: forwardedPost,
postType: 0, // Regular post type ),
),
icon: icon:
state.submitting.value state.submitting.value
? SizedBox( ? SizedBox(
@ -341,9 +348,7 @@ class PostComposeScreen extends HookConsumerWidget {
), ),
).center() ).center()
: Icon( : Icon(
originalPost != null originalPost != null ? Symbols.edit : Symbols.upload,
? Symbols.edit
: Symbols.upload,
), ),
), ),
const Gap(8), const Gap(8),
@ -405,7 +410,6 @@ class PostComposeScreen extends HookConsumerWidget {
originalPost: originalPost, originalPost: originalPost,
repliedPost: repliedPost, repliedPost: repliedPost,
forwardedPost: forwardedPost, forwardedPost: forwardedPost,
postType: 0, // Regular post type
), ),
child: TextField( child: TextField(
controller: state.contentController, controller: state.contentController,

View File

@ -60,7 +60,10 @@ class ArticleComposeScreen extends HookConsumerWidget {
final publishers = ref.watch(publishersManagedProvider); final publishers = ref.watch(publishersManagedProvider);
final state = useMemoized( final state = useMemoized(
() => ComposeLogic.createState(originalPost: originalPost), () => ComposeLogic.createState(
originalPost: originalPost,
postType: 1, // Article type
),
[originalPost], [originalPost],
); );
@ -70,7 +73,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
if (originalPost == null) { if (originalPost == null) {
// Only auto-save for new articles, not edits // Only auto-save for new articles, not edits
autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) { autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1); ComposeLogic.saveDraftWithoutUpload(ref, state);
}); });
} }
return () { return () {
@ -78,7 +81,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
state.stopAutoSave(); state.stopAutoSave();
// Save final draft before disposing // Save final draft before disposing
if (originalPost == null) { if (originalPost == null) {
ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1); ComposeLogic.saveDraftWithoutUpload(ref, state);
} }
ComposeLogic.dispose(state); ComposeLogic.dispose(state);
autoSaveTimer?.cancel(); autoSaveTimer?.cancel();
@ -362,7 +365,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
return PopScope( return PopScope(
onPopInvoked: (_) { onPopInvoked: (_) {
if (originalPost == null) { if (originalPost == null) {
ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1); ComposeLogic.saveDraftWithoutUpload(ref, state);
} }
}, },
child: AppScaffold( child: AppScaffold(
@ -410,7 +413,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
), ),
IconButton( IconButton(
icon: const Icon(Symbols.save), icon: const Icon(Symbols.save),
onPressed: () => ComposeLogic.saveDraft(ref, state, postType: 1), onPressed: () => ComposeLogic.saveDraft(ref, state),
tooltip: 'saveDraft'.tr(), tooltip: 'saveDraft'.tr(),
), ),
IconButton( IconButton(
@ -437,7 +440,6 @@ class ArticleComposeScreen extends HookConsumerWidget {
state, state,
context, context,
originalPost: originalPost, originalPost: originalPost,
postType: 1, // Article type
), ),
icon: icon:
submitting submitting
@ -530,18 +532,17 @@ class ArticleComposeScreen extends HookConsumerWidget {
if (isPaste && isModifierPressed) { if (isPaste && isModifierPressed) {
ComposeLogic.handlePaste(state); ComposeLogic.handlePaste(state);
} else if (isSave && isModifierPressed) { } else if (isSave && isModifierPressed) {
ComposeLogic.saveDraft(ref, state, postType: 1); ComposeLogic.saveDraft(ref, state);
ComposeLogic.saveDraft(ref, state);
} else if (isSubmit && isModifierPressed && !state.submitting.value) { } else if (isSubmit && isModifierPressed && !state.submitting.value) {
ComposeLogic.performAction( ComposeLogic.performAction(
ref, ref,
state, state,
context, context,
originalPost: originalPost, originalPost: originalPost,
postType: 1, // Article type
); );
} }
} }
// Helper method to save article draft // Helper method to save article draft
} }

View File

@ -27,6 +27,7 @@ class ChipTagInputField extends StatelessWidget {
decoration: InputDecoration( decoration: InputDecoration(
label: Text(labelText).tr(), label: Text(labelText).tr(),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.all(16),
hintText: inputFieldValues.tags.isNotEmpty ? '' : hintText.tr(), hintText: inputFieldValues.tags.isNotEmpty ? '' : hintText.tr(),
errorText: inputFieldValues.error, errorText: inputFieldValues.error,
prefixIconConstraints: BoxConstraints( prefixIconConstraints: BoxConstraints(
@ -51,9 +52,7 @@ class ChipTagInputField extends StatelessWidget {
), ),
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.only(left: 5),
horizontal: 5.0,
),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10.0, horizontal: 10.0,
vertical: 5.0, vertical: 5.0,
@ -72,11 +71,8 @@ class ChipTagInputField extends StatelessWidget {
).colorScheme.onPrimary, ).colorScheme.onPrimary,
), ),
), ),
onTap: () {
//print("$tag selected");
},
), ),
const SizedBox(width: 4.0), const Gap(4),
InkWell( InkWell(
child: const Icon( child: const Icon(
Icons.cancel, Icons.cancel,

View File

@ -27,9 +27,10 @@ class ComposeState {
final ValueNotifier<Map<int, double>> attachmentProgress; final ValueNotifier<Map<int, double>> attachmentProgress;
final ValueNotifier<SnPublisher?> currentPublisher; final ValueNotifier<SnPublisher?> currentPublisher;
final ValueNotifier<bool> submitting; final ValueNotifier<bool> submitting;
final StringTagController tagsController; StringTagController tagsController;
final StringTagController categoriesController; StringTagController categoriesController;
final String draftId; final String draftId;
int postType;
Timer? _autoSaveTimer; Timer? _autoSaveTimer;
ComposeState({ ComposeState({
@ -44,12 +45,13 @@ class ComposeState {
required this.tagsController, required this.tagsController,
required this.categoriesController, required this.categoriesController,
required this.draftId, required this.draftId,
this.postType = 0,
}); });
void startAutoSave(WidgetRef ref, {int postType = 0}) { void startAutoSave(WidgetRef ref) {
_autoSaveTimer?.cancel(); _autoSaveTimer?.cancel();
_autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) { _autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
ComposeLogic.saveDraftWithoutUpload(ref, this, postType: postType); ComposeLogic.saveDraftWithoutUpload(ref, this);
}); });
} }
@ -65,6 +67,7 @@ class ComposeLogic {
SnPost? forwardedPost, SnPost? forwardedPost,
SnPost? repliedPost, SnPost? repliedPost,
String? draftId, String? draftId,
int postType = 0,
}) { }) {
final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString(); final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString();
final tagsController = StringTagController(); final tagsController = StringTagController();
@ -110,10 +113,11 @@ class ComposeLogic {
tagsController: tagsController, tagsController: tagsController,
categoriesController: categoriesController, categoriesController: categoriesController,
draftId: id, draftId: id,
postType: postType,
); );
} }
static ComposeState createStateFromDraft(SnPost draft) { static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) {
final tagsController = StringTagController(); final tagsController = StringTagController();
final categoriesController = StringTagController(); final categoriesController = StringTagController();
for (var x in draft.tags) { for (var x in draft.tags) {
@ -136,14 +140,11 @@ class ComposeLogic {
tagsController: tagsController, tagsController: tagsController,
categoriesController: categoriesController, categoriesController: categoriesController,
draftId: draft.id, draftId: draft.id,
postType: postType,
); );
} }
static Future<void> saveDraft( static Future<void> saveDraft(WidgetRef ref, ComposeState state) async {
WidgetRef ref,
ComposeState state, {
int postType = 0,
}) async {
final hasContent = final hasContent =
state.titleController.text.trim().isNotEmpty || state.titleController.text.trim().isNotEmpty ||
state.descriptionController.text.trim().isNotEmpty || state.descriptionController.text.trim().isNotEmpty ||
@ -175,7 +176,7 @@ class ComposeLogic {
baseUrl: baseUrl, baseUrl: baseUrl,
filename: filename:
attachment.data.name ?? attachment.data.name ??
(postType == 1 ? 'Article media' : 'Post media'), (state.postType == 1 ? 'Article media' : 'Post media'),
mimetype: mimetype:
attachment.data.mimeType ?? attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(attachment.type), ComposeLogic.getMimeTypeFromFileType(attachment.type),
@ -202,7 +203,7 @@ class ComposeLogic {
publishedAt: DateTime.now(), publishedAt: DateTime.now(),
visibility: state.visibility.value, visibility: state.visibility.value,
content: state.contentController.text, content: state.contentController.text,
type: postType, type: state.postType,
meta: null, meta: null,
viewsUnique: 0, viewsUnique: 0,
viewsTotal: 0, viewsTotal: 0,
@ -252,9 +253,8 @@ class ComposeLogic {
static Future<void> saveDraftWithoutUpload( static Future<void> saveDraftWithoutUpload(
WidgetRef ref, WidgetRef ref,
ComposeState state, { ComposeState state,
int postType = 0, ) async {
}) async {
final hasContent = final hasContent =
state.titleController.text.trim().isNotEmpty || state.titleController.text.trim().isNotEmpty ||
state.descriptionController.text.trim().isNotEmpty || state.descriptionController.text.trim().isNotEmpty ||
@ -279,7 +279,7 @@ class ComposeLogic {
publishedAt: DateTime.now(), publishedAt: DateTime.now(),
visibility: state.visibility.value, visibility: state.visibility.value,
content: state.contentController.text, content: state.contentController.text,
type: postType, type: state.postType,
meta: null, meta: null,
viewsUnique: 0, viewsUnique: 0,
viewsTotal: 0, viewsTotal: 0,
@ -333,54 +333,7 @@ class ComposeLogic {
BuildContext context, BuildContext context,
) async { ) async {
try { try {
final draft = SnPost( await saveDraft(ref, state);
id: state.draftId,
title: state.titleController.text,
description: state.descriptionController.text,
language: null,
editedAt: null,
publishedAt: DateTime.now(),
visibility: state.visibility.value,
content: state.contentController.text,
type: 0,
meta: null,
viewsUnique: 0,
viewsTotal: 0,
upvotes: 0,
downvotes: 0,
repliesCount: 0,
threadedPostId: null,
threadedPost: null,
repliedPostId: null,
repliedPost: null,
forwardedPostId: null,
forwardedPost: null,
attachments: [], // TODO: Handle attachments
publisher: SnPublisher(
id: '',
type: 0,
name: '',
nick: '',
picture: null,
background: null,
account: null,
accountId: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
realmId: null,
verification: null,
),
reactions: [],
tags: [],
categories: [],
collections: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
);
await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
if (context.mounted) { if (context.mounted) {
showSnackBar('draftSaved'.tr()); showSnackBar('draftSaved'.tr());
@ -535,7 +488,6 @@ class ComposeLogic {
SnPost? originalPost, SnPost? originalPost,
SnPost? repliedPost, SnPost? repliedPost,
SnPost? forwardedPost, SnPost? forwardedPost,
int? postType, // 0 for regular post, 1 for article
}) async { }) async {
if (state.submitting.value) return; if (state.submitting.value) return;
@ -581,7 +533,7 @@ class ComposeLogic {
.where((e) => e.isOnCloud) .where((e) => e.isOnCloud)
.map((e) => e.data.id) .map((e) => e.data.id)
.toList(), .toList(),
if (postType != null) 'type': postType, 'type': state.postType,
if (repliedPost != null) 'replied_post_id': repliedPost.id, if (repliedPost != null) 'replied_post_id': repliedPost.id,
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
'tags': state.tagsController.getTags, 'tags': state.tagsController.getTags,
@ -599,7 +551,7 @@ class ComposeLogic {
); );
// Delete draft after successful submission // Delete draft after successful submission
if (postType == 1) { if (state.postType == 1) {
// Delete article draft // Delete article draft
await ref await ref
.read(composeStorageNotifierProvider.notifier) .read(composeStorageNotifierProvider.notifier)
@ -642,7 +594,6 @@ class ComposeLogic {
SnPost? originalPost, SnPost? originalPost,
SnPost? repliedPost, SnPost? repliedPost,
SnPost? forwardedPost, SnPost? forwardedPost,
int? postType,
}) { }) {
if (event is! RawKeyDownEvent) return; if (event is! RawKeyDownEvent) return;
@ -663,7 +614,6 @@ class ComposeLogic {
originalPost: originalPost, originalPost: originalPost,
repliedPost: repliedPost, repliedPost: repliedPost,
forwardedPost: forwardedPost, forwardedPost: forwardedPost,
postType: postType,
); );
} }
} }

View File

@ -242,6 +242,47 @@ class PostItem extends HookConsumerWidget {
? EdgeInsets.only(bottom: 8) ? EdgeInsets.only(bottom: 8)
: null, : null,
), ),
// Render tags and categories if they exist
if (item.tags.isNotEmpty || item.categories.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.tags.isNotEmpty)
Wrap(
children: [
for (final tag in item.tags)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(Symbols.label, size: 13),
Text(tag.name ?? '#${tag.slug}')
.fontSize(13)
],
),
onTap: () {},
),
],
),
if (item.categories.isNotEmpty)
Wrap(
children: [
for (final category in item.categories)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(Symbols.category, size: 13),
Text(category.name ?? '#${category.slug}')
.fontSize(13)
],
),
onTap: () {},
),
],
),
],
),
// Show truncation hint if post is truncated // Show truncation hint if post is truncated
if (item.isTruncated && !isFullPost) if (item.isTruncated && !isFullPost)
_PostTruncateHint().padding( _PostTruncateHint().padding(

View File

@ -2301,10 +2301,11 @@ packages:
textfield_tags: textfield_tags:
dependency: "direct main" dependency: "direct main"
description: description:
name: textfield_tags path: "."
sha256: d1f2204114157a1296bb97c20d7f8c8c7fd036212812afb2e19de7bb34acc55b ref: "fixes/allow-controller-re-registration"
url: "https://pub.dev" resolved-ref: "7574e79649e34df1c3cc0c49b2f0cc2b92de6a7b"
source: hosted url: "https://github.com/lionelmennig/textfield_tags.git"
source: git
version: "3.0.1" version: "3.0.1"
timezone: timezone:
dependency: "direct main" dependency: "direct main"

View File

@ -122,7 +122,10 @@ dependencies:
share_plus: ^11.0.0 share_plus: ^11.0.0
receive_sharing_intent: ^1.8.1 receive_sharing_intent: ^1.8.1
top_snackbar_flutter: ^3.3.0 top_snackbar_flutter: ^3.3.0
textfield_tags: ^3.0.1 textfield_tags:
git:
url: https://github.com/lionelmennig/textfield_tags.git
ref: fixes/allow-controller-re-registration
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: