Skip to content

Commit

Permalink
Migrate drafts from SharedPreferences to local database (#1455)
Browse files Browse the repository at this point in the history
* Store drafts in database

* Fix tests
  • Loading branch information
micahmo authored Jun 23, 2024
1 parent c9e5fa7 commit 7532804
Show file tree
Hide file tree
Showing 10 changed files with 704 additions and 98 deletions.
144 changes: 144 additions & 0 deletions lib/account/models/draft.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:thunder/core/database/database.dart';
import 'package:thunder/core/database/type_converters.dart';
import 'package:thunder/drafts/draft_type.dart';
import 'package:thunder/main.dart';

class Draft {
/// The database identifier for this object
final String id;

/// The type of draft
final DraftType draftType;

/// Existing id, if we're editing
final int? existingId;

/// The community/post/comment we're replying to
final int? replyId;

/// The title of the post
final String? title;

/// The URL of the post
final String? url;

/// The body of the post/comment
final String? body;

const Draft({
required this.id,
required this.draftType,
this.existingId,
this.replyId,
this.title,
this.url,
this.body,
});

Draft copyWith({
String? id,
DraftType? draftType,
int? existingId,
int? replyId,
String? title,
String? url,
String? body,
}) =>
Draft(
id: id ?? this.id,
draftType: draftType ?? this.draftType,
existingId: existingId ?? this.existingId,
replyId: replyId ?? this.replyId,
title: title ?? this.title,
url: url ?? this.url,
body: body ?? this.body,
);

/// See whether this draft contains enough info to save for a post
bool get isPostNotEmpty => title?.isNotEmpty == true || url?.isNotEmpty == true || body?.isNotEmpty == true;

/// See whether this draft contains enough info to save for a comment
bool get isCommentNotEmpty => body?.isNotEmpty == true;

/// Create or update a draft in the db
static Future<Draft?> upsertDraft(Draft draft) async {
try {
final existingDraft = await (database.select(database.drafts)
..where((t) => t.draftType.equals(const DraftTypeConverter().toSql(draft.draftType)))
..where((t) => draft.existingId == null ? t.existingId.isNull() : t.existingId.equals(draft.existingId!))
..where((t) => draft.replyId == null ? t.replyId.isNull() : t.replyId.equals(draft.replyId!)))
.getSingleOrNull();

if (existingDraft == null) {
final id = await database.into(database.drafts).insert(
DraftsCompanion.insert(
draftType: draft.draftType,
existingId: Value(draft.existingId),
replyId: Value(draft.replyId),
title: Value(draft.title),
url: Value(draft.url),
body: Value(draft.body),
),
);
return draft.copyWith(id: id.toString());
} else {
await database.update(database.drafts).replace(
DraftsCompanion(
id: Value(existingDraft.id),
draftType: Value(draft.draftType),
existingId: Value(draft.existingId),
replyId: Value(draft.replyId),
title: Value(draft.title),
url: Value(draft.url),
body: Value(draft.body),
),
);
return draft.copyWith(id: existingDraft.id.toString());
}
} catch (e) {
debugPrint(e.toString());
return null;
}
}

/// Retrieve a draft from the db
static Future<Draft?> fetchDraft(DraftType draftType, int? existingId, int? replyId) async {
try {
final draft = await (database.select(database.drafts)
..where((t) => t.draftType.equals(const DraftTypeConverter().toSql(draftType)))
..where((t) => existingId == null ? t.existingId.isNull() : t.existingId.equals(existingId))
..where((t) => replyId == null ? t.replyId.isNull() : t.replyId.equals(replyId)))
.getSingleOrNull();

if (draft == null) return null;

return Draft(
id: draft.id.toString(),
draftType: draft.draftType,
existingId: draft.existingId,
replyId: draft.replyId,
title: draft.title,
url: draft.url,
body: draft.body,
);
} catch (e) {
debugPrint(e.toString());
return null;
}
}

/// Delete a draft from the db
static Future<void> deleteDraft(DraftType draftType, int? existingId, int? replyId) async {
try {
await (database.delete(database.drafts)
..where((t) => t.draftType.equals(const DraftTypeConverter().toSql(draftType)))
..where((t) => existingId == null ? t.existingId.isNull() : t.existingId.equals(existingId))
..where((t) => replyId == null ? t.replyId.isNull() : t.replyId.equals(replyId)))
.go();
} catch (e) {
debugPrint(e.toString());
}
}
}
100 changes: 60 additions & 40 deletions lib/comment/view/create_comment_page.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// Dart imports
import 'dart:async';
import 'dart:convert';

// Flutter imports
import 'package:flutter/material.dart';
Expand All @@ -11,16 +10,15 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:markdown_editor/markdown_editor.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:thunder/account/models/account.dart';
import 'package:thunder/account/models/draft.dart';

// Project imports
import 'package:thunder/comment/cubit/create_comment_cubit.dart';
import 'package:thunder/community/utils/post_card_action_helpers.dart';
import 'package:thunder/core/auth/bloc/auth_bloc.dart';
import 'package:thunder/core/enums/local_settings.dart';
import 'package:thunder/core/models/post_view_media.dart';
import 'package:thunder/core/singletons/preferences.dart';
import 'package:thunder/drafts/draft_type.dart';
import 'package:thunder/post/widgets/post_view.dart';
import 'package:thunder/shared/comment_content.dart';
import 'package:thunder/shared/common_markdown_body.dart';
Expand Down Expand Up @@ -60,17 +58,21 @@ class CreateCommentPage extends StatefulWidget {
}

class _CreateCommentPageState extends State<CreateCommentPage> {
/// Holds the draft id associated with the comment. This id is determined by the input parameters passed in.
/// If [commentView] is passed in, the id will be in the form 'drafts_cache-comment-edit-{commentView.comment.id}'
/// If [postViewMedia] is passed in, the id will be in the form 'drafts_cache-comment-create-{postViewMedia.postView.post.id}'
/// If [parentCommentView] is passed in, the id will be in the form 'drafts_cache-comment-create-{parentCommentView.comment.id}'
/// If none of these are passed in, the id will be in the form 'drafts_cache-comment-create-general'
String draftId = '';
/// Holds the draft type associated with the comment. This type is determined by the input parameters passed in.
/// If [commentView], it will be [DraftType.commentEdit].
/// If [postViewMedia] or [parentCommentView] is passed in, it will be [DraftType.commentCreate].
late DraftType draftType;

/// Holds the current draft for the comment.
DraftComment draftComment = DraftComment();
/// The ID of the comment we are editing, to find a corresponding draft, if any
int? draftExistingId;

/// Timer for saving the current draft to local storage
/// The ID of the post or comment we're replying to, to find a corresponding draft, if any
int? draftReplyId;

/// Whether to save this comment as a draft
bool saveDraft = true;

/// Timer for saving the current draft
Timer? _draftTimer;

/// Whether or not to show the preview for the comment from the raw markdown
Expand All @@ -94,9 +96,6 @@ class _CreateCommentPageState extends State<CreateCommentPage> {
/// The keyboard visibility controller used to determine if the keyboard is visible at a given time
final keyboardVisibilityController = KeyboardVisibilityController();

/// Used for restoring and saving drafts
SharedPreferences? sharedPreferences;

/// Whether to view source for posts or comments
bool viewSource = false;

Expand All @@ -120,16 +119,13 @@ class _CreateCommentPageState extends State<CreateCommentPage> {
parentCommentId = widget.parentCommentView?.comment.id;

_bodyTextController.addListener(() {
draftComment.text = _bodyTextController.text;
_validateSubmission();
});

// Logic for pre-populating the comment with the [postView] for edits
if (widget.commentView != null) {
_bodyTextController.text = widget.commentView!.comment.content;
languageId = widget.commentView!.comment.languageId;

return;
}

// Finally, if there is no pre-populated fields, then we retrieve the most recent draft
Expand All @@ -152,63 +148,86 @@ class _CreateCommentPageState extends State<CreateCommentPage> {

_draftTimer?.cancel();

if (draftComment.isNotEmpty && draftComment.saveAsDraft) {
sharedPreferences?.setString(draftId, jsonEncode(draftComment.toJson()));
Draft draft = _generateDraft();

if (draft.isCommentNotEmpty && saveDraft && _draftDiffersFromEdit(draft)) {
Draft.upsertDraft(draft);
showSnackbar(l10n.commentSavedAsDraft);
} else {
sharedPreferences?.remove(draftId);
Draft.deleteDraft(draftType, draftExistingId, draftReplyId);
}

super.dispose();
}

/// Attempts to restore an existing draft of a comment
void _restoreExistingDraft() async {
sharedPreferences = (await UserPreferences.instance).sharedPreferences;

if (widget.commentView != null) {
draftId = '${LocalSettings.draftsCache.name}-comment-edit-${widget.commentView!.comment.id}';
draftType = DraftType.commentEdit;
draftExistingId = widget.commentView!.comment.id;
} else if (widget.postViewMedia != null) {
draftId = '${LocalSettings.draftsCache.name}-comment-create-${widget.postViewMedia!.postView.post.id}';
draftType = DraftType.commentCreate;
draftReplyId = widget.postViewMedia!.postView.post.id;
} else if (widget.parentCommentView != null) {
draftId = '${LocalSettings.draftsCache.name}-comment-create-${widget.parentCommentView!.comment.id}';
draftType = DraftType.commentCreate;
draftReplyId = widget.parentCommentView!.comment.id;
} else {
draftId = '${LocalSettings.draftsCache.name}-comment-create-general';
// Should never come here.
return;
}

String? draftCommentJson = sharedPreferences?.getString(draftId);
Draft? draft = await Draft.fetchDraft(draftType, draftExistingId, draftReplyId);

if (draftCommentJson != null) {
draftComment = DraftComment.fromJson(jsonDecode(draftCommentJson));

_bodyTextController.text = draftComment.text ?? '';
if (draft != null) {
_bodyTextController.text = draft.body ?? '';
}

_draftTimer = Timer.periodic(const Duration(seconds: 10), (Timer t) {
if (draftComment.isNotEmpty && draftComment.saveAsDraft) {
sharedPreferences?.setString(draftId, jsonEncode(draftComment.toJson()));
Draft draft = _generateDraft();
if (draft.isCommentNotEmpty && saveDraft && _draftDiffersFromEdit(draft)) {
Draft.upsertDraft(draft);
} else {
sharedPreferences?.remove(draftId);
Draft.deleteDraft(draftType, draftExistingId, draftReplyId);
}
});

if (context.mounted && draftComment.isNotEmpty) {
if (context.mounted && draft?.isCommentNotEmpty == true) {
// We need to wait until the keyboard is visible before showing the snackbar
Future.delayed(const Duration(milliseconds: 1000), () {
showSnackbar(
AppLocalizations.of(context)!.restoredCommentFromDraft,
trailingIcon: Icons.delete_forever_rounded,
trailingIconColor: Theme.of(context).colorScheme.errorContainer,
trailingAction: () {
sharedPreferences?.remove(draftId);
_bodyTextController.clear();
Draft.deleteDraft(draftType, draftExistingId, draftReplyId);
_bodyTextController.text = widget.commentView?.comment.content ?? '';
},
closable: true,
);
});
}
}

Draft _generateDraft() {
return Draft(
id: '',
draftType: draftType,
existingId: draftExistingId,
replyId: draftReplyId,
body: _bodyTextController.text,
);
}

/// Checks whether we are potentially saving a draft of an edit and, if so,
/// whether the draft contains different contents from the edit
bool _draftDiffersFromEdit(Draft draft) {
if (widget.commentView == null) {
return true;
}

return draft.body != widget.commentView!.comment.content;
}

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
Expand Down Expand Up @@ -264,7 +283,7 @@ class _CreateCommentPageState extends State<CreateCommentPage> {
onPressed: isSubmitButtonDisabled
? null
: () {
draftComment.saveAsDraft = false;
saveDraft = false;

context.read<CreateCommentCubit>().createOrEditComment(
postId: postId,
Expand Down Expand Up @@ -507,6 +526,7 @@ class _CreateCommentPageState extends State<CreateCommentPage> {
}
}

@Deprecated('Use Draft model through database instead')
class DraftComment {
String? text;
bool saveAsDraft = true;
Expand Down
Loading

0 comments on commit 7532804

Please sign in to comment.