diff --git a/build.yaml b/build.yaml index 3640e1c3d..9e5aa741a 100644 --- a/build.yaml +++ b/build.yaml @@ -4,5 +4,5 @@ targets: drift_dev: options: databases: - thunder: lib/src/core/database/database.dart - schema_dir: lib/src/core/database/schemas/ \ No newline at end of file + thunder: lib/src/foundation/persistence/database/database.dart + schema_dir: lib/src/foundation/persistence/database/schemas/ diff --git a/lib/src/app/bootstrap/bootstrap.dart b/lib/src/app/bootstrap/bootstrap.dart index f6fb4a74a..f5bd9d231 100644 --- a/lib/src/app/bootstrap/bootstrap.dart +++ b/lib/src/app/bootstrap/bootstrap.dart @@ -31,10 +31,10 @@ Future bootstrap() async { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); // Initialize preferences and database - await UserPreferences.instance.initialize(); - await performSharedPreferencesMigration(); initializeDatabase(); await performDatabaseIntegrityChecks(); + await UserPreferences.instance.initialize(); + await performSharedPreferencesMigration(); final account = await fetchActiveProfile(); diff --git a/lib/src/app/bootstrap/preferences_migration.dart b/lib/src/app/bootstrap/preferences_migration.dart index e33fdf278..6147f11a8 100644 --- a/lib/src/app/bootstrap/preferences_migration.dart +++ b/lib/src/app/bootstrap/preferences_migration.dart @@ -14,6 +14,7 @@ import 'package:thunder/packages/ui/ui.dart' show NameColor; /// Performs migrations for shared preferences. Future performSharedPreferencesMigration() async { final prefs = UserPreferences.instance.preferences; + final draftRepository = DraftRepositoryImpl(database: database); // Migrate the openInExternalBrowser setting, if found. bool? legacyOpenInExternalBrowser = prefs.getBool(LocalSettings.openLinksInExternalBrowser.name); @@ -91,7 +92,7 @@ Future performSharedPreferencesMigration() async { body: (draftPost?['text'] ?? draftComment?['text']) as String?, ); - Draft.upsertDraft(draft); + await draftRepository.upsertDraft(draft); // If we've gotten this far without exception, it's safe to delete the shared pref eky prefs.remove(draftKey); diff --git a/lib/src/app/shell/navigation/navigation_post.dart b/lib/src/app/shell/navigation/navigation_post.dart index c26adfcc3..cd4c62ff2 100644 --- a/lib/src/app/shell/navigation/navigation_post.dart +++ b/lib/src/app/shell/navigation/navigation_post.dart @@ -152,6 +152,7 @@ Future navigateToComment(BuildContext context, ThunderComment comment) asy Future navigateToCreateCommentPage( BuildContext context, { + Account? account, ThunderPost? post, ThunderComment? comment, ThunderComment? parentComment, @@ -183,6 +184,7 @@ Future navigateToCreateCommentPage( BlocProvider.value(value: profileBloc), ], child: CreateCommentPage( + account: account ?? profileBloc.state.account, post: post, comment: comment, parentComment: parentComment, @@ -198,6 +200,7 @@ Future navigateToCreateCommentPage( Future navigateToCreatePostPage( BuildContext context, { + Account? account, String? title, String? text, File? image, @@ -211,13 +214,13 @@ Future navigateToCreatePostPage( }) async { try { final l10n = AppLocalizations.of(context)!; - final account = context.read().state.account; + final effectiveAccount = account ?? context.read().state.account; FeedBloc? feedBloc; PostBloc? postBloc; ThunderBloc thunderBloc = context.read(); ProfileBloc profileBloc = context.read(); - CreatePostCubit createPostCubit = createCreatePostCubit(account); + CreatePostCubit createPostCubit = createCreatePostCubit(effectiveAccount); final themeCubit = context.read(); final bool reduceAnimations = themeCubit.state.reduceAnimations; @@ -250,13 +253,14 @@ Future navigateToCreatePostPage( builder: (navigatorContext) { return MultiBlocProvider( providers: [ - feedBloc != null ? BlocProvider.value(value: feedBloc) : BlocProvider(create: (context) => createFeedBloc(account)), + feedBloc != null ? BlocProvider.value(value: feedBloc) : BlocProvider(create: (context) => createFeedBloc(effectiveAccount)), if (postBloc != null) BlocProvider.value(value: postBloc), BlocProvider.value(value: thunderBloc), BlocProvider.value(value: profileBloc), BlocProvider.value(value: createPostCubit), ], child: CreatePostPage( + account: effectiveAccount, title: title, text: text, image: image, diff --git a/lib/src/app/shell/pages/thunder_page.dart b/lib/src/app/shell/pages/thunder_page.dart index b72506686..5d827f1b6 100644 --- a/lib/src/app/shell/pages/thunder_page.dart +++ b/lib/src/app/shell/pages/thunder_page.dart @@ -30,6 +30,7 @@ import 'package:thunder/src/features/notification/application/state/notification import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/drafts/drafts.dart'; import 'package:thunder/src/app/shell/widgets/bottom_nav_bar.dart'; import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; @@ -57,6 +58,7 @@ class _ThunderState extends State { bool hasShownUpdateDialog = false; bool hasShownChangelogDialog = false; bool hasShownPageView = false; + bool hasAttemptedDraftRestore = false; bool _isFabOpen = false; @@ -70,6 +72,8 @@ class _ThunderState extends State { bool errorMessageLoading = false; + final DraftRepository _draftRepository = DraftRepositoryImpl(database: database); + @override void initState() { super.initState(); @@ -150,6 +154,68 @@ class _ThunderState extends State { } } + void _restoreDraftSession(ProfileState profileState) { + if (hasAttemptedDraftRestore) { + return; + } + + hasAttemptedDraftRestore = true; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.delayed(const Duration(milliseconds: 400)); + + final launchedFromExternalIntent = currentIntent == ANDROID_INTENT_ACTION_VIEW || currentIntent == 'android.intent.action.SEND' || currentIntent == 'android.intent.action.SEND_MULTIPLE'; + + if (!mounted || launchedFromExternalIntent) { + return; + } + + if (Navigator.of(context).canPop()) { + return; + } + + await restoreActiveDraftSession( + repository: _draftRepository, + account: profileState.account, + onPostCreateRestore: (account, communityId, community) async { + if (!mounted || Navigator.of(context).canPop()) { + return; + } + + await navigateToCreatePostPage(context, account: account, communityId: communityId, community: community); + }, + onPostEditRestore: (account, post) async { + if (!mounted || Navigator.of(context).canPop()) { + return; + } + + await navigateToCreatePostPage(context, account: account, post: post); + }, + onCommentCreateFromPostRestore: (account, post) async { + if (!mounted || Navigator.of(context).canPop()) { + return; + } + + await navigateToCreateCommentPage(context, account: account, post: post); + }, + onCommentCreateFromCommentRestore: (account, comment) async { + if (!mounted || Navigator.of(context).canPop()) { + return; + } + + await navigateToCreateCommentPage(context, account: account, parentComment: comment); + }, + onCommentEditRestore: (account, comment) async { + if (!mounted || Navigator.of(context).canPop()) { + return; + } + + await navigateToCreateCommentPage(context, account: account, comment: comment); + }, + ); + }); + } + FutureOr _handleBackButtonPress(bool stopDefaultButtonEvent, RouteInfo info) async { final bool topOfNavigationStack = ModalRoute.of(context)?.isCurrent ?? false; @@ -370,6 +436,8 @@ class _ThunderState extends State { ); case ProfileStatus.contentWarning: case ProfileStatus.success: + _restoreDraftSession(state); + Version? version = thunderBlocState.version; bool showInAppUpdateNotification = thunderBlocState.showInAppUpdateNotification; diff --git a/lib/src/features/comment/presentation/pages/create_comment_page.dart b/lib/src/features/comment/presentation/pages/create_comment_page.dart index 421a24ba8..7efbb3803 100644 --- a/lib/src/features/comment/presentation/pages/create_comment_page.dart +++ b/lib/src/features/comment/presentation/pages/create_comment_page.dart @@ -12,6 +12,7 @@ import 'package:markdown_editor/markdown_editor.dart'; // Project imports import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/app/wiring/state_factories.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/drafts/drafts.dart'; import 'package:thunder/src/features/comment/comment.dart'; @@ -29,6 +30,9 @@ import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.d import 'package:thunder/packages/ui/ui.dart' show selectImagesToUpload, showSnackbar; class CreateCommentPage extends StatefulWidget { + /// The account to use for composing this comment. + final Account? account; + /// [post] is passed in when replying to a post. [comment] and [parentComment] must be null if this is passed in. /// When this is passed in, a post preview will be shown. final ThunderPost? post; @@ -44,6 +48,7 @@ class CreateCommentPage extends StatefulWidget { const CreateCommentPage({ super.key, + this.account, this.post, this.comment, this.parentComment, @@ -54,29 +59,20 @@ class CreateCommentPage extends StatefulWidget { State createState() => _CreateCommentPageState(); } -class _CreateCommentPageState extends State { +class _CreateCommentPageState extends State with WidgetsBindingObserver { /// The current account Account? account; /// The account's user information ThunderUser? user; - /// Holds the draft type associated with the comment. This type is determined by the input parameters passed in. - /// If [comment], it will be [DraftType.commentEdit]. - /// If [post] or [parentComment] is passed in, it will be [DraftType.commentCreate]. - late DraftType draftType; - - /// The ID of the comment we are editing, to find a corresponding draft, if any - int? draftExistingId; - - /// The ID of the post or comment we're replying to, to find a corresponding draft, if any - int? draftReplyId; + final DraftRepository _draftRepository = DraftRepositoryImpl(database: database); /// Whether to save this comment as a draft bool saveDraft = true; - /// Timer for saving the current draft - Timer? _draftTimer; + /// Debounces writes while the user is typing + Timer? _draftDebounceTimer; /// Whether or not to show the preview for the comment from the raw markdown bool showPreview = false; @@ -118,13 +114,16 @@ class _CreateCommentPageState extends State { void initState() { super.initState(); - account = context.read().state.account; + WidgetsBinding.instance.addObserver(this); + + account = widget.account ?? context.read().state.account; postId = widget.post?.id ?? widget.parentComment?.postId; parentCommentId = widget.parentComment?.id; _bodyTextController.addListener(() { _validateSubmission(); + _onDraftInputChanged(); }); // Logic for pre-populating the comment with the [post] for edits @@ -146,58 +145,44 @@ class _CreateCommentPageState extends State { @override void dispose() { - _bodyTextController.dispose(); - _bodyFocusNode.dispose(); + WidgetsBinding.instance.removeObserver(this); FocusManager.instance.primaryFocus?.unfocus(); - _draftTimer?.cancel(); - - Draft draft = _generateDraft(); + _draftDebounceTimer?.cancel(); - if (draft.isCommentNotEmpty && saveDraft && _draftDiffersFromEdit(draft)) { - final l10n = GlobalContext.l10n; + _persistOrDeleteDraft(showSaveDraftSnackbar: true); + unawaited(_draftRepository.clearActiveDraft()); - Draft.upsertDraft(draft); - showSnackbar(l10n.commentSavedAsDraft); - } else { - Draft.deleteDraft(draftType, draftExistingId, draftReplyId); - } + _bodyTextController.dispose(); + _bodyFocusNode.dispose(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.inactive || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { + _persistOrDeleteDraft(); + } + } + /// Attempts to restore an existing draft of a comment void _restoreExistingDraft() async { - if (widget.comment != null) { - draftType = DraftType.commentEdit; - draftExistingId = widget.comment!.id; - } else if (widget.post != null) { - draftType = DraftType.commentCreate; - draftReplyId = widget.post!.id; - } else if (widget.parentComment != null) { - draftType = DraftType.commentCreate; - draftReplyId = widget.parentComment!.id; - } else { - // Should never come here. - return; - } + final draftContext = _draftContext; - Draft? draft = await Draft.fetchDraft(draftType, draftExistingId, draftReplyId); + final draft = await restoreDraft( + repository: _draftRepository, + context: draftContext, + ); if (draft != null) { _bodyTextController.text = draft.body ?? ''; + setState(() { + languageId = draft.languageId; + }); } - _draftTimer = Timer.periodic(const Duration(seconds: 10), (Timer t) { - Draft draft = _generateDraft(); - if (draft.isCommentNotEmpty && saveDraft && _draftDiffersFromEdit(draft)) { - Draft.upsertDraft(draft); - } else { - Draft.deleteDraft(draftType, draftExistingId, draftReplyId); - } - }); - 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), () { @@ -206,8 +191,11 @@ class _CreateCommentPageState extends State { trailingIcon: Icons.delete_forever_rounded, trailingIconColor: Theme.of(context).colorScheme.errorContainer, trailingAction: () { - Draft.deleteDraft(draftType, draftExistingId, draftReplyId); + unawaited(_draftRepository.deleteDraft(draftContext.draftType, draftContext.existingId, draftContext.replyId)); _bodyTextController.text = widget.comment?.content ?? ''; + setState(() { + languageId = widget.comment?.languageId; + }); }, closable: true, ); @@ -215,24 +203,41 @@ class _CreateCommentPageState extends State { } } - Draft _generateDraft() { - return Draft( - id: '', - draftType: draftType, - existingId: draftExistingId, - replyId: draftReplyId, - body: _bodyTextController.text, - ); + DraftContext get _draftContext => resolveCommentDraftContext( + editingCommentId: widget.comment?.id, + postId: postId, + parentCommentId: parentCommentId, + ); + + Draft _buildDraft() => buildCommentDraft( + context: _draftContext, + accountId: account?.id, + languageId: languageId, + body: _bodyTextController.text, + ); + + void _onDraftInputChanged() { + _draftDebounceTimer?.cancel(); + _draftDebounceTimer = Timer(const Duration(milliseconds: 800), _persistOrDeleteDraft); } - /// 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.comment == null) { - return true; - } - - return draft.body != widget.comment!.content; + void _persistOrDeleteDraft({bool showSaveDraftSnackbar = false}) { + final draft = _buildDraft(); + + unawaited( + persistDraft( + repository: _draftRepository, + context: _draftContext, + draft: draft, + save: saveDraft, + differsFromEdit: commentDraftDiffersFromEdit(draft, widget.comment), + hasContent: draft.isCommentNotEmpty, + ).then((result) { + if (showSaveDraftSnackbar && result == DraftPersistenceResult.saved) { + showSnackbar(GlobalContext.l10n.commentSavedAsDraft); + } + }), + ); } @override @@ -339,11 +344,15 @@ class _CreateCommentPageState extends State { child: UserSelector( account: account!, postActorId: widget.post?.apId, - onPostChanged: (ThunderPost post) => postId = post.id, + onPostChanged: (ThunderPost post) { + postId = post.id; + _onDraftInputChanged(); + }, parentCommentActorId: widget.parentComment?.apId, onParentCommentChanged: (ThunderComment parentComment) { postId = parentComment.postId; parentCommentId = parentComment.id; + _onDraftInputChanged(); }, onUserChanged: (account) { setState(() { @@ -352,6 +361,7 @@ class _CreateCommentPageState extends State { }); context.read().switchAccount(account); + _onDraftInputChanged(); }, enableAccountSwitching: widget.comment == null, ), @@ -363,6 +373,7 @@ class _CreateCommentPageState extends State { languageId: languageId, onLanguageSelected: (ThunderLanguage? language) { setState(() => languageId = language?.id); + _onDraftInputChanged(); }, ), ), diff --git a/lib/src/features/drafts/data/models/draft.dart b/lib/src/features/drafts/data/models/draft.dart index 037793a46..af7fd0f71 100644 --- a/lib/src/features/drafts/data/models/draft.dart +++ b/lib/src/features/drafts/data/models/draft.dart @@ -1,7 +1,4 @@ -import 'package:drift/drift.dart'; -import 'package:flutter/foundation.dart'; -import 'package:thunder/src/foundation/persistence/persistence.dart'; -import 'package:thunder/src/features/drafts/drafts.dart'; +import 'package:thunder/src/foundation/primitives/enums/draft_type.dart'; class Draft { /// The database identifier for this object @@ -16,6 +13,12 @@ class Draft { /// The community/post/comment we're replying to final int? replyId; + /// Whether this draft is currently considered active for draft resume. + final bool active; + + /// The account currently selected for publishing this draft. + final String? accountId; + /// The title of the post final String? title; @@ -28,6 +31,12 @@ class Draft { /// Alternative text for the image final String? altText; + /// Whether the post is marked as NSFW. + final bool nsfw; + + /// The selected language for the post/comment. + final int? languageId; + /// The body of the post/comment final String? body; @@ -36,10 +45,14 @@ class Draft { required this.draftType, this.existingId, this.replyId, + this.active = false, + this.accountId, this.title, this.url, this.customThumbnail, this.altText, + this.nsfw = false, + this.languageId, this.body, }); @@ -48,10 +61,14 @@ class Draft { DraftType? draftType, int? existingId, int? replyId, + bool? active, + String? accountId, String? title, String? url, String? customThumbnail, String? altText, + bool? nsfw, + int? languageId, String? body, }) => Draft( @@ -59,102 +76,24 @@ class Draft { draftType: draftType ?? this.draftType, existingId: existingId ?? this.existingId, replyId: replyId ?? this.replyId, + active: active ?? this.active, + accountId: accountId ?? this.accountId, title: title ?? this.title, url: url ?? this.url, customThumbnail: customThumbnail ?? this.customThumbnail, altText: altText ?? this.altText, + nsfw: nsfw ?? this.nsfw, + languageId: languageId ?? this.languageId, 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 || customThumbnail?.isNotEmpty == true || altText?.isNotEmpty == true || body?.isNotEmpty == true; + bool get isPostNotEmpty => + title?.isNotEmpty == true || url?.isNotEmpty == true || customThumbnail?.isNotEmpty == true || altText?.isNotEmpty == true || body?.isNotEmpty == true || nsfw || languageId != null; /// 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 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), - customThumbnail: Value(draft.customThumbnail), - altText: Value(draft.altText), - 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), - customThumbnail: Value(draft.customThumbnail), - altText: Value(draft.altText), - 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 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, - customThumbnail: draft.customThumbnail, - altText: draft.altText, - body: draft.body, - ); - } catch (e) { - debugPrint(e.toString()); - return null; - } - } - - /// Delete a draft from the db - static Future 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()); - } - } + bool get isCommentNotEmpty => body?.isNotEmpty == true || languageId != null; + + /// See whether this draft contains enough information to attempt a draft restore on startup. + bool get hasRestorableContent => isPostNotEmpty || isCommentNotEmpty; } diff --git a/lib/src/features/drafts/data/repositories/draft_repository.dart b/lib/src/features/drafts/data/repositories/draft_repository.dart new file mode 100644 index 000000000..56b6a2952 --- /dev/null +++ b/lib/src/features/drafts/data/repositories/draft_repository.dart @@ -0,0 +1,248 @@ +import 'package:flutter/foundation.dart'; + +import 'package:drift/drift.dart'; + +import 'package:thunder/src/features/drafts/data/models/draft.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/foundation/primitives/enums/draft_type.dart'; + +abstract class DraftRepository { + /// Upsert a draft into the database. + Future upsertDraft(Draft draft, {bool active = false}); + + /// Fetch a draft from the database. + Future fetchDraft(DraftType draftType, int? existingId, int? replyId); + + /// Fetch the active draft from the database. The active draft is the draft that will be restored on startup. + Future fetchActiveDraft(); + + /// Clear the active draft from the database. + Future clearActiveDraft(); + + /// Clear the active draft from the database by identity. + Future clearActiveDraftByIdentity(DraftType draftType, int? existingId, int? replyId); + + /// Delete a draft from the database. + Future deleteDraft(DraftType draftType, int? existingId, int? replyId); +} + +class DraftRepositoryImpl implements DraftRepository { + DraftRepositoryImpl({required AppDatabase database}) : _database = database; + + static const DraftTypeConverter _draftTypeConverter = DraftTypeConverter(); + + final AppDatabase _database; + + @override + Future upsertDraft(Draft draft, {bool active = false}) async { + try { + final Draft normalizedDraft = _normalizeDraftForStorage(draft); + final List draftTypeSqlValues = _compatibleDraftTypes(normalizedDraft.draftType).map((t) => _draftTypeConverter.toSql(t)).toList(); + + return await _database.transaction(() async { + if (active) { + await clearActiveDraft(); + } + + final existingDrafts = await (_database.select(_database.drafts) + ..where((t) => t.draftType.isIn(draftTypeSqlValues)) + ..where((t) => normalizedDraft.existingId == null ? t.existingId.isNull() : t.existingId.equals(normalizedDraft.existingId!)) + ..where((t) => normalizedDraft.replyId == null ? t.replyId.isNull() : t.replyId.equals(normalizedDraft.replyId!))) + .get(); + + dynamic existingDraft; + + for (final candidate in existingDrafts) { + if (candidate.draftType == normalizedDraft.draftType) { + existingDraft = candidate; + break; + } + } + + existingDraft ??= existingDrafts.isNotEmpty ? existingDrafts.first : null; + + final bool isActive = active ? true : normalizedDraft.active; + + if (existingDraft == null) { + final id = await _database.into(_database.drafts).insert( + DraftsCompanion.insert( + draftType: normalizedDraft.draftType, + existingId: Value(normalizedDraft.existingId), + replyId: Value(normalizedDraft.replyId), + active: Value(isActive), + accountId: Value(normalizedDraft.accountId), + title: Value(normalizedDraft.title), + url: Value(normalizedDraft.url), + customThumbnail: Value(normalizedDraft.customThumbnail), + altText: Value(normalizedDraft.altText), + nsfw: Value(normalizedDraft.nsfw), + languageId: Value(normalizedDraft.languageId), + body: Value(normalizedDraft.body), + ), + ); + + return normalizedDraft.copyWith(id: id.toString(), active: isActive); + } + + await _database.update(_database.drafts).replace( + DraftsCompanion( + id: Value(existingDraft.id), + draftType: Value(normalizedDraft.draftType), + existingId: Value(normalizedDraft.existingId), + replyId: Value(normalizedDraft.replyId), + active: Value(isActive), + accountId: Value(normalizedDraft.accountId), + title: Value(normalizedDraft.title), + url: Value(normalizedDraft.url), + customThumbnail: Value(normalizedDraft.customThumbnail), + altText: Value(normalizedDraft.altText), + nsfw: Value(normalizedDraft.nsfw), + languageId: Value(normalizedDraft.languageId), + body: Value(normalizedDraft.body), + ), + ); + + return normalizedDraft.copyWith(id: existingDraft.id.toString(), active: isActive); + }); + } catch (e) { + debugPrint(e.toString()); + return null; + } + } + + @override + Future fetchDraft(DraftType draftType, int? existingId, int? replyId) async { + try { + final List draftTypeSqlValues = _compatibleDraftTypes(draftType).map((t) => _draftTypeConverter.toSql(t)).toList(); + final drafts = await (_database.select(_database.drafts) + ..where((t) => t.draftType.isIn(draftTypeSqlValues)) + ..where((t) => existingId == null ? t.existingId.isNull() : t.existingId.equals(existingId)) + ..where((t) => replyId == null ? t.replyId.isNull() : t.replyId.equals(replyId))) + .get(); + + if (drafts.isEmpty) return null; + + dynamic draft; + + for (final candidate in drafts) { + if (candidate.draftType == draftType) { + draft = candidate; + break; + } + } + + draft ??= drafts.first; + + return _toDraft(draft); + } catch (e) { + debugPrint(e.toString()); + return null; + } + } + + @override + Future fetchActiveDraft() async { + try { + final drafts = await (_database.select(_database.drafts)..where((t) => t.active.equals(true))).get(); + + if (drafts.isEmpty) { + return null; + } + + return _toDraft(drafts.first); + } catch (e) { + debugPrint(e.toString()); + return null; + } + } + + @override + Future clearActiveDraft() async { + try { + await (_database.update(_database.drafts)..where((t) => t.active.equals(true))).write(const DraftsCompanion(active: Value(false))); + } catch (e) { + debugPrint(e.toString()); + } + } + + @override + Future clearActiveDraftByIdentity(DraftType draftType, int? existingId, int? replyId) async { + try { + final List draftTypeSqlValues = _compatibleDraftTypes(draftType).map((t) => _draftTypeConverter.toSql(t)).toList(); + await (_database.update(_database.drafts) + ..where((t) => t.active.equals(true)) + ..where((t) => t.draftType.isIn(draftTypeSqlValues)) + ..where((t) => existingId == null ? t.existingId.isNull() : t.existingId.equals(existingId)) + ..where((t) => replyId == null ? t.replyId.isNull() : t.replyId.equals(replyId))) + .write(const DraftsCompanion(active: Value(false))); + } catch (e) { + debugPrint(e.toString()); + } + } + + @override + Future deleteDraft(DraftType draftType, int? existingId, int? replyId) async { + try { + final List draftTypeSqlValues = _compatibleDraftTypes(draftType).map((t) => _draftTypeConverter.toSql(t)).toList(); + await (_database.delete(_database.drafts) + ..where((t) => t.draftType.isIn(draftTypeSqlValues)) + ..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()); + } + } + + Draft _toDraft(dynamic draft) { + return Draft( + id: draft.id.toString(), + draftType: draft.draftType, + existingId: draft.existingId, + replyId: draft.replyId, + active: draft.active, + accountId: draft.accountId, + title: draft.title, + url: draft.url, + customThumbnail: draft.customThumbnail, + altText: draft.altText, + nsfw: draft.nsfw, + languageId: draft.languageId, + body: draft.body, + ); + } + + Draft _normalizeDraftForStorage(Draft draft) { + return draft.copyWith( + title: _normalizeNullableText(draft.title), + url: _normalizeNullableText(draft.url), + customThumbnail: _normalizeNullableText(draft.customThumbnail), + altText: _normalizeNullableText(draft.altText), + body: _normalizeNullableText(draft.body), + ); + } + + List _compatibleDraftTypes(DraftType draftType) { + if (draftType == DraftType.commentCreateFromPost || draftType == DraftType.commentCreateFromComment) { + return [draftType, DraftType.commentCreate]; + } + + if (draftType == DraftType.postCreate || draftType == DraftType.postCreateGeneral) { + return [DraftType.postCreate, DraftType.postCreateGeneral]; + } + + return [draftType]; + } + + String? _normalizeNullableText(String? value) { + if (value == null) { + return null; + } + + if (value.trim().isEmpty) { + return null; + } + + return value; + } +} diff --git a/lib/src/features/drafts/drafts.dart b/lib/src/features/drafts/drafts.dart index 377a8a8e3..01598666b 100644 --- a/lib/src/features/drafts/drafts.dart +++ b/lib/src/features/drafts/drafts.dart @@ -1,2 +1,4 @@ export 'data/models/draft.dart'; +export 'data/repositories/draft_repository.dart'; +export 'presentation/utils/draft_utils.dart'; export 'package:thunder/src/foundation/primitives/enums/draft_type.dart'; diff --git a/lib/src/features/drafts/presentation/utils/draft_utils.dart b/lib/src/features/drafts/presentation/utils/draft_utils.dart new file mode 100644 index 000000000..01f4233b0 --- /dev/null +++ b/lib/src/features/drafts/presentation/utils/draft_utils.dart @@ -0,0 +1,322 @@ +import 'package:thunder/src/features/comment/data/repositories/comment_repository_impl.dart'; +import 'package:thunder/src/features/community/data/repositories/community_repository_impl.dart'; +import 'package:thunder/src/features/drafts/data/models/draft.dart'; +import 'package:thunder/src/features/drafts/data/repositories/draft_repository.dart'; +import 'package:thunder/src/features/post/data/repositories/post_repository.dart'; +import 'package:thunder/src/foundation/contracts/account.dart'; +import 'package:thunder/src/foundation/primitives/enums/draft_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; + +class DraftContext { + const DraftContext({ + required this.draftType, + this.existingId, + this.replyId, + }); + + /// The type of draft + final DraftType draftType; + + /// The existing id, if we're editing + final int? existingId; + + /// The reply id, if we're replying to a post or comment + final int? replyId; + + bool get hasRequiredReplyTarget => !draftType.isCommentCreate || replyId != null; +} + +enum DraftPersistenceResult { + saved, + deleted, + skipped, +} + +class ActiveDraftRestoreData { + const ActiveDraftRestoreData({ + required this.draft, + required this.account, + }); + + final Draft draft; + final Account account; +} + +DraftContext resolvePostDraftContext({ + required int? editingPostId, + required int? communityId, +}) { + if (editingPostId != null) { + return DraftContext( + draftType: DraftType.postEdit, + existingId: editingPostId, + ); + } + + return DraftContext( + draftType: DraftType.postCreate, + replyId: communityId, + ); +} + +DraftContext resolveCommentDraftContext({ + required int? editingCommentId, + required int? postId, + required int? parentCommentId, +}) { + if (editingCommentId != null) { + return DraftContext( + draftType: DraftType.commentEdit, + existingId: editingCommentId, + ); + } + + return DraftContext( + draftType: parentCommentId != null ? DraftType.commentCreateFromComment : DraftType.commentCreateFromPost, + replyId: parentCommentId ?? postId, + ); +} + +Draft buildPostDraft({ + required DraftContext context, + required String? accountId, + required String title, + required String url, + required String customThumbnail, + required String altText, + required bool nsfw, + required int? languageId, + required String body, +}) { + return Draft( + id: '', + draftType: context.draftType, + existingId: context.existingId, + replyId: context.replyId, + accountId: accountId, + title: title, + url: url, + customThumbnail: customThumbnail, + altText: altText, + nsfw: nsfw, + languageId: languageId, + body: body, + ); +} + +Draft buildCommentDraft({ + required DraftContext context, + required String? accountId, + required int? languageId, + required String body, +}) { + return Draft( + id: '', + draftType: context.draftType, + existingId: context.existingId, + replyId: context.replyId, + accountId: accountId, + languageId: languageId, + body: body, + ); +} + +Future restoreDraft({ + required DraftRepository repository, + required DraftContext context, +}) async { + if (!context.hasRequiredReplyTarget) { + return null; + } + + return repository.fetchDraft(context.draftType, context.existingId, context.replyId); +} + +Future persistDraft({ + required DraftRepository repository, + required DraftContext context, + required Draft draft, + required bool save, + required bool differsFromEdit, + required bool hasContent, +}) async { + if (!context.hasRequiredReplyTarget) { + return DraftPersistenceResult.skipped; + } + + if (hasContent && save && differsFromEdit) { + await repository.upsertDraft(draft, active: true); + return DraftPersistenceResult.saved; + } + + await repository.deleteDraft(context.draftType, context.existingId, context.replyId); + return DraftPersistenceResult.deleted; +} + +bool postDraftDiffersFromEdit(Draft draft, ThunderPost? post) { + if (post == null) { + return true; + } + + return draft.title != post.name || + draft.url != (post.url ?? '') || + draft.customThumbnail != (post.thumbnailUrl ?? '') || + draft.altText != (post.altText ?? '') || + draft.nsfw != post.nsfw || + draft.languageId != post.languageId || + draft.body != (post.body ?? ''); +} + +bool commentDraftDiffersFromEdit(Draft draft, ThunderComment? comment) { + if (comment == null) { + return true; + } + + return draft.body != comment.content || draft.languageId != comment.languageId; +} + +Future resolveActiveDraftRestoreData({ + required DraftRepository repository, + required Account account, +}) async { + final activeDraft = await repository.fetchActiveDraft(); + if (activeDraft == null || !activeDraft.hasRestorableContent) { + if (activeDraft != null) { + await repository.clearActiveDraft(); + } + + return null; + } + + Account restoreAccount = account; + if (activeDraft.accountId?.isNotEmpty == true) { + final account = await Account.fetchAccount(activeDraft.accountId!); + if (account != null) { + restoreAccount = account; + } + } + + return ActiveDraftRestoreData( + draft: activeDraft, + account: restoreAccount, + ); +} + +Future restoreActiveDraftSession({ + required DraftRepository repository, + required Account account, + required Future Function(Account account, int? communityId, ThunderCommunity? community) onPostCreateRestore, + required Future Function(Account account, ThunderPost post) onPostEditRestore, + required Future Function(Account account, ThunderPost post) onCommentCreateFromPostRestore, + required Future Function(Account account, ThunderComment comment) onCommentCreateFromCommentRestore, + required Future Function(Account account, ThunderComment comment) onCommentEditRestore, +}) async { + final restoreData = await resolveActiveDraftRestoreData( + repository: repository, + account: account, + ); + + if (restoreData == null) { + return; + } + + final activeDraft = restoreData.draft; + final restoreAccount = restoreData.account; + + try { + switch (activeDraft.draftType) { + case DraftType.postCreate: + case DraftType.postCreateGeneral: + ThunderCommunity? community; + + if (activeDraft.replyId != null) { + try { + final details = await CommunityRepositoryImpl(account: restoreAccount).getCommunity(id: activeDraft.replyId); + community = details.community; + } catch (_) { + community = null; + } + } + + await onPostCreateRestore(restoreAccount, activeDraft.replyId, community); + break; + + case DraftType.postEdit: + if (activeDraft.existingId == null) { + await repository.clearActiveDraft(); + return; + } + + final response = await PostRepositoryImpl(account: restoreAccount).getPost(activeDraft.existingId!); + final post = response?['post']; + + if (post is! ThunderPost) { + return; + } + + await onPostEditRestore(restoreAccount, post); + break; + + case DraftType.commentCreateFromPost: + if (activeDraft.replyId == null) { + await repository.clearActiveDraft(); + return; + } + + final response = await PostRepositoryImpl(account: restoreAccount).getPost(activeDraft.replyId!); + final post = response?['post']; + + if (post is! ThunderPost) { + return; + } + + await onCommentCreateFromPostRestore(restoreAccount, post); + break; + + case DraftType.commentCreateFromComment: + if (activeDraft.replyId == null) { + await repository.clearActiveDraft(); + return; + } + + final comment = await CommentRepositoryImpl(account: restoreAccount).getComment(activeDraft.replyId!); + await onCommentCreateFromCommentRestore(restoreAccount, comment); + break; + + case DraftType.commentEdit: + if (activeDraft.existingId == null) { + await repository.clearActiveDraft(); + return; + } + + final comment = await CommentRepositoryImpl(account: restoreAccount).getComment(activeDraft.existingId!); + await onCommentEditRestore(restoreAccount, comment); + break; + + case DraftType.commentCreate: + if (activeDraft.replyId == null) { + await repository.clearActiveDraft(); + return; + } + + try { + final comment = await CommentRepositoryImpl(account: restoreAccount).getComment(activeDraft.replyId!); + await onCommentCreateFromCommentRestore(restoreAccount, comment); + } catch (_) { + final response = await PostRepositoryImpl(account: restoreAccount).getPost(activeDraft.replyId!); + final post = response?['post']; + + if (post is! ThunderPost) { + return; + } + + await onCommentCreateFromPostRestore(restoreAccount, post); + } + break; + } + } catch (_) { + // If anything fails, leave the active marker to allow a future recovery attempt. + } +} diff --git a/lib/src/features/post/presentation/pages/create_post_page.dart b/lib/src/features/post/presentation/pages/create_post_page.dart index 6d077fd19..47388bb23 100644 --- a/lib/src/features/post/presentation/pages/create_post_page.dart +++ b/lib/src/features/post/presentation/pages/create_post_page.dart @@ -13,7 +13,9 @@ import 'package:link_preview_generator/link_preview_generator.dart'; import 'package:markdown_editor/markdown_editor.dart'; // Project imports +import 'package:thunder/src/features/community/data/repositories/community_repository_impl.dart'; import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; @@ -35,6 +37,9 @@ import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.d import 'package:thunder/packages/ui/ui.dart' show isImageUrl, selectImagesToUpload, showSnackbar; class CreatePostPage extends StatefulWidget { + /// The account to use for composing this post. + final Account? account; + /// The community ID to create the post in final int? communityId; @@ -73,6 +78,7 @@ class CreatePostPage extends StatefulWidget { const CreatePostPage({ super.key, + this.account, required this.communityId, this.community, this.image, @@ -91,30 +97,20 @@ class CreatePostPage extends StatefulWidget { State createState() => _CreatePostPageState(); } -class _CreatePostPageState extends State { +class _CreatePostPageState extends State with WidgetsBindingObserver { /// The account to use for the post Account? account; /// The account's user information ThunderUser? user; - /// Holds the draft type associated with the post. This type is determined by the input parameters passed in. - /// If [post] is passed in, this will be a [DraftType.postEdit]. - /// If [communityId] or [communityView] is passed in, this will be a [DraftType.postCreate]. - /// Otherwise it will be a [DraftType.postCreateGeneral]. - late DraftType draftType; - - /// The ID of the post we are editing, to find a corresponding draft, if any - int? draftExistingId; - - /// The ID of the community we're replying to, to find a corresponding draft, if any - int? draftReplyId; + final DraftRepository _draftRepository = DraftRepositoryImpl(database: database); /// Whether to save this post as a draft bool saveDraft = true; - /// Timer for saving the current draft - Timer? _draftTimer; + /// Debounces writes while the user is typing + Timer? _draftDebounceTimer; /// Whether or not to show the preview for the post from the raw markdown bool showPreview = false; @@ -174,35 +170,48 @@ class _CreatePostPageState extends State { void initState() { super.initState(); - account = context.read().state.account; + WidgetsBinding.instance.addObserver(this); + + account = widget.account ?? context.read().state.account; communityId = widget.communityId; if (widget.community != null) { community = widget.community; + communityId ??= widget.community?.id; } + unawaited(_restoreCommunity()); + // Set up any text controller listeners _titleTextController.addListener(() { _validateSubmission(); + _onDraftInputChanged(); }); _urlTextController.addListener(() { url = _urlTextController.text; _validateSubmission(); debounce(const Duration(milliseconds: 1000), _updatePreview, [url]); + _onDraftInputChanged(); }); _customThumbnailTextController.addListener(() { customThumbnail = _customThumbnailTextController.text; _validateSubmission(); debounce(const Duration(milliseconds: 1000), _updatePreview, [customThumbnail]); + _onDraftInputChanged(); }); _altTextTextController.addListener(() { altText = _altTextTextController.text; _validateSubmission(); debounce(const Duration(milliseconds: 1000), _updatePreview, [altText]); + _onDraftInputChanged(); + }); + + _bodyTextController.addListener(() { + _onDraftInputChanged(); }); // Logic for pre-populating the post with the given fields @@ -237,29 +246,36 @@ class _CreatePostPageState extends State { } }); } + } else { + // Logic for pre-populating the post with the [postView] for edits + if (widget.post != null) { + _titleTextController.text = widget.post!.name; + _urlTextController.text = widget.post!.url ?? ''; + _customThumbnailTextController.text = widget.post!.thumbnailUrl ?? ''; + _altTextTextController.text = widget.post!.altText ?? ''; + _bodyTextController.text = widget.post!.body ?? ''; + isNSFW = widget.post!.nsfw; + languageId = widget.post!.languageId; + } - return; - } - - // Logic for pre-populating the post with the [postView] for edits - if (widget.post != null) { - _titleTextController.text = widget.post!.name; - _urlTextController.text = widget.post!.url ?? ''; - _customThumbnailTextController.text = widget.post!.thumbnailUrl ?? ''; - _altTextTextController.text = widget.post!.altText ?? ''; - _bodyTextController.text = widget.post!.body ?? ''; - isNSFW = widget.post!.nsfw; - languageId = widget.post!.languageId; + // Finally, if there is no pre-populated fields, then we retrieve the most recent draft + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + _restoreExistingDraft(); + }); } - - // Finally, if there is no pre-populated fields, then we retrieve the most recent draft - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - _restoreExistingDraft(); - }); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + + FocusManager.instance.primaryFocus?.unfocus(); + + _draftDebounceTimer?.cancel(); + + _persistOrDeleteDraft(showSaveDraftSnackbar: true); + unawaited(_draftRepository.clearActiveDraft()); + _bodyTextController.dispose(); _titleTextController.dispose(); _urlTextController.dispose(); @@ -267,40 +283,24 @@ class _CreatePostPageState extends State { _altTextTextController.dispose(); _bodyFocusNode.dispose(); - FocusManager.instance.primaryFocus?.unfocus(); - - _draftTimer?.cancel(); - - Draft draft = _generateDraft(); - - if (draft.isPostNotEmpty && saveDraft && _draftDiffersFromEdit(draft)) { - final l10n = GlobalContext.l10n; + super.dispose(); + } - Draft.upsertDraft(draft); - showSnackbar(l10n.postSavedAsDraft); - } else { - Draft.deleteDraft(draftType, draftExistingId, draftReplyId); + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.inactive || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { + _persistOrDeleteDraft(); } - - super.dispose(); } /// Attempts to restore an existing draft of a post void _restoreExistingDraft() async { - if (widget.post != null) { - draftType = DraftType.postEdit; - draftExistingId = widget.post?.id; - } else if (widget.communityId != null) { - draftType = DraftType.postCreate; - draftReplyId = widget.communityId; - } else if (widget.community != null) { - draftType = DraftType.postCreate; - draftReplyId = widget.community!.id; - } else { - draftType = DraftType.postCreateGeneral; - } + final draftContext = _draftContext; - Draft? draft = await Draft.fetchDraft(draftType, draftExistingId, draftReplyId); + final draft = await restoreDraft( + repository: _draftRepository, + context: draftContext, + ); if (draft != null) { _titleTextController.text = draft.title ?? ''; @@ -308,60 +308,94 @@ class _CreatePostPageState extends State { _customThumbnailTextController.text = draft.customThumbnail ?? ''; _altTextTextController.text = draft.altText ?? ''; _bodyTextController.text = draft.body ?? ''; - } - _draftTimer = Timer.periodic(const Duration(seconds: 10), (Timer t) { - Draft draft = _generateDraft(); - if (draft.isPostNotEmpty && saveDraft && _draftDiffersFromEdit(draft)) { - Draft.upsertDraft(draft); - } else { - Draft.deleteDraft(draftType, draftExistingId, draftReplyId); - } - }); + setState(() { + isNSFW = draft.nsfw; + languageId = draft.languageId; + }); + } - if (context.mounted && draft?.isPostNotEmpty == true && _draftDiffersFromEdit(draft!)) { + if (context.mounted && draft?.isPostNotEmpty == true && postDraftDiffersFromEdit(draft!, widget.post)) { showSnackbar( AppLocalizations.of(context)!.restoredPostFromDraft, trailingIcon: Icons.delete_forever_rounded, trailingIconColor: Theme.of(context).colorScheme.errorContainer, trailingAction: () { - Draft.deleteDraft(draftType, draftExistingId, draftReplyId); + unawaited(_draftRepository.deleteDraft(draftContext.draftType, draftContext.existingId, draftContext.replyId)); _titleTextController.text = widget.post?.name ?? ''; _urlTextController.text = widget.post?.url ?? ''; _customThumbnailTextController.text = widget.post?.thumbnailUrl ?? ''; _altTextTextController.text = widget.post?.altText ?? ''; _bodyTextController.text = widget.post?.body ?? ''; + + setState(() { + isNSFW = widget.post?.nsfw ?? false; + languageId = widget.post?.languageId; + }); }, ); } } - Draft _generateDraft() { - return Draft( - id: '', - draftType: draftType, - existingId: draftExistingId, - replyId: draftReplyId, - title: _titleTextController.text, - url: _urlTextController.text, - customThumbnail: _customThumbnailTextController.text, - altText: _altTextTextController.text, - body: _bodyTextController.text, - ); - } + Future _restoreCommunity() async { + if (community != null || communityId == null || account == null) { + return; + } + + try { + final details = await CommunityRepositoryImpl(account: account!).getCommunity(id: communityId); - /// 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.post == null) { - return true; + if (!mounted) { + return; + } + + setState(() { + community = details.community; + }); + } catch (_) { + // It's fine to continue without the full community object. } + } - return draft.title != widget.post!.name || - draft.url != (widget.post!.url ?? '') || - draft.customThumbnail != (widget.post!.thumbnailUrl ?? '') || - draft.altText != (widget.post!.altText ?? '') || - draft.body != (widget.post!.body ?? ''); + DraftContext get _draftContext => resolvePostDraftContext( + editingPostId: widget.post?.id, + communityId: communityId, + ); + + Draft _buildDraft() => buildPostDraft( + context: _draftContext, + accountId: account?.id, + title: _titleTextController.text, + url: _urlTextController.text, + customThumbnail: _customThumbnailTextController.text, + altText: _altTextTextController.text, + nsfw: isNSFW, + languageId: languageId, + body: _bodyTextController.text, + ); + + void _onDraftInputChanged() { + _draftDebounceTimer?.cancel(); + _draftDebounceTimer = Timer(const Duration(milliseconds: 800), _persistOrDeleteDraft); + } + + void _persistOrDeleteDraft({bool showSaveDraftSnackbar = false}) { + final draft = _buildDraft(); + + unawaited( + persistDraft( + repository: _draftRepository, + context: _draftContext, + draft: draft, + save: saveDraft, + differsFromEdit: postDraftDiffersFromEdit(draft, widget.post), + hasContent: draft.isPostNotEmpty, + ).then((result) { + if (showSaveDraftSnackbar && result == DraftPersistenceResult.saved) { + showSnackbar(GlobalContext.l10n.postSavedAsDraft); + } + }), + ); } /// Attempts to get the suggested title for a given link @@ -450,6 +484,7 @@ class _CreatePostPageState extends State { communityId = c.id; community = c; }); + _onDraftInputChanged(); _validateSubmission(); }, ), @@ -463,6 +498,7 @@ class _CreatePostPageState extends State { community = community; }); + _onDraftInputChanged(); _validateSubmission(); }, onUserChanged: (account) { @@ -472,6 +508,7 @@ class _CreatePostPageState extends State { }); context.read().switchAccount(account); + _onDraftInputChanged(); }, enableAccountSwitching: widget.post == null, ), @@ -611,6 +648,7 @@ class _CreatePostPageState extends State { languageId: languageId, onLanguageSelected: (ThunderLanguage? language) { setState(() => languageId = language?.id); + _onDraftInputChanged(); }, ), ), @@ -621,7 +659,10 @@ class _CreatePostPageState extends State { const SizedBox(width: 4.0), Switch( value: isNSFW, - onChanged: (bool value) => setState(() => isNSFW = value), + onChanged: (bool value) { + setState(() => isNSFW = value); + _onDraftInputChanged(); + }, ), ], ), diff --git a/lib/src/foundation/persistence/database/database.dart b/lib/src/foundation/persistence/database/database.dart index eda1d915f..fe9ca538e 100644 --- a/lib/src/foundation/persistence/database/database.dart +++ b/lib/src/foundation/persistence/database/database.dart @@ -16,7 +16,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 7; + int get schemaVersion => 8; static QueryExecutor _openConnection() { return driftDatabase( @@ -34,6 +34,7 @@ class AppDatabase extends _$AppDatabase { return MigrationStrategy( onCreate: (m) async { await m.createAll(); + await customStatement('CREATE UNIQUE INDEX IF NOT EXISTS drafts_single_active_idx ON drafts(active) WHERE active = 1'); }, onUpgrade: (m, from, to) async { await customStatement('PRAGMA foreign_keys = OFF'); @@ -93,6 +94,39 @@ class AppDatabase extends _$AppDatabase { await customStatement('UPDATE accounts SET platform = \'lemmy\''); } }, + from7To8: (m, schema) async { + try { + await customStatement('SELECT active FROM drafts LIMIT 1'); + } catch (e) { + // Add active column to drafts + await m.addColumn(schema.drafts, schema.drafts.active); + } + + try { + await customStatement('SELECT account_id FROM drafts LIMIT 1'); + } catch (e) { + // Add account_id column to drafts + await m.addColumn(schema.drafts, schema.drafts.accountId); + } + + try { + await customStatement('SELECT nsfw FROM drafts LIMIT 1'); + } catch (e) { + // Add nsfw column to drafts + await m.addColumn(schema.drafts, schema.drafts.nsfw); + } + + try { + await customStatement('SELECT language_id FROM drafts LIMIT 1'); + } catch (e) { + // Add language_id column to drafts + await m.addColumn(schema.drafts, schema.drafts.languageId); + } + + await customStatement('UPDATE drafts SET active = 0 WHERE active IS NULL'); + await customStatement('UPDATE drafts SET nsfw = 0 WHERE nsfw IS NULL'); + await customStatement('CREATE UNIQUE INDEX IF NOT EXISTS drafts_single_active_idx ON drafts(active) WHERE active = 1'); + }, ), ); @@ -132,7 +166,15 @@ Future _onDowngrade(AppDatabase database, int fromVersion, int toVersion) } Future _onDownGradeOneStep(AppDatabase database, int fromVersion, int toVersion) async { - if (fromVersion == 7 && toVersion == 6) { + if (fromVersion == 8 && toVersion == 7) { + await database.customStatement('DROP INDEX IF EXISTS drafts_single_active_idx'); + + // Drop active, account_id, nsfw, and language_id columns from drafts + await database.customStatement('ALTER TABLE drafts DROP COLUMN active'); + await database.customStatement('ALTER TABLE drafts DROP COLUMN account_id'); + await database.customStatement('ALTER TABLE drafts DROP COLUMN nsfw'); + await database.customStatement('ALTER TABLE drafts DROP COLUMN language_id'); + } else if (fromVersion == 7 && toVersion == 6) { // Drop the platform column on the accounts table await database.customStatement('ALTER TABLE accounts DROP COLUMN platform'); } else if (fromVersion == 6 && toVersion == 5) { diff --git a/lib/src/foundation/persistence/database/database.g.dart b/lib/src/foundation/persistence/database/database.g.dart index 02544c729..352efa552 100644 --- a/lib/src/foundation/persistence/database/database.g.dart +++ b/lib/src/foundation/persistence/database/database.g.dart @@ -994,6 +994,13 @@ class $DraftsTable extends Drafts with TableInfo<$DraftsTable, Draft> { static const VerificationMeta _replyIdMeta = const VerificationMeta('replyId'); @override late final GeneratedColumn replyId = GeneratedColumn('reply_id', aliasedName, true, type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _activeMeta = const VerificationMeta('active'); + @override + late final GeneratedColumn active = GeneratedColumn('active', aliasedName, false, + type: DriftSqlType.bool, requiredDuringInsert: false, defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("active" IN (0, 1))'), defaultValue: const Constant(false)); + static const VerificationMeta _accountIdMeta = const VerificationMeta('accountId'); + @override + late final GeneratedColumn accountId = GeneratedColumn('account_id', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false); static const VerificationMeta _titleMeta = const VerificationMeta('title'); @override late final GeneratedColumn title = GeneratedColumn('title', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false); @@ -1006,11 +1013,18 @@ class $DraftsTable extends Drafts with TableInfo<$DraftsTable, Draft> { static const VerificationMeta _altTextMeta = const VerificationMeta('altText'); @override late final GeneratedColumn altText = GeneratedColumn('alt_text', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _nsfwMeta = const VerificationMeta('nsfw'); + @override + late final GeneratedColumn nsfw = GeneratedColumn('nsfw', aliasedName, false, + type: DriftSqlType.bool, requiredDuringInsert: false, defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("nsfw" IN (0, 1))'), defaultValue: const Constant(false)); + static const VerificationMeta _languageIdMeta = const VerificationMeta('languageId'); + @override + late final GeneratedColumn languageId = GeneratedColumn('language_id', aliasedName, true, type: DriftSqlType.int, requiredDuringInsert: false); static const VerificationMeta _bodyMeta = const VerificationMeta('body'); @override late final GeneratedColumn body = GeneratedColumn('body', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false); @override - List get $columns => [id, draftType, existingId, replyId, title, url, customThumbnail, altText, body]; + List get $columns => [id, draftType, existingId, replyId, active, accountId, title, url, customThumbnail, altText, nsfw, languageId, body]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -1029,6 +1043,12 @@ class $DraftsTable extends Drafts with TableInfo<$DraftsTable, Draft> { if (data.containsKey('reply_id')) { context.handle(_replyIdMeta, replyId.isAcceptableOrUnknown(data['reply_id']!, _replyIdMeta)); } + if (data.containsKey('active')) { + context.handle(_activeMeta, active.isAcceptableOrUnknown(data['active']!, _activeMeta)); + } + if (data.containsKey('account_id')) { + context.handle(_accountIdMeta, accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta)); + } if (data.containsKey('title')) { context.handle(_titleMeta, title.isAcceptableOrUnknown(data['title']!, _titleMeta)); } @@ -1041,6 +1061,12 @@ class $DraftsTable extends Drafts with TableInfo<$DraftsTable, Draft> { if (data.containsKey('alt_text')) { context.handle(_altTextMeta, altText.isAcceptableOrUnknown(data['alt_text']!, _altTextMeta)); } + if (data.containsKey('nsfw')) { + context.handle(_nsfwMeta, nsfw.isAcceptableOrUnknown(data['nsfw']!, _nsfwMeta)); + } + if (data.containsKey('language_id')) { + context.handle(_languageIdMeta, languageId.isAcceptableOrUnknown(data['language_id']!, _languageIdMeta)); + } if (data.containsKey('body')) { context.handle(_bodyMeta, body.isAcceptableOrUnknown(data['body']!, _bodyMeta)); } @@ -1057,10 +1083,14 @@ class $DraftsTable extends Drafts with TableInfo<$DraftsTable, Draft> { draftType: $DraftsTable.$converterdraftType.fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}draft_type'])!), existingId: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}existing_id']), replyId: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}reply_id']), + active: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}active'])!, + accountId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}account_id']), title: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}title']), url: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}url']), customThumbnail: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}custom_thumbnail']), altText: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}alt_text']), + nsfw: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}nsfw'])!, + languageId: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}language_id']), body: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}body']), ); } @@ -1078,12 +1108,29 @@ class Draft extends DataClass implements Insertable { final DraftType draftType; final int? existingId; final int? replyId; + final bool active; + final String? accountId; final String? title; final String? url; final String? customThumbnail; final String? altText; + final bool nsfw; + final int? languageId; final String? body; - const Draft({required this.id, required this.draftType, this.existingId, this.replyId, this.title, this.url, this.customThumbnail, this.altText, this.body}); + const Draft( + {required this.id, + required this.draftType, + this.existingId, + this.replyId, + required this.active, + this.accountId, + this.title, + this.url, + this.customThumbnail, + this.altText, + required this.nsfw, + this.languageId, + this.body}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -1097,6 +1144,10 @@ class Draft extends DataClass implements Insertable { if (!nullToAbsent || replyId != null) { map['reply_id'] = Variable(replyId); } + map['active'] = Variable(active); + if (!nullToAbsent || accountId != null) { + map['account_id'] = Variable(accountId); + } if (!nullToAbsent || title != null) { map['title'] = Variable(title); } @@ -1109,6 +1160,10 @@ class Draft extends DataClass implements Insertable { if (!nullToAbsent || altText != null) { map['alt_text'] = Variable(altText); } + map['nsfw'] = Variable(nsfw); + if (!nullToAbsent || languageId != null) { + map['language_id'] = Variable(languageId); + } if (!nullToAbsent || body != null) { map['body'] = Variable(body); } @@ -1121,10 +1176,14 @@ class Draft extends DataClass implements Insertable { draftType: Value(draftType), existingId: existingId == null && nullToAbsent ? const Value.absent() : Value(existingId), replyId: replyId == null && nullToAbsent ? const Value.absent() : Value(replyId), + active: Value(active), + accountId: accountId == null && nullToAbsent ? const Value.absent() : Value(accountId), title: title == null && nullToAbsent ? const Value.absent() : Value(title), url: url == null && nullToAbsent ? const Value.absent() : Value(url), customThumbnail: customThumbnail == null && nullToAbsent ? const Value.absent() : Value(customThumbnail), altText: altText == null && nullToAbsent ? const Value.absent() : Value(altText), + nsfw: Value(nsfw), + languageId: languageId == null && nullToAbsent ? const Value.absent() : Value(languageId), body: body == null && nullToAbsent ? const Value.absent() : Value(body), ); } @@ -1136,10 +1195,14 @@ class Draft extends DataClass implements Insertable { draftType: serializer.fromJson(json['draftType']), existingId: serializer.fromJson(json['existingId']), replyId: serializer.fromJson(json['replyId']), + active: serializer.fromJson(json['active']), + accountId: serializer.fromJson(json['accountId']), title: serializer.fromJson(json['title']), url: serializer.fromJson(json['url']), customThumbnail: serializer.fromJson(json['customThumbnail']), altText: serializer.fromJson(json['altText']), + nsfw: serializer.fromJson(json['nsfw']), + languageId: serializer.fromJson(json['languageId']), body: serializer.fromJson(json['body']), ); } @@ -1151,10 +1214,14 @@ class Draft extends DataClass implements Insertable { 'draftType': serializer.toJson(draftType), 'existingId': serializer.toJson(existingId), 'replyId': serializer.toJson(replyId), + 'active': serializer.toJson(active), + 'accountId': serializer.toJson(accountId), 'title': serializer.toJson(title), 'url': serializer.toJson(url), 'customThumbnail': serializer.toJson(customThumbnail), 'altText': serializer.toJson(altText), + 'nsfw': serializer.toJson(nsfw), + 'languageId': serializer.toJson(languageId), 'body': serializer.toJson(body), }; } @@ -1164,20 +1231,28 @@ class Draft extends DataClass implements Insertable { DraftType? draftType, Value existingId = const Value.absent(), Value replyId = const Value.absent(), + bool? active, + Value accountId = const Value.absent(), Value title = const Value.absent(), Value url = const Value.absent(), Value customThumbnail = const Value.absent(), Value altText = const Value.absent(), + bool? nsfw, + Value languageId = const Value.absent(), Value body = const Value.absent()}) => Draft( id: id ?? this.id, draftType: draftType ?? this.draftType, existingId: existingId.present ? existingId.value : this.existingId, replyId: replyId.present ? replyId.value : this.replyId, + active: active ?? this.active, + accountId: accountId.present ? accountId.value : this.accountId, title: title.present ? title.value : this.title, url: url.present ? url.value : this.url, customThumbnail: customThumbnail.present ? customThumbnail.value : this.customThumbnail, altText: altText.present ? altText.value : this.altText, + nsfw: nsfw ?? this.nsfw, + languageId: languageId.present ? languageId.value : this.languageId, body: body.present ? body.value : this.body, ); Draft copyWithCompanion(DraftsCompanion data) { @@ -1186,10 +1261,14 @@ class Draft extends DataClass implements Insertable { draftType: data.draftType.present ? data.draftType.value : this.draftType, existingId: data.existingId.present ? data.existingId.value : this.existingId, replyId: data.replyId.present ? data.replyId.value : this.replyId, + active: data.active.present ? data.active.value : this.active, + accountId: data.accountId.present ? data.accountId.value : this.accountId, title: data.title.present ? data.title.value : this.title, url: data.url.present ? data.url.value : this.url, customThumbnail: data.customThumbnail.present ? data.customThumbnail.value : this.customThumbnail, altText: data.altText.present ? data.altText.value : this.altText, + nsfw: data.nsfw.present ? data.nsfw.value : this.nsfw, + languageId: data.languageId.present ? data.languageId.value : this.languageId, body: data.body.present ? data.body.value : this.body, ); } @@ -1201,17 +1280,21 @@ class Draft extends DataClass implements Insertable { ..write('draftType: $draftType, ') ..write('existingId: $existingId, ') ..write('replyId: $replyId, ') + ..write('active: $active, ') + ..write('accountId: $accountId, ') ..write('title: $title, ') ..write('url: $url, ') ..write('customThumbnail: $customThumbnail, ') ..write('altText: $altText, ') + ..write('nsfw: $nsfw, ') + ..write('languageId: $languageId, ') ..write('body: $body') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, draftType, existingId, replyId, title, url, customThumbnail, altText, body); + int get hashCode => Object.hash(id, draftType, existingId, replyId, active, accountId, title, url, customThumbnail, altText, nsfw, languageId, body); @override bool operator ==(Object other) => identical(this, other) || @@ -1220,10 +1303,14 @@ class Draft extends DataClass implements Insertable { other.draftType == this.draftType && other.existingId == this.existingId && other.replyId == this.replyId && + other.active == this.active && + other.accountId == this.accountId && other.title == this.title && other.url == this.url && other.customThumbnail == this.customThumbnail && other.altText == this.altText && + other.nsfw == this.nsfw && + other.languageId == this.languageId && other.body == this.body); } @@ -1232,20 +1319,28 @@ class DraftsCompanion extends UpdateCompanion { final Value draftType; final Value existingId; final Value replyId; + final Value active; + final Value accountId; final Value title; final Value url; final Value customThumbnail; final Value altText; + final Value nsfw; + final Value languageId; final Value body; const DraftsCompanion({ this.id = const Value.absent(), this.draftType = const Value.absent(), this.existingId = const Value.absent(), this.replyId = const Value.absent(), + this.active = const Value.absent(), + this.accountId = const Value.absent(), this.title = const Value.absent(), this.url = const Value.absent(), this.customThumbnail = const Value.absent(), this.altText = const Value.absent(), + this.nsfw = const Value.absent(), + this.languageId = const Value.absent(), this.body = const Value.absent(), }); DraftsCompanion.insert({ @@ -1253,10 +1348,14 @@ class DraftsCompanion extends UpdateCompanion { required DraftType draftType, this.existingId = const Value.absent(), this.replyId = const Value.absent(), + this.active = const Value.absent(), + this.accountId = const Value.absent(), this.title = const Value.absent(), this.url = const Value.absent(), this.customThumbnail = const Value.absent(), this.altText = const Value.absent(), + this.nsfw = const Value.absent(), + this.languageId = const Value.absent(), this.body = const Value.absent(), }) : draftType = Value(draftType); static Insertable custom({ @@ -1264,10 +1363,14 @@ class DraftsCompanion extends UpdateCompanion { Expression? draftType, Expression? existingId, Expression? replyId, + Expression? active, + Expression? accountId, Expression? title, Expression? url, Expression? customThumbnail, Expression? altText, + Expression? nsfw, + Expression? languageId, Expression? body, }) { return RawValuesInsertable({ @@ -1275,10 +1378,14 @@ class DraftsCompanion extends UpdateCompanion { if (draftType != null) 'draft_type': draftType, if (existingId != null) 'existing_id': existingId, if (replyId != null) 'reply_id': replyId, + if (active != null) 'active': active, + if (accountId != null) 'account_id': accountId, if (title != null) 'title': title, if (url != null) 'url': url, if (customThumbnail != null) 'custom_thumbnail': customThumbnail, if (altText != null) 'alt_text': altText, + if (nsfw != null) 'nsfw': nsfw, + if (languageId != null) 'language_id': languageId, if (body != null) 'body': body, }); } @@ -1288,20 +1395,28 @@ class DraftsCompanion extends UpdateCompanion { Value? draftType, Value? existingId, Value? replyId, + Value? active, + Value? accountId, Value? title, Value? url, Value? customThumbnail, Value? altText, + Value? nsfw, + Value? languageId, Value? body}) { return DraftsCompanion( id: id ?? this.id, draftType: draftType ?? this.draftType, existingId: existingId ?? this.existingId, replyId: replyId ?? this.replyId, + active: active ?? this.active, + accountId: accountId ?? this.accountId, title: title ?? this.title, url: url ?? this.url, customThumbnail: customThumbnail ?? this.customThumbnail, altText: altText ?? this.altText, + nsfw: nsfw ?? this.nsfw, + languageId: languageId ?? this.languageId, body: body ?? this.body, ); } @@ -1321,6 +1436,12 @@ class DraftsCompanion extends UpdateCompanion { if (replyId.present) { map['reply_id'] = Variable(replyId.value); } + if (active.present) { + map['active'] = Variable(active.value); + } + if (accountId.present) { + map['account_id'] = Variable(accountId.value); + } if (title.present) { map['title'] = Variable(title.value); } @@ -1333,6 +1454,12 @@ class DraftsCompanion extends UpdateCompanion { if (altText.present) { map['alt_text'] = Variable(altText.value); } + if (nsfw.present) { + map['nsfw'] = Variable(nsfw.value); + } + if (languageId.present) { + map['language_id'] = Variable(languageId.value); + } if (body.present) { map['body'] = Variable(body.value); } @@ -1346,10 +1473,14 @@ class DraftsCompanion extends UpdateCompanion { ..write('draftType: $draftType, ') ..write('existingId: $existingId, ') ..write('replyId: $replyId, ') + ..write('active: $active, ') + ..write('accountId: $accountId, ') ..write('title: $title, ') ..write('url: $url, ') ..write('customThumbnail: $customThumbnail, ') ..write('altText: $altText, ') + ..write('nsfw: $nsfw, ') + ..write('languageId: $languageId, ') ..write('body: $body') ..write(')')) .toString(); @@ -1898,10 +2029,14 @@ typedef $$DraftsTableCreateCompanionBuilder = DraftsCompanion Function({ required DraftType draftType, Value existingId, Value replyId, + Value active, + Value accountId, Value title, Value url, Value customThumbnail, Value altText, + Value nsfw, + Value languageId, Value body, }); typedef $$DraftsTableUpdateCompanionBuilder = DraftsCompanion Function({ @@ -1909,10 +2044,14 @@ typedef $$DraftsTableUpdateCompanionBuilder = DraftsCompanion Function({ Value draftType, Value existingId, Value replyId, + Value active, + Value accountId, Value title, Value url, Value customThumbnail, Value altText, + Value nsfw, + Value languageId, Value body, }); @@ -1932,6 +2071,10 @@ class $$DraftsTableFilterComposer extends Composer<_$AppDatabase, $DraftsTable> ColumnFilters get replyId => $composableBuilder(column: $table.replyId, builder: (column) => ColumnFilters(column)); + ColumnFilters get active => $composableBuilder(column: $table.active, builder: (column) => ColumnFilters(column)); + + ColumnFilters get accountId => $composableBuilder(column: $table.accountId, builder: (column) => ColumnFilters(column)); + ColumnFilters get title => $composableBuilder(column: $table.title, builder: (column) => ColumnFilters(column)); ColumnFilters get url => $composableBuilder(column: $table.url, builder: (column) => ColumnFilters(column)); @@ -1940,6 +2083,10 @@ class $$DraftsTableFilterComposer extends Composer<_$AppDatabase, $DraftsTable> ColumnFilters get altText => $composableBuilder(column: $table.altText, builder: (column) => ColumnFilters(column)); + ColumnFilters get nsfw => $composableBuilder(column: $table.nsfw, builder: (column) => ColumnFilters(column)); + + ColumnFilters get languageId => $composableBuilder(column: $table.languageId, builder: (column) => ColumnFilters(column)); + ColumnFilters get body => $composableBuilder(column: $table.body, builder: (column) => ColumnFilters(column)); } @@ -1959,6 +2106,10 @@ class $$DraftsTableOrderingComposer extends Composer<_$AppDatabase, $DraftsTable ColumnOrderings get replyId => $composableBuilder(column: $table.replyId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get active => $composableBuilder(column: $table.active, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get accountId => $composableBuilder(column: $table.accountId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get title => $composableBuilder(column: $table.title, builder: (column) => ColumnOrderings(column)); ColumnOrderings get url => $composableBuilder(column: $table.url, builder: (column) => ColumnOrderings(column)); @@ -1967,6 +2118,10 @@ class $$DraftsTableOrderingComposer extends Composer<_$AppDatabase, $DraftsTable ColumnOrderings get altText => $composableBuilder(column: $table.altText, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get nsfw => $composableBuilder(column: $table.nsfw, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get languageId => $composableBuilder(column: $table.languageId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get body => $composableBuilder(column: $table.body, builder: (column) => ColumnOrderings(column)); } @@ -1986,6 +2141,10 @@ class $$DraftsTableAnnotationComposer extends Composer<_$AppDatabase, $DraftsTab GeneratedColumn get replyId => $composableBuilder(column: $table.replyId, builder: (column) => column); + GeneratedColumn get active => $composableBuilder(column: $table.active, builder: (column) => column); + + GeneratedColumn get accountId => $composableBuilder(column: $table.accountId, builder: (column) => column); + GeneratedColumn get title => $composableBuilder(column: $table.title, builder: (column) => column); GeneratedColumn get url => $composableBuilder(column: $table.url, builder: (column) => column); @@ -1994,6 +2153,10 @@ class $$DraftsTableAnnotationComposer extends Composer<_$AppDatabase, $DraftsTab GeneratedColumn get altText => $composableBuilder(column: $table.altText, builder: (column) => column); + GeneratedColumn get nsfw => $composableBuilder(column: $table.nsfw, builder: (column) => column); + + GeneratedColumn get languageId => $composableBuilder(column: $table.languageId, builder: (column) => column); + GeneratedColumn get body => $composableBuilder(column: $table.body, builder: (column) => column); } @@ -2011,10 +2174,14 @@ class $$DraftsTableTableManager extends RootTableManager<_$AppDatabase, $DraftsT Value draftType = const Value.absent(), Value existingId = const Value.absent(), Value replyId = const Value.absent(), + Value active = const Value.absent(), + Value accountId = const Value.absent(), Value title = const Value.absent(), Value url = const Value.absent(), Value customThumbnail = const Value.absent(), Value altText = const Value.absent(), + Value nsfw = const Value.absent(), + Value languageId = const Value.absent(), Value body = const Value.absent(), }) => DraftsCompanion( @@ -2022,10 +2189,14 @@ class $$DraftsTableTableManager extends RootTableManager<_$AppDatabase, $DraftsT draftType: draftType, existingId: existingId, replyId: replyId, + active: active, + accountId: accountId, title: title, url: url, customThumbnail: customThumbnail, altText: altText, + nsfw: nsfw, + languageId: languageId, body: body, ), createCompanionCallback: ({ @@ -2033,10 +2204,14 @@ class $$DraftsTableTableManager extends RootTableManager<_$AppDatabase, $DraftsT required DraftType draftType, Value existingId = const Value.absent(), Value replyId = const Value.absent(), + Value active = const Value.absent(), + Value accountId = const Value.absent(), Value title = const Value.absent(), Value url = const Value.absent(), Value customThumbnail = const Value.absent(), Value altText = const Value.absent(), + Value nsfw = const Value.absent(), + Value languageId = const Value.absent(), Value body = const Value.absent(), }) => DraftsCompanion.insert( @@ -2044,10 +2219,14 @@ class $$DraftsTableTableManager extends RootTableManager<_$AppDatabase, $DraftsT draftType: draftType, existingId: existingId, replyId: replyId, + active: active, + accountId: accountId, title: title, url: url, customThumbnail: customThumbnail, altText: altText, + nsfw: nsfw, + languageId: languageId, body: body, ), withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), diff --git a/lib/src/foundation/persistence/database/database.steps.dart b/lib/src/foundation/persistence/database/database.steps.dart index 062430158..64d4d9437 100644 --- a/lib/src/foundation/persistence/database/database.steps.dart +++ b/lib/src/foundation/persistence/database/database.steps.dart @@ -1,9 +1,10 @@ // dart format width=80 import 'package:drift/internal/versioned_schema.dart' as i0; import 'package:drift/drift.dart' as i1; -import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import +import 'package:drift/drift.dart'; // GENERATED BY drift_dev, DO NOT MODIFY. -// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// final class Schema2 extends i0.VersionedSchema { Schema2({required super.database}) : super(version: 2); @override @@ -667,6 +668,169 @@ class Shape8 extends i0.VersionedTable { } i1.GeneratedColumn _column_23(String aliasedName) => i1.GeneratedColumn('platform', aliasedName, true, type: i1.DriftSqlType.string); + +final class Schema8 extends i0.VersionedSchema { + Schema8({required super.database}) : super(version: 8); + @override + late final List entities = [ + accounts, + favorites, + localSubscriptions, + userLabels, + drafts, + ]; + late final Shape9 accounts = Shape9( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_24, + _column_25, + _column_26, + _column_27, + _column_28, + _column_29, + _column_30, + _column_31, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape1 favorites = Shape1( + source: i0.VersionedTable( + entityName: 'favorites', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_24, + _column_32, + _column_33, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape2 localSubscriptions = Shape2( + source: i0.VersionedTable( + entityName: 'local_subscriptions', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_24, + _column_34, + _column_35, + _column_36, + _column_37, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape3 userLabels = Shape3( + source: i0.VersionedTable( + entityName: 'user_labels', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_24, + _column_38, + _column_39, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape10 drafts = Shape10( + source: i0.VersionedTable( + entityName: 'drafts', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_24, + _column_40, + _column_41, + _column_42, + _column_43, + _column_44, + _column_45, + _column_46, + _column_47, + _column_48, + _column_49, + _column_50, + _column_51, + ], + attachedDatabase: database, + ), + alias: null); +} + +class Shape9 extends i0.VersionedTable { + Shape9({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get username => columnsByName['username']! as i1.GeneratedColumn; + i1.GeneratedColumn get jwt => columnsByName['jwt']! as i1.GeneratedColumn; + i1.GeneratedColumn get instance => columnsByName['instance']! as i1.GeneratedColumn; + i1.GeneratedColumn get anonymous => columnsByName['anonymous']! as i1.GeneratedColumn; + i1.GeneratedColumn get userId => columnsByName['user_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get listIndex => columnsByName['list_index']! as i1.GeneratedColumn; + i1.GeneratedColumn get platform => columnsByName['platform']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_24(String aliasedName) => + i1.GeneratedColumn('id', aliasedName, false, hasAutoIncrement: true, type: i1.DriftSqlType.int, $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT'); +i1.GeneratedColumn _column_25(String aliasedName) => i1.GeneratedColumn('username', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_26(String aliasedName) => i1.GeneratedColumn('jwt', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_27(String aliasedName) => i1.GeneratedColumn('instance', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_28(String aliasedName) => i1.GeneratedColumn('anonymous', aliasedName, false, + type: i1.DriftSqlType.int, $customConstraints: 'NOT NULL DEFAULT 0 CHECK (anonymous IN (0, 1))', defaultValue: const i1.CustomExpression('0')); +i1.GeneratedColumn _column_29(String aliasedName) => i1.GeneratedColumn('user_id', aliasedName, true, type: i1.DriftSqlType.int, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_30(String aliasedName) => + i1.GeneratedColumn('list_index', aliasedName, false, type: i1.DriftSqlType.int, $customConstraints: 'NOT NULL DEFAULT (-1)', defaultValue: const i1.CustomExpression('-1')); +i1.GeneratedColumn _column_31(String aliasedName) => i1.GeneratedColumn('platform', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_32(String aliasedName) => i1.GeneratedColumn('account_id', aliasedName, false, type: i1.DriftSqlType.int, $customConstraints: 'NOT NULL'); +i1.GeneratedColumn _column_33(String aliasedName) => i1.GeneratedColumn('community_id', aliasedName, false, type: i1.DriftSqlType.int, $customConstraints: 'NOT NULL'); +i1.GeneratedColumn _column_34(String aliasedName) => i1.GeneratedColumn('name', aliasedName, false, type: i1.DriftSqlType.string, $customConstraints: 'NOT NULL'); +i1.GeneratedColumn _column_35(String aliasedName) => i1.GeneratedColumn('title', aliasedName, false, type: i1.DriftSqlType.string, $customConstraints: 'NOT NULL'); +i1.GeneratedColumn _column_36(String aliasedName) => i1.GeneratedColumn('actor_id', aliasedName, false, type: i1.DriftSqlType.string, $customConstraints: 'NOT NULL'); +i1.GeneratedColumn _column_37(String aliasedName) => i1.GeneratedColumn('icon', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_38(String aliasedName) => i1.GeneratedColumn('username', aliasedName, false, type: i1.DriftSqlType.string, $customConstraints: 'NOT NULL'); +i1.GeneratedColumn _column_39(String aliasedName) => i1.GeneratedColumn('label', aliasedName, false, type: i1.DriftSqlType.string, $customConstraints: 'NOT NULL'); + +class Shape10 extends i0.VersionedTable { + Shape10({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get draftType => columnsByName['draft_type']! as i1.GeneratedColumn; + i1.GeneratedColumn get existingId => columnsByName['existing_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get replyId => columnsByName['reply_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get active => columnsByName['active']! as i1.GeneratedColumn; + i1.GeneratedColumn get accountId => columnsByName['account_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get title => columnsByName['title']! as i1.GeneratedColumn; + i1.GeneratedColumn get url => columnsByName['url']! as i1.GeneratedColumn; + i1.GeneratedColumn get customThumbnail => columnsByName['custom_thumbnail']! as i1.GeneratedColumn; + i1.GeneratedColumn get altText => columnsByName['alt_text']! as i1.GeneratedColumn; + i1.GeneratedColumn get nsfw => columnsByName['nsfw']! as i1.GeneratedColumn; + i1.GeneratedColumn get languageId => columnsByName['language_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get body => columnsByName['body']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_40(String aliasedName) => i1.GeneratedColumn('draft_type', aliasedName, false, type: i1.DriftSqlType.string, $customConstraints: 'NOT NULL'); +i1.GeneratedColumn _column_41(String aliasedName) => i1.GeneratedColumn('existing_id', aliasedName, true, type: i1.DriftSqlType.int, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_42(String aliasedName) => i1.GeneratedColumn('reply_id', aliasedName, true, type: i1.DriftSqlType.int, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_43(String aliasedName) => + i1.GeneratedColumn('active', aliasedName, false, type: i1.DriftSqlType.int, $customConstraints: 'NOT NULL DEFAULT 0 CHECK (active IN (0, 1))', defaultValue: const i1.CustomExpression('0')); +i1.GeneratedColumn _column_44(String aliasedName) => i1.GeneratedColumn('account_id', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_45(String aliasedName) => i1.GeneratedColumn('title', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_46(String aliasedName) => i1.GeneratedColumn('url', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_47(String aliasedName) => i1.GeneratedColumn('custom_thumbnail', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_48(String aliasedName) => i1.GeneratedColumn('alt_text', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_49(String aliasedName) => + i1.GeneratedColumn('nsfw', aliasedName, false, type: i1.DriftSqlType.int, $customConstraints: 'NOT NULL DEFAULT 0 CHECK (nsfw IN (0, 1))', defaultValue: const i1.CustomExpression('0')); +i1.GeneratedColumn _column_50(String aliasedName) => i1.GeneratedColumn('language_id', aliasedName, true, type: i1.DriftSqlType.int, $customConstraints: 'NULL'); +i1.GeneratedColumn _column_51(String aliasedName) => i1.GeneratedColumn('body', aliasedName, true, type: i1.DriftSqlType.string, $customConstraints: 'NULL'); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -674,6 +838,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -707,6 +872,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from6To7(migrator, schema); return 7; + case 7: + final schema = Schema8(database: database); + final migrator = i1.Migrator(database, schema); + await from7To8(migrator, schema); + return 8; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -720,6 +890,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( @@ -729,4 +900,5 @@ i1.OnUpgrade stepByStep({ from4To5: from4To5, from5To6: from5To6, from6To7: from6To7, + from7To8: from7To8, )); diff --git a/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v8.json b/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v8.json new file mode 100644 index 000000000..694acdabd --- /dev/null +++ b/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v8.json @@ -0,0 +1,498 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "accounts", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "username", + "getter_name": "username", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "jwt", + "getter_name": "jwt", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "instance", + "getter_name": "instance", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "anonymous", + "getter_name": "anonymous", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"anonymous\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"anonymous\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "user_id", + "getter_name": "userId", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "list_index", + "getter_name": "listIndex", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('-1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "platform", + "getter_name": "platform", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ThreadiversePlatformConverter()", + "dart_type_name": "ThreadiversePlatform?" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "favorites", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "account_id", + "getter_name": "accountId", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "community_id", + "getter_name": "communityId", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "local_subscriptions", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "actor_id", + "getter_name": "actorId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "icon", + "getter_name": "icon", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "user_labels", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "username", + "getter_name": "username", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "label", + "getter_name": "label", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "drafts", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "draft_type", + "getter_name": "draftType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const DraftTypeConverter()", + "dart_type_name": "DraftType" + } + }, + { + "name": "existing_id", + "getter_name": "existingId", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "reply_id", + "getter_name": "replyId", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "active", + "getter_name": "active", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"active\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"active\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "account_id", + "getter_name": "accountId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "custom_thumbnail", + "getter_name": "customThumbnail", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "alt_text", + "getter_name": "altText", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "nsfw", + "getter_name": "nsfw", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"nsfw\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"nsfw\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language_id", + "getter_name": "languageId", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "body", + "getter_name": "body", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + } + ], + "fixed_sql": [ + { + "name": "accounts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"accounts\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"username\" TEXT NULL, \"jwt\" TEXT NULL, \"instance\" TEXT NULL, \"anonymous\" INTEGER NOT NULL DEFAULT 0 CHECK (\"anonymous\" IN (0, 1)), \"user_id\" INTEGER NULL, \"list_index\" INTEGER NOT NULL DEFAULT -1, \"platform\" TEXT NULL);" + } + ] + }, + { + "name": "favorites", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"favorites\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"account_id\" INTEGER NOT NULL, \"community_id\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "local_subscriptions", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"local_subscriptions\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"name\" TEXT NOT NULL, \"title\" TEXT NOT NULL, \"actor_id\" TEXT NOT NULL, \"icon\" TEXT NULL);" + } + ] + }, + { + "name": "user_labels", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"user_labels\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"username\" TEXT NOT NULL, \"label\" TEXT NOT NULL);" + } + ] + }, + { + "name": "drafts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"drafts\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"draft_type\" TEXT NOT NULL, \"existing_id\" INTEGER NULL, \"reply_id\" INTEGER NULL, \"active\" INTEGER NOT NULL DEFAULT 0 CHECK (\"active\" IN (0, 1)), \"account_id\" TEXT NULL, \"title\" TEXT NULL, \"url\" TEXT NULL, \"custom_thumbnail\" TEXT NULL, \"alt_text\" TEXT NULL, \"nsfw\" INTEGER NOT NULL DEFAULT 0 CHECK (\"nsfw\" IN (0, 1)), \"language_id\" INTEGER NULL, \"body\" TEXT NULL);" + } + ] + } + ] +} \ No newline at end of file diff --git a/lib/src/foundation/persistence/database/tables.dart b/lib/src/foundation/persistence/database/tables.dart index 5deb4580d..6ab0b77de 100644 --- a/lib/src/foundation/persistence/database/tables.dart +++ b/lib/src/foundation/persistence/database/tables.dart @@ -38,9 +38,13 @@ class Drafts extends Table { TextColumn get draftType => text().map(const DraftTypeConverter())(); IntColumn get existingId => integer().nullable()(); IntColumn get replyId => integer().nullable()(); + BoolColumn get active => boolean().withDefault(const Constant(false))(); + TextColumn get accountId => text().nullable()(); TextColumn get title => text().nullable()(); TextColumn get url => text().nullable()(); TextColumn get customThumbnail => text().nullable()(); TextColumn get altText => text().nullable()(); + BoolColumn get nsfw => boolean().withDefault(const Constant(false))(); + IntColumn get languageId => integer().nullable()(); TextColumn get body => text().nullable()(); } diff --git a/lib/src/foundation/primitives/enums/draft_type.dart b/lib/src/foundation/primitives/enums/draft_type.dart index 0a24b2149..dbc309593 100644 --- a/lib/src/foundation/primitives/enums/draft_type.dart +++ b/lib/src/foundation/primitives/enums/draft_type.dart @@ -2,7 +2,15 @@ enum DraftType { commentEdit, commentCreate, + commentCreateFromPost, + commentCreateFromComment, postEdit, postCreate, postCreateGeneral, } + +extension DraftTypeExtension on DraftType { + bool get isCommentCreate => this == DraftType.commentCreate || this == DraftType.commentCreateFromPost || this == DraftType.commentCreateFromComment; + + bool get isPostCreate => this == DraftType.postCreate || this == DraftType.postCreateGeneral; +} diff --git a/lib/src/shared/language_selector.dart b/lib/src/shared/language_selector.dart index 2b7000f8a..f73caf31f 100644 --- a/lib/src/shared/language_selector.dart +++ b/lib/src/shared/language_selector.dart @@ -33,23 +33,12 @@ class LanguageSelector extends StatefulWidget { } class _LanguageSelectorState extends State { - late int? _languageId; - late ThunderLanguage? _language; - - @override - void initState() { - super.initState(); - _languageId = widget.languageId; - - // Determine the language from the languageId - final languages = context.read().state.siteResponse?.allLanguages ?? []; - _language = languages.firstWhereOrNull((ThunderLanguage language) => language.id == _languageId); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final theme = Theme.of(context); + final languages = context.read().state.siteResponse?.allLanguages ?? []; + final language = languages.firstWhereOrNull((ThunderLanguage candidate) => candidate.id == widget.languageId); return Transform.translate( offset: const Offset(-8, 0), @@ -60,13 +49,8 @@ class _LanguageSelectorState extends State { title: l10n.language, onLanguageSelected: (language) { if (language.id == -1) { - setState(() => _languageId = _language = null); widget.onLanguageSelected(null); } else { - setState(() { - _languageId = language.id; - _language = language; - }); widget.onLanguageSelected(language); } }, @@ -79,7 +63,7 @@ class _LanguageSelectorState extends State { softWrap: true, TextSpan( children: [ - TextSpan(text: _language != null ? '${l10n.language}: ${_language?.name}' : l10n.selectLanguage), + TextSpan(text: language != null ? '${l10n.language}: ${language.name}' : l10n.selectLanguage), const WidgetSpan( alignment: PlaceholderAlignment.middle, child: Icon(Icons.chevron_right_rounded), diff --git a/test/drift/thunder/generated/schema.dart b/test/drift/thunder/generated/schema.dart index 87de9194d..f76d308b7 100644 --- a/test/drift/thunder/generated/schema.dart +++ b/test/drift/thunder/generated/schema.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; import 'package:drift/internal/migrations.dart'; import 'schema_v1.dart' as v1; @@ -10,6 +11,7 @@ import 'schema_v4.dart' as v4; import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; +import 'schema_v8.dart' as v8; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -29,10 +31,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v6.DatabaseAtV6(db); case 7: return v7.DatabaseAtV7(db); + case 8: + return v8.DatabaseAtV8(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8]; } diff --git a/test/drift/thunder/generated/schema_v1.dart b/test/drift/thunder/generated/schema_v1.dart index cc1782ecb..5e6d401c4 100644 --- a/test/drift/thunder/generated/schema_v1.dart +++ b/test/drift/thunder/generated/schema_v1.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Accounts extends Table with TableInfo { diff --git a/test/drift/thunder/generated/schema_v2.dart b/test/drift/thunder/generated/schema_v2.dart index 549245762..5dca542f4 100644 --- a/test/drift/thunder/generated/schema_v2.dart +++ b/test/drift/thunder/generated/schema_v2.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Accounts extends Table with TableInfo { diff --git a/test/drift/thunder/generated/schema_v3.dart b/test/drift/thunder/generated/schema_v3.dart index aea2798e8..5add1778e 100644 --- a/test/drift/thunder/generated/schema_v3.dart +++ b/test/drift/thunder/generated/schema_v3.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Accounts extends Table with TableInfo { diff --git a/test/drift/thunder/generated/schema_v4.dart b/test/drift/thunder/generated/schema_v4.dart index 45503ea31..22ba7b1e2 100644 --- a/test/drift/thunder/generated/schema_v4.dart +++ b/test/drift/thunder/generated/schema_v4.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Accounts extends Table with TableInfo { diff --git a/test/drift/thunder/generated/schema_v5.dart b/test/drift/thunder/generated/schema_v5.dart index cb33e8d6c..194afa624 100644 --- a/test/drift/thunder/generated/schema_v5.dart +++ b/test/drift/thunder/generated/schema_v5.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Accounts extends Table with TableInfo { diff --git a/test/drift/thunder/generated/schema_v6.dart b/test/drift/thunder/generated/schema_v6.dart index b14b91c9b..66e1cad4b 100644 --- a/test/drift/thunder/generated/schema_v6.dart +++ b/test/drift/thunder/generated/schema_v6.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Accounts extends Table with TableInfo { diff --git a/test/drift/thunder/generated/schema_v7.dart b/test/drift/thunder/generated/schema_v7.dart index 2a00fb3f2..e1cafd91e 100644 --- a/test/drift/thunder/generated/schema_v7.dart +++ b/test/drift/thunder/generated/schema_v7.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Accounts extends Table with TableInfo { diff --git a/test/drift/thunder/generated/schema_v8.dart b/test/drift/thunder/generated/schema_v8.dart new file mode 100644 index 000000000..0b5168996 --- /dev/null +++ b/test/drift/thunder/generated/schema_v8.dart @@ -0,0 +1,1571 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT'); + late final GeneratedColumn username = GeneratedColumn( + 'username', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn jwt = GeneratedColumn( + 'jwt', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn instance = GeneratedColumn( + 'instance', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn anonymous = GeneratedColumn( + 'anonymous', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (anonymous IN (0, 1))', + defaultValue: const CustomExpression('0')); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn listIndex = GeneratedColumn( + 'list_index', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT (-1)', + defaultValue: const CustomExpression('-1')); + late final GeneratedColumn platform = GeneratedColumn( + 'platform', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + @override + List get $columns => + [id, username, jwt, instance, anonymous, userId, listIndex, platform]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + username: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}username']), + jwt: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}jwt']), + instance: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}instance']), + anonymous: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}anonymous'])!, + userId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}user_id']), + listIndex: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}list_index'])!, + platform: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}platform']), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String? username; + final String? jwt; + final String? instance; + final int anonymous; + final int? userId; + final int listIndex; + final String? platform; + const AccountsData( + {required this.id, + this.username, + this.jwt, + this.instance, + required this.anonymous, + this.userId, + required this.listIndex, + this.platform}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || username != null) { + map['username'] = Variable(username); + } + if (!nullToAbsent || jwt != null) { + map['jwt'] = Variable(jwt); + } + if (!nullToAbsent || instance != null) { + map['instance'] = Variable(instance); + } + map['anonymous'] = Variable(anonymous); + if (!nullToAbsent || userId != null) { + map['user_id'] = Variable(userId); + } + map['list_index'] = Variable(listIndex); + if (!nullToAbsent || platform != null) { + map['platform'] = Variable(platform); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + username: username == null && nullToAbsent + ? const Value.absent() + : Value(username), + jwt: jwt == null && nullToAbsent ? const Value.absent() : Value(jwt), + instance: instance == null && nullToAbsent + ? const Value.absent() + : Value(instance), + anonymous: Value(anonymous), + userId: + userId == null && nullToAbsent ? const Value.absent() : Value(userId), + listIndex: Value(listIndex), + platform: platform == null && nullToAbsent + ? const Value.absent() + : Value(platform), + ); + } + + factory AccountsData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + username: serializer.fromJson(json['username']), + jwt: serializer.fromJson(json['jwt']), + instance: serializer.fromJson(json['instance']), + anonymous: serializer.fromJson(json['anonymous']), + userId: serializer.fromJson(json['userId']), + listIndex: serializer.fromJson(json['listIndex']), + platform: serializer.fromJson(json['platform']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'username': serializer.toJson(username), + 'jwt': serializer.toJson(jwt), + 'instance': serializer.toJson(instance), + 'anonymous': serializer.toJson(anonymous), + 'userId': serializer.toJson(userId), + 'listIndex': serializer.toJson(listIndex), + 'platform': serializer.toJson(platform), + }; + } + + AccountsData copyWith( + {int? id, + Value username = const Value.absent(), + Value jwt = const Value.absent(), + Value instance = const Value.absent(), + int? anonymous, + Value userId = const Value.absent(), + int? listIndex, + Value platform = const Value.absent()}) => + AccountsData( + id: id ?? this.id, + username: username.present ? username.value : this.username, + jwt: jwt.present ? jwt.value : this.jwt, + instance: instance.present ? instance.value : this.instance, + anonymous: anonymous ?? this.anonymous, + userId: userId.present ? userId.value : this.userId, + listIndex: listIndex ?? this.listIndex, + platform: platform.present ? platform.value : this.platform, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + username: data.username.present ? data.username.value : this.username, + jwt: data.jwt.present ? data.jwt.value : this.jwt, + instance: data.instance.present ? data.instance.value : this.instance, + anonymous: data.anonymous.present ? data.anonymous.value : this.anonymous, + userId: data.userId.present ? data.userId.value : this.userId, + listIndex: data.listIndex.present ? data.listIndex.value : this.listIndex, + platform: data.platform.present ? data.platform.value : this.platform, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('username: $username, ') + ..write('jwt: $jwt, ') + ..write('instance: $instance, ') + ..write('anonymous: $anonymous, ') + ..write('userId: $userId, ') + ..write('listIndex: $listIndex, ') + ..write('platform: $platform') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, username, jwt, instance, anonymous, userId, listIndex, platform); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.username == this.username && + other.jwt == this.jwt && + other.instance == this.instance && + other.anonymous == this.anonymous && + other.userId == this.userId && + other.listIndex == this.listIndex && + other.platform == this.platform); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value username; + final Value jwt; + final Value instance; + final Value anonymous; + final Value userId; + final Value listIndex; + final Value platform; + const AccountsCompanion({ + this.id = const Value.absent(), + this.username = const Value.absent(), + this.jwt = const Value.absent(), + this.instance = const Value.absent(), + this.anonymous = const Value.absent(), + this.userId = const Value.absent(), + this.listIndex = const Value.absent(), + this.platform = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + this.username = const Value.absent(), + this.jwt = const Value.absent(), + this.instance = const Value.absent(), + this.anonymous = const Value.absent(), + this.userId = const Value.absent(), + this.listIndex = const Value.absent(), + this.platform = const Value.absent(), + }); + static Insertable custom({ + Expression? id, + Expression? username, + Expression? jwt, + Expression? instance, + Expression? anonymous, + Expression? userId, + Expression? listIndex, + Expression? platform, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (username != null) 'username': username, + if (jwt != null) 'jwt': jwt, + if (instance != null) 'instance': instance, + if (anonymous != null) 'anonymous': anonymous, + if (userId != null) 'user_id': userId, + if (listIndex != null) 'list_index': listIndex, + if (platform != null) 'platform': platform, + }); + } + + AccountsCompanion copyWith( + {Value? id, + Value? username, + Value? jwt, + Value? instance, + Value? anonymous, + Value? userId, + Value? listIndex, + Value? platform}) { + return AccountsCompanion( + id: id ?? this.id, + username: username ?? this.username, + jwt: jwt ?? this.jwt, + instance: instance ?? this.instance, + anonymous: anonymous ?? this.anonymous, + userId: userId ?? this.userId, + listIndex: listIndex ?? this.listIndex, + platform: platform ?? this.platform, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (username.present) { + map['username'] = Variable(username.value); + } + if (jwt.present) { + map['jwt'] = Variable(jwt.value); + } + if (instance.present) { + map['instance'] = Variable(instance.value); + } + if (anonymous.present) { + map['anonymous'] = Variable(anonymous.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (listIndex.present) { + map['list_index'] = Variable(listIndex.value); + } + if (platform.present) { + map['platform'] = Variable(platform.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('username: $username, ') + ..write('jwt: $jwt, ') + ..write('instance: $instance, ') + ..write('anonymous: $anonymous, ') + ..write('userId: $userId, ') + ..write('listIndex: $listIndex, ') + ..write('platform: $platform') + ..write(')')) + .toString(); + } +} + +class Favorites extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Favorites(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT'); + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn communityId = GeneratedColumn( + 'community_id', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + @override + List get $columns => [id, accountId, communityId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'favorites'; + @override + Set get $primaryKey => {id}; + @override + FavoritesData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FavoritesData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + accountId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}account_id'])!, + communityId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}community_id'])!, + ); + } + + @override + Favorites createAlias(String alias) { + return Favorites(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class FavoritesData extends DataClass implements Insertable { + final int id; + final int accountId; + final int communityId; + const FavoritesData( + {required this.id, required this.accountId, required this.communityId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['account_id'] = Variable(accountId); + map['community_id'] = Variable(communityId); + return map; + } + + FavoritesCompanion toCompanion(bool nullToAbsent) { + return FavoritesCompanion( + id: Value(id), + accountId: Value(accountId), + communityId: Value(communityId), + ); + } + + factory FavoritesData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return FavoritesData( + id: serializer.fromJson(json['id']), + accountId: serializer.fromJson(json['accountId']), + communityId: serializer.fromJson(json['communityId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'accountId': serializer.toJson(accountId), + 'communityId': serializer.toJson(communityId), + }; + } + + FavoritesData copyWith({int? id, int? accountId, int? communityId}) => + FavoritesData( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + communityId: communityId ?? this.communityId, + ); + FavoritesData copyWithCompanion(FavoritesCompanion data) { + return FavoritesData( + id: data.id.present ? data.id.value : this.id, + accountId: data.accountId.present ? data.accountId.value : this.accountId, + communityId: + data.communityId.present ? data.communityId.value : this.communityId, + ); + } + + @override + String toString() { + return (StringBuffer('FavoritesData(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('communityId: $communityId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, accountId, communityId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FavoritesData && + other.id == this.id && + other.accountId == this.accountId && + other.communityId == this.communityId); +} + +class FavoritesCompanion extends UpdateCompanion { + final Value id; + final Value accountId; + final Value communityId; + const FavoritesCompanion({ + this.id = const Value.absent(), + this.accountId = const Value.absent(), + this.communityId = const Value.absent(), + }); + FavoritesCompanion.insert({ + this.id = const Value.absent(), + required int accountId, + required int communityId, + }) : accountId = Value(accountId), + communityId = Value(communityId); + static Insertable custom({ + Expression? id, + Expression? accountId, + Expression? communityId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (accountId != null) 'account_id': accountId, + if (communityId != null) 'community_id': communityId, + }); + } + + FavoritesCompanion copyWith( + {Value? id, Value? accountId, Value? communityId}) { + return FavoritesCompanion( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + communityId: communityId ?? this.communityId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (accountId.present) { + map['account_id'] = Variable(accountId.value); + } + if (communityId.present) { + map['community_id'] = Variable(communityId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FavoritesCompanion(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('communityId: $communityId') + ..write(')')) + .toString(); + } +} + +class LocalSubscriptions extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalSubscriptions(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT'); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn actorId = GeneratedColumn( + 'actor_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn icon = GeneratedColumn( + 'icon', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + @override + List get $columns => [id, name, title, actorId, icon]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_subscriptions'; + @override + Set get $primaryKey => {id}; + @override + LocalSubscriptionsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalSubscriptionsData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + actorId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}actor_id'])!, + icon: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}icon']), + ); + } + + @override + LocalSubscriptions createAlias(String alias) { + return LocalSubscriptions(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class LocalSubscriptionsData extends DataClass + implements Insertable { + final int id; + final String name; + final String title; + final String actorId; + final String? icon; + const LocalSubscriptionsData( + {required this.id, + required this.name, + required this.title, + required this.actorId, + this.icon}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['title'] = Variable(title); + map['actor_id'] = Variable(actorId); + if (!nullToAbsent || icon != null) { + map['icon'] = Variable(icon); + } + return map; + } + + LocalSubscriptionsCompanion toCompanion(bool nullToAbsent) { + return LocalSubscriptionsCompanion( + id: Value(id), + name: Value(name), + title: Value(title), + actorId: Value(actorId), + icon: icon == null && nullToAbsent ? const Value.absent() : Value(icon), + ); + } + + factory LocalSubscriptionsData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalSubscriptionsData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + title: serializer.fromJson(json['title']), + actorId: serializer.fromJson(json['actorId']), + icon: serializer.fromJson(json['icon']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'title': serializer.toJson(title), + 'actorId': serializer.toJson(actorId), + 'icon': serializer.toJson(icon), + }; + } + + LocalSubscriptionsData copyWith( + {int? id, + String? name, + String? title, + String? actorId, + Value icon = const Value.absent()}) => + LocalSubscriptionsData( + id: id ?? this.id, + name: name ?? this.name, + title: title ?? this.title, + actorId: actorId ?? this.actorId, + icon: icon.present ? icon.value : this.icon, + ); + LocalSubscriptionsData copyWithCompanion(LocalSubscriptionsCompanion data) { + return LocalSubscriptionsData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + title: data.title.present ? data.title.value : this.title, + actorId: data.actorId.present ? data.actorId.value : this.actorId, + icon: data.icon.present ? data.icon.value : this.icon, + ); + } + + @override + String toString() { + return (StringBuffer('LocalSubscriptionsData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('title: $title, ') + ..write('actorId: $actorId, ') + ..write('icon: $icon') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, title, actorId, icon); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalSubscriptionsData && + other.id == this.id && + other.name == this.name && + other.title == this.title && + other.actorId == this.actorId && + other.icon == this.icon); +} + +class LocalSubscriptionsCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value title; + final Value actorId; + final Value icon; + const LocalSubscriptionsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.title = const Value.absent(), + this.actorId = const Value.absent(), + this.icon = const Value.absent(), + }); + LocalSubscriptionsCompanion.insert({ + this.id = const Value.absent(), + required String name, + required String title, + required String actorId, + this.icon = const Value.absent(), + }) : name = Value(name), + title = Value(title), + actorId = Value(actorId); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? title, + Expression? actorId, + Expression? icon, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (title != null) 'title': title, + if (actorId != null) 'actor_id': actorId, + if (icon != null) 'icon': icon, + }); + } + + LocalSubscriptionsCompanion copyWith( + {Value? id, + Value? name, + Value? title, + Value? actorId, + Value? icon}) { + return LocalSubscriptionsCompanion( + id: id ?? this.id, + name: name ?? this.name, + title: title ?? this.title, + actorId: actorId ?? this.actorId, + icon: icon ?? this.icon, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (actorId.present) { + map['actor_id'] = Variable(actorId.value); + } + if (icon.present) { + map['icon'] = Variable(icon.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalSubscriptionsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('title: $title, ') + ..write('actorId: $actorId, ') + ..write('icon: $icon') + ..write(')')) + .toString(); + } +} + +class UserLabels extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserLabels(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT'); + late final GeneratedColumn username = GeneratedColumn( + 'username', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn label = GeneratedColumn( + 'label', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + @override + List get $columns => [id, username, label]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_labels'; + @override + Set get $primaryKey => {id}; + @override + UserLabelsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserLabelsData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + username: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}username'])!, + label: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}label'])!, + ); + } + + @override + UserLabels createAlias(String alias) { + return UserLabels(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class UserLabelsData extends DataClass implements Insertable { + final int id; + final String username; + final String label; + const UserLabelsData( + {required this.id, required this.username, required this.label}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['username'] = Variable(username); + map['label'] = Variable(label); + return map; + } + + UserLabelsCompanion toCompanion(bool nullToAbsent) { + return UserLabelsCompanion( + id: Value(id), + username: Value(username), + label: Value(label), + ); + } + + factory UserLabelsData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserLabelsData( + id: serializer.fromJson(json['id']), + username: serializer.fromJson(json['username']), + label: serializer.fromJson(json['label']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'username': serializer.toJson(username), + 'label': serializer.toJson(label), + }; + } + + UserLabelsData copyWith({int? id, String? username, String? label}) => + UserLabelsData( + id: id ?? this.id, + username: username ?? this.username, + label: label ?? this.label, + ); + UserLabelsData copyWithCompanion(UserLabelsCompanion data) { + return UserLabelsData( + id: data.id.present ? data.id.value : this.id, + username: data.username.present ? data.username.value : this.username, + label: data.label.present ? data.label.value : this.label, + ); + } + + @override + String toString() { + return (StringBuffer('UserLabelsData(') + ..write('id: $id, ') + ..write('username: $username, ') + ..write('label: $label') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, username, label); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserLabelsData && + other.id == this.id && + other.username == this.username && + other.label == this.label); +} + +class UserLabelsCompanion extends UpdateCompanion { + final Value id; + final Value username; + final Value label; + const UserLabelsCompanion({ + this.id = const Value.absent(), + this.username = const Value.absent(), + this.label = const Value.absent(), + }); + UserLabelsCompanion.insert({ + this.id = const Value.absent(), + required String username, + required String label, + }) : username = Value(username), + label = Value(label); + static Insertable custom({ + Expression? id, + Expression? username, + Expression? label, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (username != null) 'username': username, + if (label != null) 'label': label, + }); + } + + UserLabelsCompanion copyWith( + {Value? id, Value? username, Value? label}) { + return UserLabelsCompanion( + id: id ?? this.id, + username: username ?? this.username, + label: label ?? this.label, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (username.present) { + map['username'] = Variable(username.value); + } + if (label.present) { + map['label'] = Variable(label.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserLabelsCompanion(') + ..write('id: $id, ') + ..write('username: $username, ') + ..write('label: $label') + ..write(')')) + .toString(); + } +} + +class Drafts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Drafts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT'); + late final GeneratedColumn draftType = GeneratedColumn( + 'draft_type', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn existingId = GeneratedColumn( + 'existing_id', aliasedName, true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn replyId = GeneratedColumn( + 'reply_id', aliasedName, true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn active = GeneratedColumn( + 'active', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (active IN (0, 1))', + defaultValue: const CustomExpression('0')); + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn url = GeneratedColumn( + 'url', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn customThumbnail = GeneratedColumn( + 'custom_thumbnail', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn altText = GeneratedColumn( + 'alt_text', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn nsfw = GeneratedColumn( + 'nsfw', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (nsfw IN (0, 1))', + defaultValue: const CustomExpression('0')); + late final GeneratedColumn languageId = GeneratedColumn( + 'language_id', aliasedName, true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + late final GeneratedColumn body = GeneratedColumn( + 'body', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL'); + @override + List get $columns => [ + id, + draftType, + existingId, + replyId, + active, + accountId, + title, + url, + customThumbnail, + altText, + nsfw, + languageId, + body + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'drafts'; + @override + Set get $primaryKey => {id}; + @override + DraftsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return DraftsData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + draftType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}draft_type'])!, + existingId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}existing_id']), + replyId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}reply_id']), + active: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}active'])!, + accountId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}account_id']), + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title']), + url: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}url']), + customThumbnail: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}custom_thumbnail']), + altText: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}alt_text']), + nsfw: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}nsfw'])!, + languageId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}language_id']), + body: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}body']), + ); + } + + @override + Drafts createAlias(String alias) { + return Drafts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class DraftsData extends DataClass implements Insertable { + final int id; + final String draftType; + final int? existingId; + final int? replyId; + final int active; + final String? accountId; + final String? title; + final String? url; + final String? customThumbnail; + final String? altText; + final int nsfw; + final int? languageId; + final String? body; + const DraftsData( + {required this.id, + required this.draftType, + this.existingId, + this.replyId, + required this.active, + this.accountId, + this.title, + this.url, + this.customThumbnail, + this.altText, + required this.nsfw, + this.languageId, + this.body}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['draft_type'] = Variable(draftType); + if (!nullToAbsent || existingId != null) { + map['existing_id'] = Variable(existingId); + } + if (!nullToAbsent || replyId != null) { + map['reply_id'] = Variable(replyId); + } + map['active'] = Variable(active); + if (!nullToAbsent || accountId != null) { + map['account_id'] = Variable(accountId); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || customThumbnail != null) { + map['custom_thumbnail'] = Variable(customThumbnail); + } + if (!nullToAbsent || altText != null) { + map['alt_text'] = Variable(altText); + } + map['nsfw'] = Variable(nsfw); + if (!nullToAbsent || languageId != null) { + map['language_id'] = Variable(languageId); + } + if (!nullToAbsent || body != null) { + map['body'] = Variable(body); + } + return map; + } + + DraftsCompanion toCompanion(bool nullToAbsent) { + return DraftsCompanion( + id: Value(id), + draftType: Value(draftType), + existingId: existingId == null && nullToAbsent + ? const Value.absent() + : Value(existingId), + replyId: replyId == null && nullToAbsent + ? const Value.absent() + : Value(replyId), + active: Value(active), + accountId: accountId == null && nullToAbsent + ? const Value.absent() + : Value(accountId), + title: + title == null && nullToAbsent ? const Value.absent() : Value(title), + url: url == null && nullToAbsent ? const Value.absent() : Value(url), + customThumbnail: customThumbnail == null && nullToAbsent + ? const Value.absent() + : Value(customThumbnail), + altText: altText == null && nullToAbsent + ? const Value.absent() + : Value(altText), + nsfw: Value(nsfw), + languageId: languageId == null && nullToAbsent + ? const Value.absent() + : Value(languageId), + body: body == null && nullToAbsent ? const Value.absent() : Value(body), + ); + } + + factory DraftsData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return DraftsData( + id: serializer.fromJson(json['id']), + draftType: serializer.fromJson(json['draftType']), + existingId: serializer.fromJson(json['existingId']), + replyId: serializer.fromJson(json['replyId']), + active: serializer.fromJson(json['active']), + accountId: serializer.fromJson(json['accountId']), + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + customThumbnail: serializer.fromJson(json['customThumbnail']), + altText: serializer.fromJson(json['altText']), + nsfw: serializer.fromJson(json['nsfw']), + languageId: serializer.fromJson(json['languageId']), + body: serializer.fromJson(json['body']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'draftType': serializer.toJson(draftType), + 'existingId': serializer.toJson(existingId), + 'replyId': serializer.toJson(replyId), + 'active': serializer.toJson(active), + 'accountId': serializer.toJson(accountId), + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'customThumbnail': serializer.toJson(customThumbnail), + 'altText': serializer.toJson(altText), + 'nsfw': serializer.toJson(nsfw), + 'languageId': serializer.toJson(languageId), + 'body': serializer.toJson(body), + }; + } + + DraftsData copyWith( + {int? id, + String? draftType, + Value existingId = const Value.absent(), + Value replyId = const Value.absent(), + int? active, + Value accountId = const Value.absent(), + Value title = const Value.absent(), + Value url = const Value.absent(), + Value customThumbnail = const Value.absent(), + Value altText = const Value.absent(), + int? nsfw, + Value languageId = const Value.absent(), + Value body = const Value.absent()}) => + DraftsData( + id: id ?? this.id, + draftType: draftType ?? this.draftType, + existingId: existingId.present ? existingId.value : this.existingId, + replyId: replyId.present ? replyId.value : this.replyId, + active: active ?? this.active, + accountId: accountId.present ? accountId.value : this.accountId, + title: title.present ? title.value : this.title, + url: url.present ? url.value : this.url, + customThumbnail: customThumbnail.present + ? customThumbnail.value + : this.customThumbnail, + altText: altText.present ? altText.value : this.altText, + nsfw: nsfw ?? this.nsfw, + languageId: languageId.present ? languageId.value : this.languageId, + body: body.present ? body.value : this.body, + ); + DraftsData copyWithCompanion(DraftsCompanion data) { + return DraftsData( + id: data.id.present ? data.id.value : this.id, + draftType: data.draftType.present ? data.draftType.value : this.draftType, + existingId: + data.existingId.present ? data.existingId.value : this.existingId, + replyId: data.replyId.present ? data.replyId.value : this.replyId, + active: data.active.present ? data.active.value : this.active, + accountId: data.accountId.present ? data.accountId.value : this.accountId, + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + customThumbnail: data.customThumbnail.present + ? data.customThumbnail.value + : this.customThumbnail, + altText: data.altText.present ? data.altText.value : this.altText, + nsfw: data.nsfw.present ? data.nsfw.value : this.nsfw, + languageId: + data.languageId.present ? data.languageId.value : this.languageId, + body: data.body.present ? data.body.value : this.body, + ); + } + + @override + String toString() { + return (StringBuffer('DraftsData(') + ..write('id: $id, ') + ..write('draftType: $draftType, ') + ..write('existingId: $existingId, ') + ..write('replyId: $replyId, ') + ..write('active: $active, ') + ..write('accountId: $accountId, ') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('customThumbnail: $customThumbnail, ') + ..write('altText: $altText, ') + ..write('nsfw: $nsfw, ') + ..write('languageId: $languageId, ') + ..write('body: $body') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, draftType, existingId, replyId, active, + accountId, title, url, customThumbnail, altText, nsfw, languageId, body); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DraftsData && + other.id == this.id && + other.draftType == this.draftType && + other.existingId == this.existingId && + other.replyId == this.replyId && + other.active == this.active && + other.accountId == this.accountId && + other.title == this.title && + other.url == this.url && + other.customThumbnail == this.customThumbnail && + other.altText == this.altText && + other.nsfw == this.nsfw && + other.languageId == this.languageId && + other.body == this.body); +} + +class DraftsCompanion extends UpdateCompanion { + final Value id; + final Value draftType; + final Value existingId; + final Value replyId; + final Value active; + final Value accountId; + final Value title; + final Value url; + final Value customThumbnail; + final Value altText; + final Value nsfw; + final Value languageId; + final Value body; + const DraftsCompanion({ + this.id = const Value.absent(), + this.draftType = const Value.absent(), + this.existingId = const Value.absent(), + this.replyId = const Value.absent(), + this.active = const Value.absent(), + this.accountId = const Value.absent(), + this.title = const Value.absent(), + this.url = const Value.absent(), + this.customThumbnail = const Value.absent(), + this.altText = const Value.absent(), + this.nsfw = const Value.absent(), + this.languageId = const Value.absent(), + this.body = const Value.absent(), + }); + DraftsCompanion.insert({ + this.id = const Value.absent(), + required String draftType, + this.existingId = const Value.absent(), + this.replyId = const Value.absent(), + this.active = const Value.absent(), + this.accountId = const Value.absent(), + this.title = const Value.absent(), + this.url = const Value.absent(), + this.customThumbnail = const Value.absent(), + this.altText = const Value.absent(), + this.nsfw = const Value.absent(), + this.languageId = const Value.absent(), + this.body = const Value.absent(), + }) : draftType = Value(draftType); + static Insertable custom({ + Expression? id, + Expression? draftType, + Expression? existingId, + Expression? replyId, + Expression? active, + Expression? accountId, + Expression? title, + Expression? url, + Expression? customThumbnail, + Expression? altText, + Expression? nsfw, + Expression? languageId, + Expression? body, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (draftType != null) 'draft_type': draftType, + if (existingId != null) 'existing_id': existingId, + if (replyId != null) 'reply_id': replyId, + if (active != null) 'active': active, + if (accountId != null) 'account_id': accountId, + if (title != null) 'title': title, + if (url != null) 'url': url, + if (customThumbnail != null) 'custom_thumbnail': customThumbnail, + if (altText != null) 'alt_text': altText, + if (nsfw != null) 'nsfw': nsfw, + if (languageId != null) 'language_id': languageId, + if (body != null) 'body': body, + }); + } + + DraftsCompanion copyWith( + {Value? id, + Value? draftType, + Value? existingId, + Value? replyId, + Value? active, + Value? accountId, + Value? title, + Value? url, + Value? customThumbnail, + Value? altText, + Value? nsfw, + Value? languageId, + Value? body}) { + return DraftsCompanion( + id: id ?? this.id, + draftType: draftType ?? this.draftType, + existingId: existingId ?? this.existingId, + replyId: replyId ?? this.replyId, + active: active ?? this.active, + accountId: accountId ?? this.accountId, + title: title ?? this.title, + url: url ?? this.url, + customThumbnail: customThumbnail ?? this.customThumbnail, + altText: altText ?? this.altText, + nsfw: nsfw ?? this.nsfw, + languageId: languageId ?? this.languageId, + body: body ?? this.body, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (draftType.present) { + map['draft_type'] = Variable(draftType.value); + } + if (existingId.present) { + map['existing_id'] = Variable(existingId.value); + } + if (replyId.present) { + map['reply_id'] = Variable(replyId.value); + } + if (active.present) { + map['active'] = Variable(active.value); + } + if (accountId.present) { + map['account_id'] = Variable(accountId.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (customThumbnail.present) { + map['custom_thumbnail'] = Variable(customThumbnail.value); + } + if (altText.present) { + map['alt_text'] = Variable(altText.value); + } + if (nsfw.present) { + map['nsfw'] = Variable(nsfw.value); + } + if (languageId.present) { + map['language_id'] = Variable(languageId.value); + } + if (body.present) { + map['body'] = Variable(body.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('DraftsCompanion(') + ..write('id: $id, ') + ..write('draftType: $draftType, ') + ..write('existingId: $existingId, ') + ..write('replyId: $replyId, ') + ..write('active: $active, ') + ..write('accountId: $accountId, ') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('customThumbnail: $customThumbnail, ') + ..write('altText: $altText, ') + ..write('nsfw: $nsfw, ') + ..write('languageId: $languageId, ') + ..write('body: $body') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV8 extends GeneratedDatabase { + DatabaseAtV8(QueryExecutor e) : super(e); + late final Accounts accounts = Accounts(this); + late final Favorites favorites = Favorites(this); + late final LocalSubscriptions localSubscriptions = LocalSubscriptions(this); + late final UserLabels userLabels = UserLabels(this); + late final Drafts drafts = Drafts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => + [accounts, favorites, localSubscriptions, userLabels, drafts]; + @override + int get schemaVersion => 8; +} diff --git a/test/drift/thunder/migration_test.dart b/test/drift/thunder/migration_test.dart index bbeeacd28..4cb41c9af 100644 --- a/test/drift/thunder/migration_test.dart +++ b/test/drift/thunder/migration_test.dart @@ -10,6 +10,7 @@ import 'generated/schema_v4.dart' as v4; import 'generated/schema_v5.dart' as v5; import 'generated/schema_v6.dart' as v6; import 'generated/schema_v7.dart' as v7; +import 'generated/schema_v8.dart' as v8; void main() { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; @@ -40,26 +41,8 @@ void main() { group('from v3 to v4', () { test('add custom_thumbnail to Drafts table', () async { // Add data to insert into the old database, and the expected rows after the migration. - final oldDraftsData = [ - v3.DraftsData( - id: 1, - draftType: 'postCreate', - existingId: 1, - replyId: 1, - title: 'title', - url: 'url', - body: 'body') - ]; - final expectedNewDraftsData = [ - v4.DraftsData( - id: 1, - draftType: 'postCreate', - existingId: 1, - replyId: 1, - title: 'title', - url: 'url', - body: 'body') - ]; + final oldDraftsData = [v3.DraftsData(id: 1, draftType: 'postCreate', existingId: 1, replyId: 1, title: 'title', url: 'url', body: 'body')]; + final expectedNewDraftsData = [v4.DraftsData(id: 1, draftType: 'postCreate', existingId: 1, replyId: 1, title: 'title', url: 'url', body: 'body')]; await verifier.testWithDataIntegrity( oldVersion: 3, @@ -67,10 +50,8 @@ void main() { createOld: v3.DatabaseAtV3.new, createNew: v4.DatabaseAtV4.new, openTestedDatabase: AppDatabase.new, - createItems: (batch, oldDb) => - batch.insertAll(oldDb.drafts, oldDraftsData), - validateItems: (newDb) async => expect( - expectedNewDraftsData, await newDb.select(newDb.drafts).get()), + createItems: (batch, oldDb) => batch.insertAll(oldDb.drafts, oldDraftsData), + validateItems: (newDb) async => expect(expectedNewDraftsData, await newDb.select(newDb.drafts).get()), ); }); }); @@ -79,31 +60,13 @@ void main() { test('add list_index column and set list_index to id', () async { // Add data to insert into the old database, and the expected rows after the migration. final oldAccountsData = [ - v4.AccountsData( - id: 1, - username: 'thunder', - jwt: 'jwt', - instance: 'lemmy.thunderapp.dev', - anonymous: false, - userId: 1), - v4.AccountsData( - id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true), + v4.AccountsData(id: 1, username: 'thunder', jwt: 'jwt', instance: 'lemmy.thunderapp.dev', anonymous: false, userId: 1), + v4.AccountsData(id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true), ]; final expectedNewAccountsData = [ - v5.AccountsData( - id: 1, - username: 'thunder', - jwt: 'jwt', - instance: 'lemmy.thunderapp.dev', - anonymous: false, - userId: 1, - listIndex: 1), - v5.AccountsData( - id: 2, - instance: 'lemmy.thunderapp.dev', - anonymous: true, - listIndex: 2), + v5.AccountsData(id: 1, username: 'thunder', jwt: 'jwt', instance: 'lemmy.thunderapp.dev', anonymous: false, userId: 1, listIndex: 1), + v5.AccountsData(id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true, listIndex: 2), ]; await verifier.testWithDataIntegrity( @@ -112,10 +75,8 @@ void main() { createOld: v4.DatabaseAtV4.new, createNew: v5.DatabaseAtV5.new, openTestedDatabase: AppDatabase.new, - createItems: (batch, oldDb) => - batch.insertAll(oldDb.accounts, oldAccountsData), - validateItems: (newDb) async => expect(expectedNewAccountsData, - await newDb.select(newDb.accounts).get()), + createItems: (batch, oldDb) => batch.insertAll(oldDb.accounts, oldAccountsData), + validateItems: (newDb) async => expect(expectedNewAccountsData, await newDb.select(newDb.accounts).get()), ); }); }); @@ -123,26 +84,8 @@ void main() { group('from v5 to v6', () { test('add alt_text column to Drafts table', () async { // Add data to insert into the old database, and the expected rows after the migration. - final oldDraftsData = [ - v5.DraftsData( - id: 1, - draftType: 'postCreate', - existingId: 1, - replyId: 1, - title: 'title', - url: 'url', - body: 'body') - ]; - final expectedNewDraftsData = [ - v6.DraftsData( - id: 1, - draftType: 'postCreate', - existingId: 1, - replyId: 1, - title: 'title', - url: 'url', - body: 'body') - ]; + final oldDraftsData = [v5.DraftsData(id: 1, draftType: 'postCreate', existingId: 1, replyId: 1, title: 'title', url: 'url', body: 'body')]; + final expectedNewDraftsData = [v6.DraftsData(id: 1, draftType: 'postCreate', existingId: 1, replyId: 1, title: 'title', url: 'url', body: 'body')]; await verifier.testWithDataIntegrity( oldVersion: 5, @@ -150,50 +93,23 @@ void main() { createOld: v5.DatabaseAtV5.new, createNew: v6.DatabaseAtV6.new, openTestedDatabase: AppDatabase.new, - createItems: (batch, oldDb) => - batch.insertAll(oldDb.drafts, oldDraftsData), - validateItems: (newDb) async => expect( - expectedNewDraftsData, await newDb.select(newDb.drafts).get()), + createItems: (batch, oldDb) => batch.insertAll(oldDb.drafts, oldDraftsData), + validateItems: (newDb) async => expect(expectedNewDraftsData, await newDb.select(newDb.drafts).get()), ); }); }); group('from v6 to v7', () { - test('add platform column to Accounts table and set platform to lemmy', - () async { + test('add platform column to Accounts table and set platform to lemmy', () async { // Add data to insert into the old database, and the expected rows after the migration. final oldAccountsData = [ - v6.AccountsData( - id: 1, - username: 'thunder', - jwt: 'jwt', - instance: 'lemmy.thunderapp.dev', - anonymous: false, - userId: 1, - listIndex: 1), - v6.AccountsData( - id: 2, - instance: 'lemmy.thunderapp.dev', - anonymous: true, - listIndex: 2), + v6.AccountsData(id: 1, username: 'thunder', jwt: 'jwt', instance: 'lemmy.thunderapp.dev', anonymous: false, userId: 1, listIndex: 1), + v6.AccountsData(id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true, listIndex: 2), ]; final expectedNewAccountsData = [ - v7.AccountsData( - id: 1, - username: 'thunder', - jwt: 'jwt', - instance: 'lemmy.thunderapp.dev', - anonymous: false, - userId: 1, - listIndex: 1, - platform: 'lemmy'), - v7.AccountsData( - id: 2, - instance: 'lemmy.thunderapp.dev', - anonymous: true, - listIndex: 2, - platform: 'lemmy'), + v7.AccountsData(id: 1, username: 'thunder', jwt: 'jwt', instance: 'lemmy.thunderapp.dev', anonymous: false, userId: 1, listIndex: 1, platform: 'lemmy'), + v7.AccountsData(id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true, listIndex: 2, platform: 'lemmy'), ]; await verifier.testWithDataIntegrity( @@ -202,10 +118,54 @@ void main() { createOld: v6.DatabaseAtV6.new, createNew: v7.DatabaseAtV7.new, openTestedDatabase: AppDatabase.new, - createItems: (batch, oldDb) => - batch.insertAll(oldDb.accounts, oldAccountsData), - validateItems: (newDb) async => expect(expectedNewAccountsData, - await newDb.select(newDb.accounts).get()), + createItems: (batch, oldDb) => batch.insertAll(oldDb.accounts, oldAccountsData), + validateItems: (newDb) async => expect(expectedNewAccountsData, await newDb.select(newDb.accounts).get()), + ); + }); + }); + + group('from v7 to v8', () { + test('add active/account_id/nsfw/language_id columns to Drafts table', () async { + final oldDraftsData = [ + v7.DraftsData( + id: 1, + draftType: 'postCreate', + existingId: null, + replyId: 10, + title: 'title', + url: 'url', + customThumbnail: 'thumbnail', + altText: 'alt', + body: 'body', + ) + ]; + + final expectedNewDraftsData = [ + v8.DraftsData( + id: 1, + draftType: 'postCreate', + existingId: null, + replyId: 10, + active: 0, + accountId: null, + title: 'title', + url: 'url', + customThumbnail: 'thumbnail', + altText: 'alt', + nsfw: 0, + languageId: null, + body: 'body', + ) + ]; + + await verifier.testWithDataIntegrity( + oldVersion: 7, + newVersion: 8, + createOld: v7.DatabaseAtV7.new, + createNew: v8.DatabaseAtV8.new, + openTestedDatabase: AppDatabase.new, + createItems: (batch, oldDb) => batch.insertAll(oldDb.drafts, oldDraftsData), + validateItems: (newDb) async => expect(expectedNewDraftsData, await newDb.select(newDb.drafts).get()), ); }); }); diff --git a/test/features/drafts/draft_model_test.dart b/test/features/drafts/draft_model_test.dart new file mode 100644 index 000000000..1f1420a80 --- /dev/null +++ b/test/features/drafts/draft_model_test.dart @@ -0,0 +1,145 @@ +import 'package:drift/native.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/src/features/drafts/drafts.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; + +void main() { + late AppDatabase appDatabase; + late DraftRepository repository; + + setUp(() { + appDatabase = AppDatabase(NativeDatabase.memory()); + database = appDatabase; + repository = DraftRepositoryImpl(database: appDatabase); + }); + + tearDown(() async { + await appDatabase.close(); + }); + + test('upsert draft sets active and stores account', () async { + final draft = Draft( + id: '', + draftType: DraftType.postCreate, + replyId: 11, + title: 'title', + body: 'body', + accountId: '2', + nsfw: true, + languageId: 4, + ); + + final saved = await repository.upsertDraft(draft, active: true); + final active = await repository.fetchActiveDraft(); + + expect(saved, isNotNull); + expect(active, isNotNull); + expect(active!.active, isTrue); + expect(active.draftType, DraftType.postCreate); + expect(active.replyId, 11); + expect(active.accountId, '2'); + expect(active.nsfw, isTrue); + expect(active.languageId, 4); + }); + + test('setting active draft clears previously active draft', () async { + await repository.upsertDraft( + Draft(id: '', draftType: DraftType.postCreate, replyId: 11, title: 'one', accountId: '1'), + active: true, + ); + + await repository.upsertDraft( + Draft(id: '', draftType: DraftType.commentCreateFromPost, replyId: 42, body: 'two', accountId: '3'), + active: true, + ); + + final active = await repository.fetchActiveDraft(); + final previous = await repository.fetchDraft(DraftType.postCreate, null, 11); + + expect(active, isNotNull); + expect(active!.draftType, DraftType.commentCreateFromPost); + expect(active.replyId, 42); + expect(previous, isNotNull); + expect(previous!.active, isFalse); + }); + + test('upsert stores comment language id', () async { + await repository.upsertDraft( + Draft(id: '', draftType: DraftType.commentCreateFromPost, replyId: 777, body: 'with language', accountId: '1', languageId: 19), + active: true, + ); + + final fetched = await repository.fetchDraft(DraftType.commentCreateFromPost, null, 777); + + expect(fetched, isNotNull); + expect(fetched!.languageId, 19); + }); + + test('upsert keeps draft identity while account changes', () async { + final initial = await repository.upsertDraft( + Draft(id: '', draftType: DraftType.postCreate, replyId: 10, title: 'hello', accountId: '1'), + active: true, + ); + + final updated = await repository.upsertDraft( + Draft(id: '', draftType: DraftType.postCreate, replyId: 10, title: 'hello', accountId: '999'), + active: true, + ); + + final fetched = await repository.fetchDraft(DraftType.postCreate, null, 10); + + expect(initial, isNotNull); + expect(updated, isNotNull); + expect(updated!.id, initial!.id); + expect(fetched, isNotNull); + expect(fetched!.accountId, '999'); + }); + + test('fetch supports legacy commentCreate type', () async { + await repository.upsertDraft( + Draft(id: '', draftType: DraftType.commentCreate, replyId: 99, body: 'legacy', accountId: '1'), + active: true, + ); + + final restoredFromPost = await repository.fetchDraft(DraftType.commentCreateFromPost, null, 99); + final restoredFromComment = await repository.fetchDraft(DraftType.commentCreateFromComment, null, 99); + + expect(restoredFromPost, isNotNull); + expect(restoredFromComment, isNotNull); + expect(restoredFromPost!.body, 'legacy'); + expect(restoredFromComment!.body, 'legacy'); + }); + + test('fetch supports legacy postCreateGeneral type', () async { + await repository.upsertDraft( + Draft(id: '', draftType: DraftType.postCreateGeneral, title: 'legacy post create general', accountId: '1'), + active: true, + ); + + final restored = await repository.fetchDraft(DraftType.postCreate, null, null); + + expect(restored, isNotNull); + expect(restored!.title, 'legacy post create general'); + }); + + test('database enforces one globally active draft', () async { + final first = await repository.upsertDraft( + Draft(id: '', draftType: DraftType.postCreate, replyId: 1, title: 'one', accountId: '1'), + ); + final second = await repository.upsertDraft( + Draft(id: '', draftType: DraftType.postCreate, replyId: 2, title: 'two', accountId: '1'), + ); + + expect(first, isNotNull); + expect(second, isNotNull); + + await database.customStatement('UPDATE drafts SET active = 1 WHERE id = ${first!.id}'); + + expect( + () => database.customStatement('UPDATE drafts SET active = 1 WHERE id = ${second!.id}'), + throwsA(isA()), + ); + }); +} diff --git a/test/features/drafts/draft_utils_test.dart b/test/features/drafts/draft_utils_test.dart new file mode 100644 index 000000000..8ffd873fb --- /dev/null +++ b/test/features/drafts/draft_utils_test.dart @@ -0,0 +1,194 @@ +import 'package:drift/native.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/src/features/drafts/drafts.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/foundation/primitives/enums/subscription_status.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; + +void main() { + group('draft compose context helpers', () { + test('resolve post context handles edit and create', () { + final editContext = resolvePostDraftContext(editingPostId: 4, communityId: 11); + final createContext = resolvePostDraftContext(editingPostId: null, communityId: 11); + + expect(editContext.draftType, DraftType.postEdit); + expect(editContext.existingId, 4); + expect(editContext.replyId, isNull); + + expect(createContext.draftType, DraftType.postCreate); + expect(createContext.existingId, isNull); + expect(createContext.replyId, 11); + }); + + test('resolve comment context handles comment edit and reply targets', () { + final editContext = resolveCommentDraftContext(editingCommentId: 9, postId: 21, parentCommentId: 33); + final replyToPostContext = resolveCommentDraftContext(editingCommentId: null, postId: 21, parentCommentId: null); + final replyToCommentContext = resolveCommentDraftContext(editingCommentId: null, postId: 21, parentCommentId: 33); + + expect(editContext.draftType, DraftType.commentEdit); + expect(editContext.existingId, 9); + + expect(replyToPostContext.draftType, DraftType.commentCreateFromPost); + expect(replyToPostContext.replyId, 21); + + expect(replyToCommentContext.draftType, DraftType.commentCreateFromComment); + expect(replyToCommentContext.replyId, 33); + }); + }); + + group('persist compose draft', () { + late AppDatabase appDatabase; + late DraftRepository draftRepository; + + setUp(() { + appDatabase = AppDatabase(NativeDatabase.memory()); + database = appDatabase; + draftRepository = DraftRepositoryImpl(database: appDatabase); + }); + + tearDown(() async { + await appDatabase.close(); + }); + + test('persists active draft when content exists', () async { + final context = resolvePostDraftContext(editingPostId: null, communityId: 42); + final draft = buildPostDraft( + context: context, + accountId: '1', + title: 'title', + url: '', + customThumbnail: '', + altText: '', + nsfw: true, + languageId: 3, + body: 'body', + ); + + final result = await persistDraft( + repository: draftRepository, + context: context, + draft: draft, + save: true, + differsFromEdit: true, + hasContent: draft.isPostNotEmpty, + ); + + final active = await draftRepository.fetchActiveDraft(); + + expect(result, DraftPersistenceResult.saved); + expect(active, isNotNull); + expect(active!.active, isTrue); + expect(active.replyId, 42); + }); + + test('skips when comment context is missing reply target', () async { + const context = DraftContext(draftType: DraftType.commentCreateFromPost); + final draft = buildCommentDraft(context: context, accountId: '1', languageId: 5, body: 'hello'); + + final result = await persistDraft( + repository: draftRepository, + context: context, + draft: draft, + save: true, + differsFromEdit: true, + hasContent: draft.isCommentNotEmpty, + ); + + final active = await draftRepository.fetchActiveDraft(); + expect(result, DraftPersistenceResult.skipped); + expect(active, isNull); + }); + }); + + group('draft diff helpers', () { + test('post draft diff includes nsfw and language', () { + final draft = Draft( + id: '', + draftType: DraftType.postEdit, + existingId: 10, + title: 'title', + url: 'https://example.com', + customThumbnail: 'https://example.com/image.png', + altText: 'alt', + nsfw: true, + languageId: 2, + body: 'body', + ); + + final post = ThunderPost( + id: 10, + name: 'title', + url: 'https://example.com', + body: 'body', + altText: 'alt', + creatorId: 1, + communityId: 2, + removed: false, + locked: false, + published: DateTime.now(), + deleted: false, + nsfw: false, + thumbnailUrl: 'https://example.com/image.png', + apId: 'apId', + local: true, + languageId: 1, + featuredCommunity: false, + featuredLocal: false, + creator: ThunderUser( + id: 1, + name: 'user', + banned: false, + published: DateTime.fromMillisecondsSinceEpoch(0), + actorId: 'actor', + local: true, + deleted: false, + botAccount: false, + instanceId: 1, + ), + community: ThunderCommunity( + id: 2, + name: 'community', + title: 'community', + removed: false, + published: DateTime.fromMillisecondsSinceEpoch(0), + deleted: false, + nsfw: false, + actorId: 'communityActor', + local: true, + hidden: false, + postingRestrictedToMods: false, + instanceId: 1, + visibility: 'Public', + subscribed: SubscriptionStatus.notSubscribed, + ), + ); + + expect(postDraftDiffersFromEdit(draft, post), isTrue); + }); + + test('comment draft diff includes language', () { + final draft = Draft(id: '', draftType: DraftType.commentEdit, existingId: 5, body: 'body', languageId: 2); + final comment = ThunderComment( + id: 5, + creatorId: 1, + postId: 2, + content: 'body', + removed: false, + published: DateTime.fromMillisecondsSinceEpoch(0), + deleted: false, + apId: 'apId', + local: true, + path: '0.5', + distinguished: false, + languageId: 1, + ); + + expect(commentDraftDiffersFromEdit(draft, comment), isTrue); + }); + }); +}