Improve post tracking, visuals, and bookmark handling#53
Conversation
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the app’s “last seen” tracking from watched media to watched posts, refreshes several UI elements (blur-pill controls, board/bookmark visuals), and bumps app/dependency versions to support the changes.
Changes:
- Replace watched-media persistence/behavior with watched-post tracking (including auto-scroll to last seen).
- Introduce a shared
buildBlurPillUI helper and apply it across media controls / FABs. - Redesign bookmark and board list tiles, plus update package/app versions.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| pubspec.yaml | Version bump + dependency updates; switches ffmpeg_kit_flutter_new to a git dependency. |
| lib/widgets/floating_action_buttons.dart | Replaces FAB styling with blur-pill UI. |
| lib/widgets/feed_video_player.dart | Updates mute toggle UI to use blur-pill. |
| lib/utils/build_blur_pill.dart | Adds reusable blur-pill widget helper. |
| lib/pages/thread/thread_replies.dart | Passes postIndex into ThreadPagePost. |
| lib/pages/thread/thread_replied_to.dart | Passes postIndex into ThreadPagePost. |
| lib/pages/thread/thread_page_post.dart | Adds required postIndex parameter; removes watched-media corner UI. |
| lib/pages/thread/thread_page.dart | Switches visible-item tracking + auto-scroll to watched-posts provider. |
| lib/pages/thread/thread_media_viewer_page.dart | Replaces local blur-pill implementation with shared helper. |
| lib/pages/thread/thread_image_viewer_page.dart | Removes legacy image viewer page implementation. |
| lib/pages/settings/setting_pages/data_settings.dart | Updates retention/clear logic to watched-posts provider/settings methods. |
| lib/pages/savedAttachments/saved_media_viewer_page.dart | Replaces local blur-pill implementation with shared helper. |
| lib/pages/bookmarks/bookmarks_post.dart | Refactors bookmark card UI + lifecycle for fetching bookmark status. |
| lib/pages/bookmarks/bookmarks.dart | Adds stable keys for bookmark list items. |
| lib/pages/bookmark_button.dart | Changes favorite icon semantics from heart to bookmark + color behavior. |
| lib/pages/boards/board_tile.dart | Refreshes board tile UI (badge, colors, slidable actions). |
| lib/pages/boards/board_list_header.dart | Adds reusable header widget for board list sections. |
| lib/pages/boards/board_list.dart | Uses new headers; renames filtered boards variable; tweaks nav icons. |
| lib/pages/board/list_post.dart | Updates thread list post layout + bookmark affordance UI. |
| lib/main.dart | Removes GalleryProvider; replaces watched-media provider with watched-posts provider. |
| lib/blocs/watched_media_model.dart | Renames provider to WatchedPostsProvider and persists watched-post indices. |
| lib/blocs/settings_model.dart | Renames retention setting to watched-posts retention days. |
| lib/blocs/gallery_model.dart | Removes unused gallery provider. |
| lib/Models/watched_media.dart | Renames model to WatchedPosts and changes stored data shape. |
| ios/Runner.xcodeproj/project.pbxproj | iOS marketing version bump to 0.11.4. |
Comments suppressed due to low confidence (1)
lib/pages/settings/setting_pages/data_settings.dart:276
- The snackbar message still says “Watched media history cleared!” but the action now clears watched posts. Update the message to reflect what was cleared to avoid user confusion.
await watchedPostsProvider.clearAllWatchedPosts();
if (mounted) {
showCupertinoSnackbar(
const Duration(milliseconds: 1800),
true,
context,
'Watched media history cleared!',
);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| title: const Text('Clear Watched Media History'), | ||
| trailing: const CupertinoListTileChevron(), | ||
| onTap: () async { |
There was a problem hiding this comment.
The action is now clearAllWatchedPosts(), but the tile title still says “Clear Watched Media History”. Update the label to “watched posts/threads” (or similar) so users understand what will be cleared.
| import 'dart:ui'; | ||
|
|
There was a problem hiding this comment.
dart:ui is imported but not used in this file. This will trigger an unused_import warning; remove the import (the blur helper already imports dart:ui).
| import 'dart:ui'; |
| return FutureBuilder<BookmarkStatus>( | ||
| future: _fetchBookmarkStatus, | ||
| builder: (BuildContext context, snapshot) { | ||
| switch (snapshot.connectionState) { | ||
| case ConnectionState.waiting: | ||
| return createBookmarkPost( | ||
| ThreadStatus.online, | ||
| null, | ||
| theme, | ||
| ); | ||
| default: | ||
| if (snapshot.data!.status == ThreadStatus.deleted) { | ||
| isDeleted = true; | ||
| } | ||
| return Slidable( | ||
| endActionPane: ActionPane( | ||
| extentRatio: 0.3, | ||
| motion: const BehindMotion(), | ||
| children: [ | ||
| SlidableAction( | ||
| label: 'Delete', | ||
| backgroundColor: Colors.red, | ||
| icon: Icons.delete, | ||
| onPressed: (context) => { | ||
| bookmarks.removeBookmarks( | ||
| widget.favorite, | ||
| ), | ||
| }, | ||
| ) | ||
| ], | ||
| future: _fetchBookmarkStatus, | ||
| builder: (BuildContext context, snapshot) { | ||
| final ThreadStatus status = | ||
| snapshot.connectionState == ConnectionState.waiting | ||
| ? ThreadStatus.online | ||
| : (snapshot.data!.status ?? ThreadStatus.online); | ||
| final ThreadReplyCount? replyCount = | ||
| snapshot.connectionState == ConnectionState.waiting | ||
| ? null | ||
| : snapshot.data!.replies; |
There was a problem hiding this comment.
FutureBuilder unconditionally dereferences snapshot.data! when not waiting. If fetchBookmarkStatus completes with an error (network failure) snapshot.data will be null and this will throw. Handle snapshot.hasError / !snapshot.hasData (e.g., show an offline/deleted state) before reading snapshot.data.
| overflow: TextOverflow.ellipsis, | ||
| if (status == ThreadStatus.archived || | ||
| status == ThreadStatus.deleted) ...[ | ||
| const SizedBox(width: 6), |
There was a problem hiding this comment.
This SizedBox uses width but is placed in a Column, so it won’t add spacing between the chips. Use SizedBox(height: ...) for vertical spacing, or wrap the chips in a Row if they are meant to be inline.
| const SizedBox(width: 6), | |
| const SizedBox(height: 6), |
| class ThreadPagePost extends StatefulWidget { | ||
| const ThreadPagePost({ | ||
| Key? key, | ||
| required this.board, | ||
| required this.post, | ||
| required this.thread, | ||
| required this.allPosts, | ||
| required this.onDismiss, | ||
| required this.postIndex, | ||
| this.replies, | ||
| }) : super(key: key); | ||
|
|
||
| final String board; | ||
| final int thread; | ||
| final Post post; | ||
| final List<Post> allPosts; | ||
| final Function(int? postId) onDismiss; | ||
| final List<Post>? replies; | ||
| final int postIndex; | ||
|
|
There was a problem hiding this comment.
postIndex was added as a required parameter/field but is not used anywhere in this widget. If it’s no longer needed, remove it; if it is needed for watched/scroll logic, wire it into the relevant behavior to avoid dead API surface.
| trailing: CupertinoButton( | ||
| padding: EdgeInsets.zero, | ||
| child: Text( | ||
| '${settings.getWatchedMediaRetentionDays()} days', | ||
| '${settings.getWatchedPostsRetentionDays()} days', | ||
| style: const TextStyle(color: CupertinoColors.systemGrey), | ||
| ), | ||
| onPressed: () async { |
There was a problem hiding this comment.
This setting now uses getWatchedPostsRetentionDays but the surrounding UI copy still says “Watched Media Retention Period” / “watched status of all media”. Update the title/subtitle text so it matches the new watched-post tracking behavior.
| isFavorite ? Icons.favorite : Icons.favorite_border_outlined, | ||
| color: CupertinoColors.systemRed, | ||
| isFavorite ? Icons.bookmark : Icons.bookmark_border, | ||
| color: isFavorite ? CupertinoColors.activeBlue : Colors.white, |
There was a problem hiding this comment.
When isFavorite is false the bookmark icon color is hard-coded to Colors.white, which has very low contrast on the light navigation background. Consider deriving the color from the current theme/brightness (or using CupertinoColors.label) so it remains visible in both light/dark modes.
| color: isFavorite ? CupertinoColors.activeBlue : Colors.white, | |
| color: isFavorite | |
| ? CupertinoColors.activeBlue | |
| : CupertinoColors.label.resolveFrom(context), |
| @@ -140,22 +120,16 @@ class ThreadPageState extends State<ThreadPage> { | |||
| return; | |||
| } | |||
|
|
|||
| final latestWatchedMedia = watchedMedia.getLatestWatchedMedia( | |||
| final latestWatchedPosts = watchedPosts.getLatestWatchedPosts( | |||
| widget.thread, | |||
| ); | |||
|
|
|||
| if (latestWatchedMedia != null) { | |||
| final index = allPosts.indexWhere( | |||
| (post) => | |||
| post.tim == latestWatchedMedia.mediaId || | |||
| post.no == latestWatchedMedia.mediaId, | |||
| ); | |||
|
|
|||
| if (index != -1) { | |||
| Future.delayed(const Duration(milliseconds: 250), () { | |||
| if (latestWatchedPosts != null) { | |||
| if (latestWatchedPosts.postIndex != -1) { | |||
| Future.delayed(const Duration(milliseconds: 50), () { | |||
| if (mounted && itemScrollController.isAttached) { | |||
| itemScrollController.scrollTo( | |||
| index: index, | |||
| index: latestWatchedPosts.postIndex, | |||
| alignment: 0, | |||
| duration: const Duration(milliseconds: 500), | |||
There was a problem hiding this comment.
scrollToLastWatchedPosts scrolls to latestWatchedPosts.postIndex without validating it against the current allPosts.length. If the stored index is out of range (e.g., thread data changed), scrollTo can throw. Use the allPosts parameter to bounds-check/clamp before scrolling (and/or early return when out of range).
| import 'package:flutter_chan/Models/watched_media.dart'; | ||
| import 'package:shared_preferences/shared_preferences.dart'; | ||
|
|
||
| class WatchedMediaProvider with ChangeNotifier, WidgetsBindingObserver { | ||
| WatchedMediaProvider() { | ||
| loadWatchedMedia(); | ||
| class WatchedPostsProvider with ChangeNotifier, WidgetsBindingObserver { | ||
| WatchedPostsProvider() { | ||
| loadWatchedPosts(); |
There was a problem hiding this comment.
This provider and its model were renamed to WatchedPosts*, but the files are still named watched_media_model.dart / watched_media.dart. This mismatch makes the codebase harder to navigate and can be confusing for future refactors. Consider renaming the files to match the new responsibility (and updating imports).
| ffmpeg_kit_flutter_new: | ||
| git: | ||
| url: https://github.com/dart-technologies/ffmpeg_kit_flutter.git | ||
| ref: arm64-simulator-support |
There was a problem hiding this comment.
The ffmpeg_kit_flutter_new dependency is pinned to a named git ref (arm64-simulator-support), which can move over time and makes builds non-reproducible. Prefer pinning to an immutable commit SHA (or a tagged release) to ensure consistent dependency resolution.
| ref: arm64-simulator-support | |
| ref: <FULL_40_CHAR_COMMIT_SHA_FOR_ARM64_SIMULATOR_SUPPORT> |
…es/gif Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
…FeedVideoPlayer Co-authored-by: Copilot <copilot@github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 32 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (1)
lib/pages/thread/thread_page.dart:137
latestWatchedPosts.postIndexis only checked against-1before callingitemScrollController.scrollTo. If the stored index is >=allPosts.length(e.g., thread shrank/archived, or indices shifted),scrollTocan throw. Consider clamping/validatingpostIndexagainstallPosts.length - 1before scrolling.
final latestWatchedPosts = watchedPosts.getLatestWatchedPosts(
widget.thread,
);
if (latestWatchedPosts != null) {
if (latestWatchedPosts.postIndex != -1) {
Future.delayed(const Duration(milliseconds: 50), () {
if (mounted && itemScrollController.isAttached) {
itemScrollController.scrollTo(
index: latestWatchedPosts.postIndex,
alignment: 0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOutCubic,
);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (prefs.getInt('watchedMediaRetentionDays') != null) { | ||
| watchedMediaRetentionDays = prefs.getInt('watchedMediaRetentionDays')!; | ||
| if (prefs.getInt('watchedPostsRetentionDays') != null) { | ||
| watchedPostsRetentionDays = prefs.getInt('watchedPostsRetentionDays')!; |
| @@ -768,10 +765,15 @@ class _FeedVideoPlayerState extends State<FeedVideoPlayer> { | |||
| // Ignore toggle failures. | |||
| } | |||
| }, | |||
| child: Icon( | |||
| _isMuted ? Icons.volume_off : Icons.volume_up, | |||
| color: Colors.white, | |||
| size: 16, | |||
| child: buildBlurPill( | |||
| child: Container( | |||
| padding: const EdgeInsets.all(8), | |||
| child: Icon( | |||
| _isMuted ? Icons.volume_off : Icons.volume_up, | |||
| color: Colors.white, | |||
| size: 16, | |||
| ), | |||
| ), | |||
| ), | |||
| snapshot.connectionState == ConnectionState.waiting | ||
| ? ThreadStatus.online | ||
| : failedToLoad | ||
| ? ThreadStatus.deleted | ||
| : (snapshot.data?.status ?? ThreadStatus.online); | ||
| final ThreadReplyCount? replyCount = | ||
| snapshot.connectionState == ConnectionState.waiting || failedToLoad | ||
| ? null | ||
| : snapshot.data?.replies; | ||
| final bool isDeleted = status == ThreadStatus.deleted; |
| Future<void> markAsWatched({ | ||
| required int postIndex, | ||
| required int thread, | ||
| }) async { | ||
| final WatchedPosts newWatchedPost = WatchedPosts( | ||
| postIndex: postIndex, | ||
| thread: thread, | ||
| watchedAt: DateTime.now(), | ||
| ); | ||
|
|
||
| final existingMediaIndex = _watchedPosts.indexWhere( | ||
| (media) => media.postIndex == postIndex && media.thread == thread, | ||
| ); | ||
|
|
||
| if (existingMediaIndex != -1) { | ||
| _watchedPosts[existingMediaIndex] = newWatchedPost; | ||
| } else { | ||
| _watchedPosts.add(newWatchedPost); | ||
| } | ||
|
|
||
| await _saveWatchedPosts(); | ||
| notifyListeners(); | ||
| } |
| GestureDetector( | ||
| onTap: () => {if (goUp == null) animateToTop() else goUp!()}, | ||
| child: buildBlurPill( | ||
| child: const Icon( | ||
| Icons.keyboard_arrow_up_rounded, | ||
| size: 28, | ||
| color: CupertinoColors.white, | ||
| ), | ||
| padding: const EdgeInsets.all(8), | ||
| ), | ||
| ), | ||
| const SizedBox(height: 14), | ||
| FloatingActionButton( | ||
| child: const Icon(Icons.keyboard_arrow_down_rounded, size: 28), | ||
| elevation: 0, | ||
| backgroundColor: theme.getTheme() == ThemeData.light() | ||
| ? Colors.white.withValues(alpha: 0.92) | ||
| : const Color(0xFF171A20).withValues(alpha: 0.92), | ||
| foregroundColor: CupertinoColors.activeBlue, | ||
| onPressed: () => { | ||
| if (goDown == null) animateToBottom() else goDown!(), | ||
| }, | ||
| heroTag: null, | ||
|
|
||
| GestureDetector( | ||
| onTap: () => {if (goDown == null) animateToBottom() else goDown!()}, | ||
| child: buildBlurPill( |
| GestureDetector( | ||
| onTap: () => { | ||
| if (isFavorite) | ||
| bookmarks.removeBookmarks(favorite) | ||
| else | ||
| bookmarks.addBookmarks(favorite), | ||
| }, | ||
|
|
||
| child: Container( | ||
| padding: const EdgeInsets.all(8), | ||
| decoration: BoxDecoration( | ||
| color: Colors.black.withValues(alpha: 0.35), | ||
| borderRadius: BorderRadius.circular(999), | ||
| ), | ||
| child: Icon( | ||
| isFavorite | ||
| ? CupertinoIcons.bookmark_fill | ||
| : CupertinoIcons.bookmark, | ||
| size: 18, | ||
| color: isFavorite | ||
| ? CupertinoColors.activeBlue | ||
| : secondaryText, | ||
| ), |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…rsistence logic Co-authored-by: Copilot <copilot@github.com>
No description provided.