diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 014973a..05d849d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,6 +171,9 @@ jobs: ls -R /tmp/artifacts/final - name: Upload to S3 + id: upload_s3 + timeout-minutes: 5 + continue-on-error: true uses: Robert-Stackflow/upload-s3-action@master with: endpoint: ${{ secrets.AWS_ENDPOINT }} @@ -179,6 +182,11 @@ jobs: aws_bucket: ${{ secrets.AWS_BUCKET }} source_dir: /tmp/artifacts/final destination_dir: Loftify/${{ steps.get_version.outputs.version }} + - name: Report S3 upload failure + if: steps.upload_s3.outcome == 'failure' + run: | + echo "::warning title=S3 upload failed::Continuing with GitHub Release upload." + echo "S3 upload failed; GitHub Release upload will still run." >> "$GITHUB_STEP_SUMMARY" - name: Upload to release uses: Robert-Stackflow/release-action@master with: diff --git a/lib/Screens/Post/collection_detail_screen.dart b/lib/Screens/Post/collection_detail_screen.dart index e9dcc00..5c38041 100644 --- a/lib/Screens/Post/collection_detail_screen.dart +++ b/lib/Screens/Post/collection_detail_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:blur/blur.dart'; import 'package:flutter/material.dart' hide AnimatedSlide; @@ -14,6 +16,7 @@ import '../../Models/post_detail_response.dart'; import '../../Models/recommend_response.dart'; import '../../Screens/Download/batch_download_screen.dart'; import '../../Utils/enums.dart'; +import '../../Utils/content_order_preference.dart'; import '../../Widgets/PostItem/common_info_post_item_builder.dart'; import '../../Widgets/PostItem/general_post_item.dart'; import '../../Widgets/PostItem/loftify_post_archive_grid.dart'; @@ -165,6 +168,7 @@ class CollectionDetailScreenState @override void initState() { super.initState(); + isOldest = ContentOrderPreference.read(ContentOrderType.collection); _fetchData(refresh: true); _fetchIncantation(); } @@ -335,10 +339,13 @@ class CollectionDetailScreenState fontSizeDelta: 1, color: Theme.of(context).textTheme.labelMedium?.color, onTap: () { + if (loading) return; HapticFeedback.mediumImpact(); setState(() { isOldest = !isOldest; }); + unawaited(ContentOrderPreference.write( + ContentOrderType.collection, isOldest)); _fetchData(refresh: true, showLoading: true); }, ), diff --git a/lib/Screens/Post/grain_detail_screen.dart b/lib/Screens/Post/grain_detail_screen.dart index 810f70d..11faa58 100644 --- a/lib/Screens/Post/grain_detail_screen.dart +++ b/lib/Screens/Post/grain_detail_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:blur/blur.dart'; import 'package:flutter/material.dart' hide AnimatedSlide; @@ -13,6 +15,7 @@ import 'package:loftify/Widgets/PostItem/loftify_post_archive_grid.dart'; import '../../Models/history_response.dart'; import '../../Models/download_task.dart'; +import '../../Utils/content_order_preference.dart'; import '../../Screens/Download/batch_download_screen.dart'; import '../../Utils/post_sequence_source.dart'; import '../../Widgets/PostDetail/detail_bottom_bar.dart'; @@ -156,6 +159,7 @@ class GrainDetailScreenState extends BaseDynamicState @override void initState() { super.initState(); + isOldest = ContentOrderPreference.read(ContentOrderType.grain); _postSequenceSource = PostSequenceSource( loadMore: () async { await _fetchData(); @@ -399,10 +403,13 @@ class GrainDetailScreenState extends BaseDynamicState fontSizeDelta: 1, color: Theme.of(context).textTheme.labelMedium?.color, onTap: () { + if (loading) return; HapticFeedback.mediumImpact(); setState(() { isOldest = !isOldest; }); + unawaited(ContentOrderPreference.write( + ContentOrderType.grain, isOldest)); _fetchData(refresh: true, showLoading: true); }, ), diff --git a/lib/Utils/content_order_preference.dart b/lib/Utils/content_order_preference.dart new file mode 100644 index 0000000..9533bcb --- /dev/null +++ b/lib/Utils/content_order_preference.dart @@ -0,0 +1,24 @@ +import 'package:hive/hive.dart'; + +import 'hive_util.dart'; + +enum ContentOrderType { collection, grain } + +/// The collection detail page and its directory sheet share one preference. +/// Grain order is independent because it uses a separate content type. +class ContentOrderPreference { + const ContentOrderPreference._(); + + static String _key(ContentOrderType type) => switch (type) { + ContentOrderType.collection => HiveUtil.collectionOldestFirstKey, + ContentOrderType.grain => HiveUtil.grainOldestFirstKey, + }; + + static bool read(ContentOrderType type) { + final value = Hive.box(HiveUtil.settingsBox).get(_key(type)); + return value is bool ? value : false; + } + + static Future write(ContentOrderType type, bool oldestFirst) => + Hive.box(HiveUtil.settingsBox).put(_key(type), oldestFirst); +} diff --git a/lib/Utils/hive_util.dart b/lib/Utils/hive_util.dart index b3cb99b..bc8f480 100644 --- a/lib/Utils/hive_util.dart +++ b/lib/Utils/hive_util.dart @@ -25,6 +25,8 @@ class HiveUtil { static const String cookieKey = "cookieKey"; static const String customAvatarBoxKey = "customAvatarBox"; static const String searchHistoryKey = "searchHistory"; + static const String collectionOldestFirstKey = "collectionOldestFirst"; + static const String grainOldestFirstKey = "grainOldestFirst"; //General static const String localeKey = "locale"; diff --git a/lib/Widgets/BottomSheet/collection_bottom_sheet.dart b/lib/Widgets/BottomSheet/collection_bottom_sheet.dart index cb816ba..18366b9 100644 --- a/lib/Widgets/BottomSheet/collection_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/collection_bottom_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,6 +9,7 @@ import 'package:loftify/Models/recommend_response.dart'; import 'package:loftify/Screens/Post/collection_detail_screen.dart'; import '../../Models/history_response.dart'; +import '../../Utils/content_order_preference.dart'; import '../../Theme/loftify_design_theme.dart'; import '../../l10n/l10n.dart'; import '../Design/loftify_controls.dart'; @@ -53,6 +56,7 @@ class CollectionBottomSheetState extends State { void initState() { super.initState(); subscribed = widget.postCollection.subscribed; + isOldest = ContentOrderPreference.read(ContentOrderType.collection); } @override @@ -316,15 +320,20 @@ class CollectionBottomSheetState extends State { fontSizeDelta: 1, color: Theme.of(context).textTheme.labelMedium?.color, onTap: () { + if (loading) return; HapticFeedback.mediumImpact(); + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } setState(() { isOldest = !isOldest; + posts.clear(); + _archiveDataList.clear(); + bottomNoMore = false; + isInited = false; }); - _scrollController.animateTo(0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut); - bottomNoMore = false; - isInited = false; + unawaited(ContentOrderPreference.write( + ContentOrderType.collection, isOldest)); _refreshController.resetHeader(); _refreshController.resetFooter(); _onRefresh(showLoading: true); diff --git a/test/content_order_preference_test.dart b/test/content_order_preference_test.dart new file mode 100644 index 0000000..43d7809 --- /dev/null +++ b/test/content_order_preference_test.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Utils/content_order_preference.dart'; +import 'package:loftify/Utils/hive_util.dart'; + +void main() { + setUpAll(() async { + final directory = Directory('build/test_hive/content_order_preference'); + await directory.create(recursive: true); + Hive.init(directory.absolute.path); + await Hive.openBox(HiveUtil.settingsBox); + }); + + setUp(() async { + if (!Hive.isBoxOpen(HiveUtil.settingsBox)) { + await Hive.openBox(HiveUtil.settingsBox); + } + await Hive.box(HiveUtil.settingsBox).clear(); + }); + + tearDownAll(() async => Hive.close()); + + test('both content types default to descending order', () { + expect(ContentOrderPreference.read(ContentOrderType.collection), isFalse); + expect(ContentOrderPreference.read(ContentOrderType.grain), isFalse); + }); + + test('collection and grain remember independent choices', () async { + await ContentOrderPreference.write(ContentOrderType.collection, true); + expect(ContentOrderPreference.read(ContentOrderType.collection), isTrue); + expect(ContentOrderPreference.read(ContentOrderType.grain), isFalse); + + await ContentOrderPreference.write(ContentOrderType.grain, true); + await ContentOrderPreference.write(ContentOrderType.collection, false); + expect(ContentOrderPreference.read(ContentOrderType.collection), isFalse); + expect(ContentOrderPreference.read(ContentOrderType.grain), isTrue); + }); + + test('saved order survives reopening the settings box', () async { + await ContentOrderPreference.write(ContentOrderType.collection, true); + await Hive.box(HiveUtil.settingsBox).close(); + await Hive.openBox(HiveUtil.settingsBox); + + expect(ContentOrderPreference.read(ContentOrderType.collection), isTrue); + }); + + test('invalid saved values fall back to descending order', () async { + await Hive.box(HiveUtil.settingsBox) + .put(HiveUtil.grainOldestFirstKey, 'invalid'); + expect(ContentOrderPreference.read(ContentOrderType.grain), isFalse); + }); +}