✨ Auto save post draft
🐛 Fix publisher edit screen overflow
This commit is contained in:
parent
ffbe399614
commit
6d68b20122
@ -405,6 +405,24 @@
|
||||
"settingsKeyboardShortcutNewMessage": "New Message",
|
||||
"settingsKeyboardShortcutCloseDialog": "Close Dialog",
|
||||
"close": "Close",
|
||||
"drafts": "Drafts",
|
||||
"noDrafts": "No drafts yet",
|
||||
"articleDrafts": "Article drafts",
|
||||
"postDrafts": "Post drafts",
|
||||
"clearAllDrafts": "Clear All Drafts",
|
||||
"clearAllDraftsConfirm": "Are you sure you want to delete all drafts? This action cannot be undone.",
|
||||
"clearAll": "Clear All",
|
||||
"untitled": "Untitled",
|
||||
"noContent": "No content",
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{} minutes ago",
|
||||
"hoursAgo": "{} hours ago",
|
||||
"daysAgo": "{} days ago",
|
||||
"public": "Public",
|
||||
"unlisted": "Unlisted",
|
||||
"friends": "Friends",
|
||||
"selected": "Selected",
|
||||
"private": "Private",
|
||||
"contactMethod": "Contact Method",
|
||||
"contactMethodType": "Contact Type",
|
||||
"contactMethodTypeEmail": "Email",
|
||||
|
@ -15,6 +15,7 @@ import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/realm/realms.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
@ -190,7 +191,9 @@ class EditPublisherScreen extends HookConsumerWidget {
|
||||
title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(),
|
||||
leading: const PageBackButton(),
|
||||
),
|
||||
body: Column(
|
||||
body: SingleChildScrollView(
|
||||
padding: getTabbedPadding(context, bottom: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
RealmSelectionDropdown(
|
||||
value: currentRealm.value,
|
||||
@ -244,6 +247,8 @@ class EditPublisherScreen extends HookConsumerWidget {
|
||||
).padding(bottom: 32),
|
||||
Form(
|
||||
key: formKey,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 480),
|
||||
child: Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -284,13 +289,16 @@ class EditPublisherScreen extends HookConsumerWidget {
|
||||
nickController.text = user.value!.nick;
|
||||
bioController.text = user.value!.profile.bio;
|
||||
picture.value = user.value!.profile.picture?.id;
|
||||
background.value = user.value!.profile.background?.id;
|
||||
background.value =
|
||||
user.value!.profile.background?.id;
|
||||
} else {
|
||||
nameController.text = currentRealm.value!.slug;
|
||||
nickController.text = currentRealm.value!.name;
|
||||
bioController.text = currentRealm.value!.description;
|
||||
bioController.text =
|
||||
currentRealm.value!.description;
|
||||
picture.value = currentRealm.value!.picture?.id;
|
||||
background.value = currentRealm.value!.background?.id;
|
||||
background.value =
|
||||
currentRealm.value!.background?.id;
|
||||
}
|
||||
},
|
||||
label:
|
||||
@ -303,16 +311,21 @@ class EditPublisherScreen extends HookConsumerWidget {
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: submitting.value ? null : performAction,
|
||||
label: Text(name == null ? 'create' : 'saveChanges').tr(),
|
||||
label:
|
||||
Text(
|
||||
name == null ? 'create' : 'saveChanges',
|
||||
).tr(),
|
||||
icon: const Icon(Symbols.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24),
|
||||
).alignment(Alignment.topCenter),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,8 @@ import 'package:island/widgets/post/post_item.dart';
|
||||
import 'package:island/widgets/post/publishers_modal.dart';
|
||||
import 'package:island/screens/posts/detail.dart';
|
||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
|
||||
import 'package:island/services/compose_storage.dart';
|
||||
import 'package:island/widgets/post/draft_manager.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
@ -60,6 +62,17 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
@QueryParam('type') this.type,
|
||||
});
|
||||
|
||||
// Helper method to parse visibility
|
||||
int _parseVisibility(String visibility) {
|
||||
switch (visibility) {
|
||||
case 'public': return 0;
|
||||
case 'unlisted': return 1;
|
||||
case 'friends': return 2;
|
||||
case 'private': return 3;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Determine the compose type: auto-detect from edited post or use query parameter
|
||||
@ -88,6 +101,14 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
[originalPost, effectiveForwardedPost, effectiveRepliedPost],
|
||||
);
|
||||
|
||||
// Start auto-save when component mounts
|
||||
useEffect(() {
|
||||
if (originalPost == null) { // Only auto-save for new posts, not edits
|
||||
state.startAutoSave(ref);
|
||||
}
|
||||
return () => state.stopAutoSave();
|
||||
}, [state]);
|
||||
|
||||
// Initialize publisher once when data is available
|
||||
useEffect(() {
|
||||
if (publishers.value?.isNotEmpty ?? false) {
|
||||
@ -96,9 +117,38 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [publishers]);
|
||||
|
||||
// Load draft if available (only for new posts)
|
||||
useEffect(() {
|
||||
if (originalPost == null && effectiveForwardedPost == null && effectiveRepliedPost == null) {
|
||||
// Try to load the most recent draft
|
||||
final drafts = ref.read(composeStorageNotifierProvider);
|
||||
if (drafts.isNotEmpty) {
|
||||
final mostRecentDraft = drafts.values.reduce((a, b) =>
|
||||
a.lastModified.isAfter(b.lastModified) ? a : b);
|
||||
|
||||
// Only load if the draft has meaningful content
|
||||
if (!mostRecentDraft.isEmpty) {
|
||||
state.titleController.text = mostRecentDraft.title;
|
||||
state.descriptionController.text = mostRecentDraft.description;
|
||||
state.contentController.text = mostRecentDraft.content;
|
||||
state.visibility.value = _parseVisibility(mostRecentDraft.visibility);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Dispose state when widget is disposed
|
||||
useEffect(() {
|
||||
return () => ComposeLogic.dispose(state);
|
||||
return () {
|
||||
// Stop auto-save first to prevent race conditions
|
||||
state.stopAutoSave();
|
||||
// Save final draft before disposing
|
||||
if (originalPost == null) {
|
||||
ComposeLogic.saveDraft(ref, state);
|
||||
}
|
||||
ComposeLogic.dispose(state);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Helper methods
|
||||
@ -190,6 +240,29 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
actions: [
|
||||
if (originalPost == null) // Only show drafts for new posts
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.draft),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => DraftManagerSheet(
|
||||
isArticle: false,
|
||||
onDraftSelected: (draftId) {
|
||||
final draft = ref.read(composeStorageNotifierProvider)[draftId];
|
||||
if (draft != null) {
|
||||
state.titleController.text = draft.title;
|
||||
state.descriptionController.text = draft.description;
|
||||
state.contentController.text = draft.content;
|
||||
state.visibility.value = _parseVisibility(draft.visibility);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'drafts'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings),
|
||||
onPressed: showSettingsSheet,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -11,11 +13,13 @@ import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/screens/posts/detail.dart';
|
||||
import 'package:island/widgets/content/attachment_preview.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:island/widgets/post/compose_shared.dart';
|
||||
import 'package:island/widgets/post/publishers_modal.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
|
||||
import 'package:island/widgets/post/publishers_modal.dart';
|
||||
import 'package:island/services/compose_storage.dart';
|
||||
import 'package:island/widgets/post/draft_manager.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
@ -60,6 +64,24 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
[originalPost],
|
||||
);
|
||||
|
||||
// Start auto-save when component mounts
|
||||
useEffect(() {
|
||||
Timer? autoSaveTimer;
|
||||
if (originalPost == null) {
|
||||
// Only auto-save for new articles, not edits
|
||||
autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||||
_saveArticleDraft(ref, state);
|
||||
});
|
||||
}
|
||||
return () {
|
||||
// Save final draft before cancelling timer
|
||||
if (originalPost == null) {
|
||||
_saveArticleDraft(ref, state);
|
||||
}
|
||||
autoSaveTimer?.cancel();
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
final showPreview = useState(false);
|
||||
|
||||
// Initialize publisher once when data is available
|
||||
@ -70,10 +92,40 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [publishers]);
|
||||
|
||||
// Load draft if available (only for new articles)
|
||||
useEffect(() {
|
||||
if (originalPost == null) {
|
||||
// Try to load the most recent article draft
|
||||
final drafts = ref.read(articleStorageNotifierProvider);
|
||||
if (drafts.isNotEmpty) {
|
||||
final mostRecentDraft = drafts.values.reduce(
|
||||
(a, b) => a.lastModified.isAfter(b.lastModified) ? a : b,
|
||||
);
|
||||
|
||||
// Only load if the draft has meaningful content
|
||||
if (!mostRecentDraft.isEmpty) {
|
||||
state.titleController.text = mostRecentDraft.title;
|
||||
state.descriptionController.text = mostRecentDraft.description;
|
||||
state.contentController.text = mostRecentDraft.content;
|
||||
state.visibility.value = _parseArticleVisibility(
|
||||
mostRecentDraft.visibility,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Dispose state when widget is disposed
|
||||
useEffect(() {
|
||||
return () => ComposeLogic.dispose(state);
|
||||
}, []);
|
||||
return () {
|
||||
// Save final draft before disposing
|
||||
if (originalPost == null) {
|
||||
_saveArticleDraft(ref, state);
|
||||
}
|
||||
ComposeLogic.dispose(state);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
// Helper methods
|
||||
void showSettingsSheet() {
|
||||
@ -318,6 +370,34 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
actions: [
|
||||
// Info banner for article compose
|
||||
const SizedBox.shrink(),
|
||||
if (originalPost == null) // Only show drafts for new articles
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.draft),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => DraftManagerSheet(
|
||||
isArticle: true,
|
||||
onDraftSelected: (draftId) {
|
||||
final draft =
|
||||
ref.read(articleStorageNotifierProvider)[draftId];
|
||||
if (draft != null) {
|
||||
state.titleController.text = draft.title;
|
||||
state.descriptionController.text =
|
||||
draft.description;
|
||||
state.contentController.text = draft.content;
|
||||
state.visibility.value = _parseArticleVisibility(
|
||||
draft.visibility,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'drafts'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings),
|
||||
onPressed: showSettingsSheet,
|
||||
@ -413,4 +493,55 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper method to save article draft
|
||||
Future<void> _saveArticleDraft(WidgetRef ref, ComposeState state) async {
|
||||
try {
|
||||
final draft = ArticleDraft(
|
||||
id: state.draftId,
|
||||
title: state.titleController.text,
|
||||
description: state.descriptionController.text,
|
||||
content: state.contentController.text,
|
||||
visibility: _visibilityToString(state.visibility.value),
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
|
||||
await ref.read(articleStorageNotifierProvider.notifier).saveDraft(draft);
|
||||
} catch (e) {
|
||||
log('[ArticleCompose] Failed to save draft, error: $e');
|
||||
// Silently fail for auto-save to avoid disrupting user experience
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to convert visibility int to string
|
||||
String _visibilityToString(int visibility) {
|
||||
switch (visibility) {
|
||||
case 0:
|
||||
return 'public';
|
||||
case 1:
|
||||
return 'unlisted';
|
||||
case 2:
|
||||
return 'friends';
|
||||
case 3:
|
||||
return 'private';
|
||||
default:
|
||||
return 'public';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to parse article visibility
|
||||
int _parseArticleVisibility(String visibility) {
|
||||
switch (visibility) {
|
||||
case 'public':
|
||||
return 0;
|
||||
case 'unlisted':
|
||||
return 1;
|
||||
case 'friends':
|
||||
return 2;
|
||||
case 'private':
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
270
lib/services/compose_storage.dart
Normal file
270
lib/services/compose_storage.dart
Normal file
@ -0,0 +1,270 @@
|
||||
import 'dart:convert';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
|
||||
part 'compose_storage.g.dart';
|
||||
|
||||
const kComposeDraftStoreKey = 'compose_drafts';
|
||||
const kArticleDraftStoreKey = 'article_drafts';
|
||||
|
||||
class ComposeDraft {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String content;
|
||||
final List<String> attachmentIds;
|
||||
final String visibility;
|
||||
final DateTime lastModified;
|
||||
|
||||
ComposeDraft({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.content,
|
||||
required this.attachmentIds,
|
||||
required this.visibility,
|
||||
required this.lastModified,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'content': content,
|
||||
'attachmentIds': attachmentIds,
|
||||
'visibility': visibility,
|
||||
'lastModified': lastModified.toIso8601String(),
|
||||
};
|
||||
|
||||
factory ComposeDraft.fromJson(Map<String, dynamic> json) => ComposeDraft(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String? ?? '',
|
||||
description: json['description'] as String? ?? '',
|
||||
content: json['content'] as String? ?? '',
|
||||
attachmentIds: List<String>.from(json['attachmentIds'] as List? ?? []),
|
||||
visibility: json['visibility'] as String? ?? 'public',
|
||||
lastModified: DateTime.parse(json['lastModified'] as String),
|
||||
);
|
||||
|
||||
ComposeDraft copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
String? content,
|
||||
List<String>? attachmentIds,
|
||||
String? visibility,
|
||||
DateTime? lastModified,
|
||||
}) {
|
||||
return ComposeDraft(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
content: content ?? this.content,
|
||||
attachmentIds: attachmentIds ?? this.attachmentIds,
|
||||
visibility: visibility ?? this.visibility,
|
||||
lastModified: lastModified ?? this.lastModified,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isEmpty =>
|
||||
title.isEmpty &&
|
||||
description.isEmpty &&
|
||||
content.isEmpty &&
|
||||
attachmentIds.isEmpty;
|
||||
}
|
||||
|
||||
class ArticleDraft {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String content;
|
||||
final String visibility;
|
||||
final DateTime lastModified;
|
||||
|
||||
ArticleDraft({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.content,
|
||||
required this.visibility,
|
||||
required this.lastModified,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'content': content,
|
||||
'visibility': visibility,
|
||||
'lastModified': lastModified.toIso8601String(),
|
||||
};
|
||||
|
||||
factory ArticleDraft.fromJson(Map<String, dynamic> json) => ArticleDraft(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String? ?? '',
|
||||
description: json['description'] as String? ?? '',
|
||||
content: json['content'] as String? ?? '',
|
||||
visibility: json['visibility'] as String? ?? 'public',
|
||||
lastModified: DateTime.parse(json['lastModified'] as String),
|
||||
);
|
||||
|
||||
ArticleDraft copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
String? content,
|
||||
String? visibility,
|
||||
DateTime? lastModified,
|
||||
}) {
|
||||
return ArticleDraft(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
content: content ?? this.content,
|
||||
visibility: visibility ?? this.visibility,
|
||||
lastModified: lastModified ?? this.lastModified,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isEmpty => title.isEmpty && description.isEmpty && content.isEmpty;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ComposeStorageNotifier extends _$ComposeStorageNotifier {
|
||||
@override
|
||||
Map<String, ComposeDraft> build() {
|
||||
_loadDrafts();
|
||||
return {};
|
||||
}
|
||||
|
||||
void _loadDrafts() {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
final draftsJson = prefs.getString(kComposeDraftStoreKey);
|
||||
if (draftsJson != null) {
|
||||
try {
|
||||
final Map<String, dynamic> draftsMap = jsonDecode(draftsJson);
|
||||
final drafts = <String, ComposeDraft>{};
|
||||
for (final entry in draftsMap.entries) {
|
||||
drafts[entry.key] = ComposeDraft.fromJson(entry.value);
|
||||
}
|
||||
state = drafts;
|
||||
} catch (e) {
|
||||
// If there's an error loading drafts, start with empty state
|
||||
state = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveDrafts() async {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
final draftsMap = <String, dynamic>{};
|
||||
for (final entry in state.entries) {
|
||||
draftsMap[entry.key] = entry.value.toJson();
|
||||
}
|
||||
await prefs.setString(kComposeDraftStoreKey, jsonEncode(draftsMap));
|
||||
}
|
||||
|
||||
Future<void> saveDraft(ComposeDraft draft) async {
|
||||
if (draft.isEmpty) {
|
||||
await deleteDraft(draft.id);
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedDraft = draft.copyWith(lastModified: DateTime.now());
|
||||
state = {...state, updatedDraft.id: updatedDraft};
|
||||
await _saveDrafts();
|
||||
}
|
||||
|
||||
Future<void> deleteDraft(String id) async {
|
||||
final newState = Map<String, ComposeDraft>.from(state);
|
||||
newState.remove(id);
|
||||
state = newState;
|
||||
await _saveDrafts();
|
||||
}
|
||||
|
||||
ComposeDraft? getDraft(String id) {
|
||||
return state[id];
|
||||
}
|
||||
|
||||
List<ComposeDraft> getAllDrafts() {
|
||||
final drafts = state.values.toList();
|
||||
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
||||
return drafts;
|
||||
}
|
||||
|
||||
Future<void> clearAllDrafts() async {
|
||||
state = {};
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
await prefs.remove(kComposeDraftStoreKey);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ArticleStorageNotifier extends _$ArticleStorageNotifier {
|
||||
@override
|
||||
Map<String, ArticleDraft> build() {
|
||||
_loadDrafts();
|
||||
return {};
|
||||
}
|
||||
|
||||
void _loadDrafts() {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
final draftsJson = prefs.getString(kArticleDraftStoreKey);
|
||||
if (draftsJson != null) {
|
||||
try {
|
||||
final Map<String, dynamic> draftsMap = jsonDecode(draftsJson);
|
||||
final drafts = <String, ArticleDraft>{};
|
||||
for (final entry in draftsMap.entries) {
|
||||
drafts[entry.key] = ArticleDraft.fromJson(entry.value);
|
||||
}
|
||||
state = drafts;
|
||||
} catch (e) {
|
||||
// If there's an error loading drafts, start with empty state
|
||||
state = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveDrafts() async {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
final draftsMap = <String, dynamic>{};
|
||||
for (final entry in state.entries) {
|
||||
draftsMap[entry.key] = entry.value.toJson();
|
||||
}
|
||||
await prefs.setString(kArticleDraftStoreKey, jsonEncode(draftsMap));
|
||||
}
|
||||
|
||||
Future<void> saveDraft(ArticleDraft draft) async {
|
||||
if (draft.isEmpty) {
|
||||
await deleteDraft(draft.id);
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedDraft = draft.copyWith(lastModified: DateTime.now());
|
||||
state = {...state, updatedDraft.id: updatedDraft};
|
||||
await _saveDrafts();
|
||||
}
|
||||
|
||||
Future<void> deleteDraft(String id) async {
|
||||
final newState = Map<String, ArticleDraft>.from(state);
|
||||
newState.remove(id);
|
||||
state = newState;
|
||||
await _saveDrafts();
|
||||
}
|
||||
|
||||
ArticleDraft? getDraft(String id) {
|
||||
return state[id];
|
||||
}
|
||||
|
||||
List<ArticleDraft> getAllDrafts() {
|
||||
final drafts = state.values.toList();
|
||||
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
||||
return drafts;
|
||||
}
|
||||
|
||||
Future<void> clearAllDrafts() async {
|
||||
state = {};
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
await prefs.remove(kArticleDraftStoreKey);
|
||||
}
|
||||
}
|
52
lib/services/compose_storage.g.dart
Normal file
52
lib/services/compose_storage.g.dart
Normal file
@ -0,0 +1,52 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'compose_storage.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$composeStorageNotifierHash() =>
|
||||
r'99c5e4070fa8af2771751064b56ca3251dbda27a';
|
||||
|
||||
/// See also [ComposeStorageNotifier].
|
||||
@ProviderFor(ComposeStorageNotifier)
|
||||
final composeStorageNotifierProvider = AutoDisposeNotifierProvider<
|
||||
ComposeStorageNotifier,
|
||||
Map<String, ComposeDraft>
|
||||
>.internal(
|
||||
ComposeStorageNotifier.new,
|
||||
name: r'composeStorageNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$composeStorageNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ComposeStorageNotifier =
|
||||
AutoDisposeNotifier<Map<String, ComposeDraft>>;
|
||||
String _$articleStorageNotifierHash() =>
|
||||
r'4a200878bfe7881fc3afd2164b334e84dc44f338';
|
||||
|
||||
/// See also [ArticleStorageNotifier].
|
||||
@ProviderFor(ArticleStorageNotifier)
|
||||
final articleStorageNotifierProvider = AutoDisposeNotifierProvider<
|
||||
ArticleStorageNotifier,
|
||||
Map<String, ArticleDraft>
|
||||
>.internal(
|
||||
ArticleStorageNotifier.new,
|
||||
name: r'articleStorageNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$articleStorageNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ArticleStorageNotifier =
|
||||
AutoDisposeNotifier<Map<String, ArticleDraft>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
@ -1,3 +1,5 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -9,8 +11,10 @@ import 'package:island/models/post.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/compose_storage.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'dart:async';
|
||||
|
||||
class ComposeState {
|
||||
final ValueNotifier<List<UniversalFile>> attachments;
|
||||
@ -21,6 +25,8 @@ class ComposeState {
|
||||
final ValueNotifier<bool> submitting;
|
||||
final ValueNotifier<Map<int, double>> attachmentProgress;
|
||||
final ValueNotifier<SnPublisher?> currentPublisher;
|
||||
final String draftId;
|
||||
Timer? _autoSaveTimer;
|
||||
|
||||
ComposeState({
|
||||
required this.attachments,
|
||||
@ -31,7 +37,20 @@ class ComposeState {
|
||||
required this.submitting,
|
||||
required this.attachmentProgress,
|
||||
required this.currentPublisher,
|
||||
required this.draftId,
|
||||
});
|
||||
|
||||
void startAutoSave(WidgetRef ref) {
|
||||
_autoSaveTimer?.cancel();
|
||||
_autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||||
ComposeLogic.saveDraft(ref, this);
|
||||
});
|
||||
}
|
||||
|
||||
void stopAutoSave() {
|
||||
_autoSaveTimer?.cancel();
|
||||
_autoSaveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
class ComposeLogic {
|
||||
@ -39,7 +58,10 @@ class ComposeLogic {
|
||||
SnPost? originalPost,
|
||||
SnPost? forwardedPost,
|
||||
SnPost? repliedPost,
|
||||
String? draftId,
|
||||
}) {
|
||||
final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
return ComposeState(
|
||||
attachments: ValueNotifier<List<UniversalFile>>(
|
||||
originalPost?.attachments
|
||||
@ -70,9 +92,106 @@ class ComposeLogic {
|
||||
submitting: ValueNotifier<bool>(false),
|
||||
attachmentProgress: ValueNotifier<Map<int, double>>({}),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(null),
|
||||
draftId: id,
|
||||
);
|
||||
}
|
||||
|
||||
static ComposeState createStateFromDraft(ComposeDraft draft) {
|
||||
return ComposeState(
|
||||
attachments: ValueNotifier<List<UniversalFile>>([]),
|
||||
titleController: TextEditingController(text: draft.title),
|
||||
descriptionController: TextEditingController(text: draft.description),
|
||||
contentController: TextEditingController(text: draft.content),
|
||||
visibility: ValueNotifier<int>(_parseVisibility(draft.visibility)),
|
||||
submitting: ValueNotifier<bool>(false),
|
||||
attachmentProgress: ValueNotifier<Map<int, double>>({}),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(null),
|
||||
draftId: draft.id,
|
||||
);
|
||||
}
|
||||
|
||||
static int _parseVisibility(String visibility) {
|
||||
switch (visibility.toLowerCase()) {
|
||||
case 'public':
|
||||
return 0;
|
||||
case 'unlisted':
|
||||
return 1;
|
||||
case 'friends':
|
||||
return 2;
|
||||
case 'selected':
|
||||
return 3;
|
||||
case 'private':
|
||||
return 4;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static String _visibilityToString(int visibility) {
|
||||
switch (visibility) {
|
||||
case 0:
|
||||
return 'public';
|
||||
case 1:
|
||||
return 'unlisted';
|
||||
case 2:
|
||||
return 'friends';
|
||||
case 3:
|
||||
return 'selected';
|
||||
case 4:
|
||||
return 'private';
|
||||
default:
|
||||
return 'public';
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> saveDraft(WidgetRef ref, ComposeState state) async {
|
||||
try {
|
||||
// Check if the auto-save timer is still active (widget not disposed)
|
||||
if (state._autoSaveTimer == null) {
|
||||
return; // Widget has been disposed, don't save
|
||||
}
|
||||
|
||||
final draft = ComposeDraft(
|
||||
id: state.draftId,
|
||||
title: state.titleController.text,
|
||||
description: state.descriptionController.text,
|
||||
content: state.contentController.text,
|
||||
attachmentIds:
|
||||
state.attachments.value
|
||||
.where((e) => e.isOnCloud)
|
||||
.map((e) => e.data.id.toString())
|
||||
.toList(),
|
||||
visibility: _visibilityToString(state.visibility.value),
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
|
||||
await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
|
||||
} catch (e) {
|
||||
log('[ComposeLogic] Failed to save draft, error: $e');
|
||||
// Silently fail for auto-save to avoid disrupting user experience
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> deleteDraft(WidgetRef ref, String draftId) async {
|
||||
try {
|
||||
await ref
|
||||
.read(composeStorageNotifierProvider.notifier)
|
||||
.deleteDraft(draftId);
|
||||
} catch (e) {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
static Future<ComposeDraft?> loadDraft(WidgetRef ref, String draftId) async {
|
||||
try {
|
||||
return ref
|
||||
.read(composeStorageNotifierProvider.notifier)
|
||||
.getDraft(draftId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String getMimeTypeFromFileType(UniversalFileType type) {
|
||||
return switch (type) {
|
||||
UniversalFileType.image => 'image/unknown',
|
||||
@ -242,6 +361,19 @@ class ComposeLogic {
|
||||
),
|
||||
);
|
||||
|
||||
// Delete draft after successful submission
|
||||
if (postType == 1) {
|
||||
// Delete article draft
|
||||
await ref
|
||||
.read(articleStorageNotifierProvider.notifier)
|
||||
.deleteDraft(state.draftId);
|
||||
} else {
|
||||
// Delete regular post draft
|
||||
await ref
|
||||
.read(composeStorageNotifierProvider.notifier)
|
||||
.deleteDraft(state.draftId);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).maybePop(true);
|
||||
}
|
||||
@ -297,6 +429,7 @@ class ComposeLogic {
|
||||
}
|
||||
|
||||
static void dispose(ComposeState state) {
|
||||
state.stopAutoSave();
|
||||
state.titleController.dispose();
|
||||
state.descriptionController.dispose();
|
||||
state.contentController.dispose();
|
||||
|
335
lib/widgets/post/draft_manager.dart
Normal file
335
lib/widgets/post/draft_manager.dart
Normal file
@ -0,0 +1,335 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/services/compose_storage.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class DraftManagerSheet extends HookConsumerWidget {
|
||||
final bool isArticle;
|
||||
final Function(String draftId)? onDraftSelected;
|
||||
|
||||
const DraftManagerSheet({
|
||||
super.key,
|
||||
this.isArticle = false,
|
||||
this.onDraftSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final drafts =
|
||||
isArticle
|
||||
? ref.watch(articleStorageNotifierProvider)
|
||||
: ref.watch(composeStorageNotifierProvider);
|
||||
|
||||
final sortedDrafts = useMemoized(() {
|
||||
if (isArticle) {
|
||||
final draftList = drafts.values.cast<ArticleDraft>().toList();
|
||||
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
||||
return draftList;
|
||||
} else {
|
||||
final draftList = drafts.values.cast<ComposeDraft>().toList();
|
||||
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
||||
return draftList;
|
||||
}
|
||||
}, [drafts]);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(isArticle ? 'articleDrafts'.tr() : 'postDrafts'.tr()),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (sortedDrafts.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.draft,
|
||||
size: 64,
|
||||
color: colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'noDrafts'.tr(),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: sortedDrafts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final draft = sortedDrafts[index];
|
||||
return _DraftItem(
|
||||
draft: draft,
|
||||
isArticle: isArticle,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
final draftId =
|
||||
isArticle
|
||||
? (draft as ArticleDraft).id
|
||||
: (draft as ComposeDraft).id;
|
||||
onDraftSelected?.call(draftId);
|
||||
},
|
||||
onDelete: () async {
|
||||
final draftId =
|
||||
isArticle
|
||||
? (draft as ArticleDraft).id
|
||||
: (draft as ComposeDraft).id;
|
||||
if (isArticle) {
|
||||
await ref
|
||||
.read(articleStorageNotifierProvider.notifier)
|
||||
.deleteDraft(draftId);
|
||||
} else {
|
||||
await ref
|
||||
.read(composeStorageNotifierProvider.notifier)
|
||||
.deleteDraft(draftId);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (sortedDrafts.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text('clearAllDrafts'.tr()),
|
||||
content: Text('clearAllDraftsConfirm'.tr()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
() => Navigator.of(context).pop(false),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() => Navigator.of(context).pop(true),
|
||||
child: Text('confirm'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
if (isArticle) {
|
||||
await ref
|
||||
.read(articleStorageNotifierProvider.notifier)
|
||||
.clearAllDrafts();
|
||||
} else {
|
||||
await ref
|
||||
.read(composeStorageNotifierProvider.notifier)
|
||||
.clearAllDrafts();
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Symbols.delete_sweep),
|
||||
label: Text('clearAll'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DraftItem extends StatelessWidget {
|
||||
final dynamic draft; // ComposeDraft or ArticleDraft
|
||||
final bool isArticle;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const _DraftItem({
|
||||
required this.draft,
|
||||
required this.isArticle,
|
||||
this.onTap,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final String title;
|
||||
final String content;
|
||||
final DateTime lastModified;
|
||||
final String visibility;
|
||||
|
||||
if (isArticle) {
|
||||
final articleDraft = draft as ArticleDraft;
|
||||
title =
|
||||
articleDraft.title.isNotEmpty ? articleDraft.title : 'untitled'.tr();
|
||||
content =
|
||||
articleDraft.content.isNotEmpty
|
||||
? articleDraft.content
|
||||
: (articleDraft.description.isNotEmpty
|
||||
? articleDraft.description
|
||||
: 'noContent'.tr());
|
||||
lastModified = articleDraft.lastModified;
|
||||
visibility = _parseArticleVisibility(articleDraft.visibility);
|
||||
} else {
|
||||
final postDraft = draft as ComposeDraft;
|
||||
title = postDraft.title.isNotEmpty ? postDraft.title : 'untitled'.tr();
|
||||
content =
|
||||
postDraft.content.isNotEmpty
|
||||
? postDraft.content
|
||||
: (postDraft.description.isNotEmpty
|
||||
? postDraft.description
|
||||
: 'noContent'.tr());
|
||||
lastModified = postDraft.lastModified;
|
||||
visibility = postDraft.visibility;
|
||||
}
|
||||
|
||||
final preview =
|
||||
content.length > 100 ? '${content.substring(0, 100)}...' : content;
|
||||
final timeAgo = _formatTimeAgo(lastModified);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isArticle ? Symbols.article : Symbols.post_add,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Symbols.delete),
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (preview.isNotEmpty) ...[
|
||||
const Gap(8),
|
||||
Text(
|
||||
preview,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.schedule,
|
||||
size: 16,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
timeAgo,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
visibility,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTimeAgo(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'justNow'.tr();
|
||||
} else if (difference.inHours < 1) {
|
||||
return 'minutesAgo'.tr(args: [difference.inMinutes.toString()]);
|
||||
} else if (difference.inDays < 1) {
|
||||
return 'hoursAgo'.tr(args: [difference.inHours.toString()]);
|
||||
} else if (difference.inDays < 7) {
|
||||
return 'daysAgo'.tr(args: [difference.inDays.toString()]);
|
||||
} else {
|
||||
return DateFormat('MMM dd, yyyy').format(dateTime);
|
||||
}
|
||||
}
|
||||
|
||||
String _parseArticleVisibility(String visibility) {
|
||||
switch (visibility.toLowerCase()) {
|
||||
case 'public':
|
||||
return 'public'.tr();
|
||||
case 'unlisted':
|
||||
return 'unlisted'.tr();
|
||||
case 'friends':
|
||||
return 'friends'.tr();
|
||||
case 'selected':
|
||||
return 'selected'.tr();
|
||||
case 'private':
|
||||
return 'private'.tr();
|
||||
default:
|
||||
return 'unknown'.tr();
|
||||
}
|
||||
}
|
||||
}
|
@ -212,7 +212,9 @@ class PostItem extends HookConsumerWidget {
|
||||
MarkdownTextContent(
|
||||
content: item.content!,
|
||||
linesMargin:
|
||||
item.type == 0 ? EdgeInsets.zero : null,
|
||||
item.type == 0
|
||||
? EdgeInsets.only(bottom: 4)
|
||||
: null,
|
||||
),
|
||||
// Show truncation hint if post is truncated
|
||||
if (item.isTruncated && !isFullPost)
|
||||
@ -415,6 +417,10 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
|
||||
content: referencePost.content!,
|
||||
textStyle: const TextStyle(fontSize: 14),
|
||||
isSelectable: false,
|
||||
linesMargin:
|
||||
referencePost.type == 0
|
||||
? EdgeInsets.only(bottom: 4)
|
||||
: null,
|
||||
).padding(bottom: 4),
|
||||
// Truncation hint for referenced post
|
||||
if (referencePost.isTruncated)
|
||||
|
Loading…
x
Reference in New Issue
Block a user