From b7865877bdf78f2f8b7f277ea8ee30f2a6e73f1b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:19:12 +0000 Subject: [PATCH] Add notification bells, a taste-based For You and BooruPlus-style settings - Each follow gets an X-style bell (push / in app / off) on its profile and in Following; new follows start at a default chosen in settings. Push notifications can pop up as banners (a high-importance channel), in-app alerts show while Pawress is open, and off follows aren't checked. - New notification settings page, reachable from Settings and the Notifications page, including a link to Android's notification settings. - A thumbs up on posts stores a local Like. For You now ranks posts by a Taste built from follows, likes and favorites, drawing most of each page from followed and liked artists (sampled, at most six creator requests per page) and the rest from recent posts. The mix is set in Settings. - Settings rebuilt with BooruPlus's rounded panels and choice cards; changes still apply instantly. - Backups carry likes and bells. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LtGkvxuDRft2Lf4Jh7PW5E --- AGENT.md | 49 +- .../com/example/pawress/MainActivity.kt | 21 +- lib/core/app_state.dart | 124 ++++- lib/core/backup.dart | 23 +- lib/core/feeds.dart | 53 -- lib/core/for_you.dart | 385 ++++++++++++++ lib/core/models.dart | 72 +++ lib/core/store.dart | 62 +++ lib/core/updates.dart | 87 +++- lib/ui/backup.dart | 14 +- lib/ui/creator_page.dart | 5 + lib/ui/favorites_page.dart | 4 +- lib/ui/following_page.dart | 3 + lib/ui/home.dart | 77 ++- lib/ui/notification_settings.dart | 303 +++++++++++ lib/ui/notifications_page.dart | 10 + lib/ui/notify_bell.dart | 139 +++++ lib/ui/post_card.dart | 37 +- lib/ui/settings_cards.dart | 392 ++++++++++++++ lib/ui/settings_page.dart | 486 ++++++++++-------- test/backup_test.dart | 50 ++ test/core_test.dart | 41 +- test/for_you_test.dart | 264 ++++++++++ test/update_checker_test.dart | 28 + 24 files changed, 2395 insertions(+), 334 deletions(-) create mode 100644 lib/core/for_you.dart create mode 100644 lib/ui/notification_settings.dart create mode 100644 lib/ui/notify_bell.dart create mode 100644 lib/ui/settings_cards.dart create mode 100644 test/for_you_test.dart diff --git a/AGENT.md b/AGENT.md index fe5e9cf..7abd493 100644 --- a/AGENT.md +++ b/AGENT.md @@ -33,16 +33,17 @@ lib/ main.dart App root: splash -> onboarding -> home, revealed from the bottom core/ No widgets (except updates.dart's Flutter binding); safe for the background isolate config.dart Hosts, user agent, page size - models.dart Creator, Post, MediaFile, Poll, Favorite, Follow, Community*, UpdateNotice, ... and JSON parsing; threadComments + models.dart Creator, Post, MediaFile, Poll, Favorite, Like, Follow + NotifyLevel, Community*, UpdateNotice, ... and JSON parsing; threadComments api.dart PawchiveApi (all HTTP); scrapers for DMs, popular posts, similar artists, community chats store.dart Store: SharedPreferencesAsync + secure storage (session cookie); AppSettings backup.dart Backup file (follows, likes, sign-in sealed with AES-GCM + PBKDF2), mergeFollows / mergeFavorites - feeds.dart Feed (paged ChangeNotifier), OffsetFeed, PopularFeed, ForYouFeed + rankForYou, FollowingFeed (k-way merge) + feeds.dart Feed (paged ChangeNotifier), OffsetFeed, PopularFeed, FollowingFeed (k-way merge) + for_you.dart ForYouFeed: Taste (follows, likes, favorites), pickSources, takeBest, blendForYou creators.dart CreatorDirectory: cached, throttled profile lookups (feed posts carry only ids) - updates.dart UpdateChecker, Notifier (local notifications), BackgroundUpdates (WorkManager) + updates.dart UpdateChecker, pushable, Notifier (local notifications, banner channel), BackgroundUpdates (WorkManager) downloads.dart DownloadManager queue (2 at once, 1 s apart) writing to a SAF folder via DownloadTarget app_update.dart AppUpdater: GitHub releases check (AppRelease.newest) and install via the pawress/updater channel - app_state.dart AppState: settings, account, follows, liked posts, notices, favorites sync, backup import/export + app_state.dart AppState: settings, account, follows + bells, likes, favorites, notices + in-app alerts, sync, backup ui/ app_scope.dart AppScope.of(context) / AppScope.read(context) give AppState theme.dart Palette (from assets), buildTheme(), BrandMark, SectionLabel @@ -51,7 +52,8 @@ lib/ onboarding.dart Four parallax slides (art lags, text leads), notification permission, optional sign-in home.dart HomeScreen (floating SliverAppBar + For You / Following / Popular tabs, Popular's bottom bar) and Sidebar (drawer) popular.dart PopularTab: popular artists (from /creators, sorted on device) and PopularFeed posts - post_card.dart X-style post row, PostActions (comments / like), MediaGrid, openPost/openCreator/openViewer helpers + post_card.dart X-style post row, PostActions (comments / thumbs up / favorite), MediaGrid, openPost/openCreator/openViewer helpers + notify_bell.dart NotifyBell (X-style bell beside Following) and showNotifySheet comments.dart CommentList (threaded, left-aligned) and showComments (bottom sheet) poll.dart PollView: a Patreon poll's results as bars autoplay.dart FeedAutoplay (picks the one playing tile) and AutoplayMedia (muted video / GIF tile) @@ -62,14 +64,18 @@ lib/ creator_page.dart Artist profile (banner, site, links, Follow, Similar artists sheet) + tabs; FollowButton, ArtistTile community_page.dart CommunityChatPage: a Patreon community channel as a chat log, newest at the bottom backup.dart Export / import dialogs (SAF folder and file pickers) - favorites_page.dart, following_page.dart, notifications_page.dart, search_page.dart, settings_page.dart, login_page.dart + settings_cards.dart BooruPlus-style settings panels (SettingsGroup, SettingsSwitchCard, SettingsChoiceCard, ...) + settings_page.dart Settings, laid out like BooruPlus's but applied instantly (no save button) + notification_settings.dart X-style notification preferences and per-artist bells + favorites_page.dart, following_page.dart, notifications_page.dart, search_page.dart, login_page.dart test/ - core_test.dart Parsing, URLs, ranking, entity decoding, polls, fancards, comment threads, DM / popular / similar-artist / community scraping + for_you_test.dart Taste, source sampling, blending and ForYouFeed over fake fetches + core_test.dart Parsing, URLs, entity decoding, polls, fancards, comment threads, DM / popular / similar-artist / community scraping backup_test.dart Backup encoding and sealing, merges, AppState likes and their account sync update_checker_test.dart UpdateChecker against a fake Pawchive (MockClient + in-memory prefs) downloads_test.dart DownloadManager: URL resolution, naming, failure and retry (in-memory target) app_update_test.dart Release picking, nightly notes, AppUpdater over a mocked channel and GitHub -android/app/src/main/kotlin/.../MainActivity.kt pawress/updater channel: appVersion, installUpdate +android/app/src/main/kotlin/.../MainActivity.kt pawress/updater channel: appVersion, installUpdate, notificationSettings .github/workflows/ ci.yml (PRs, and called by CD) and cd.yml (nightly / tagged releases) docs/pawchive-api.md API reference and quirks ``` @@ -85,7 +91,10 @@ docs/pawchive-api.md API reference and quirks - **Full files.** `MediaFile.fullUrl` gives `temp_url` or the file host URL, or null when the post must be fetched again; the viewer does that. Thumbnails exist only for images. -- **Favorites (likes).** The heart on a post (`PostActions`) stores the whole +- **Likes (thumbs up).** The thumbs up on a post stores a `Like` (post key, + creator, tags, time) on the device only; Pawchive has no likes. It only + feeds For You, and Settings can reset them. They go into backups. +- **Favorites.** The heart on a post (`PostActions`) stores the whole `Post` locally (`Favorite`, without `temp_url`s, which expire) and, when signed in, syncs with the account's favorite posts the same way follows do (`Favorite.synced`). The Favorites page in the sidebar lists them with no @@ -108,6 +117,15 @@ docs/pawchive-api.md API reference and quirks with Pawchive favorite creators (`AppState.syncFavorites`): `Follow.synced` marks follows already seen on the account, so a missing synced follow means it was removed on the website. +- **Notification levels.** Each `Follow` has a `NotifyLevel` (the bell on + the profile and in Following): `push` (Android notification, on the + `artist_updates_banner` high-importance channel when + `AppSettings.notifyBanners` is on, otherwise `artist_updates`), `inApp` + (Notifications page, badge and an in-app alert snackbar) or `off` (not + checked, no requests). New follows start at `AppSettings.defaultNotify`. + Turning a bell on from off clears `seenPostIds` so the next check only + records a baseline. HomeScreen polls the store each minute while + resumed (`AppState.pollNotices`, no network) to raise in-app alerts. - **Notifications.** `UpdateChecker.check()` polls each follow's profile `updated` stamp, fetches the first page only when it changed, and treats post ids not in `Follow.seenPostIds` as new. A creator's first check only @@ -116,9 +134,15 @@ docs/pawchive-api.md API reference and quirks minimum). The background isolate builds its own `Store` and `PawchiveApi`, so keep `core/` free of UI dependencies. The app reloads follows and notices from the store on resume. -- **For You.** The API has no recommendations. Each page of `/posts` is - reordered by `rankForYou` (recency, plus boosts for followed creators and - sites, without long runs from one creator). +- **For You.** The API has no recommendations; `ForYouFeed` builds them. + `Taste` weighs creators, sites and tags from follows, thumbs-up likes + (half-life 30 days) and favorites (60 days). Each page takes + `ForYouMix.followingShare` (Settings) of its 30 posts from followed and + liked artists, whose first pages are fetched a few at a time + (`pickSources`: weighted random by affinity and recent activity; at most + 6 creator requests per page), and the rest from `/posts`. Both pools are + ranked by `Taste.score` (freshness + taste + jitter), capped per creator, + blended and spread so no artist fills consecutive rows. - **Popular.** There's no API for it. Popular posts are scraped from `/posts/popular` (`parsePopularPage`); those posts only carry a thumbnail path, so their file is marked preview-only and the viewer and downloads @@ -208,6 +232,7 @@ scheduled. - iOS and other platforms (only `android/` exists). - Blocked tags and a light theme. +- A "not interested" signal for For You (only positive likes exist). - Downloads run only while the app is open (no foreground service or download notification). - Older popular ranges (the page's "prev" links are parsed into diff --git a/android/app/src/main/kotlin/com/example/pawress/MainActivity.kt b/android/app/src/main/kotlin/com/example/pawress/MainActivity.kt index b20dcbc..d84efcf 100644 --- a/android/app/src/main/kotlin/com/example/pawress/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/pawress/MainActivity.kt @@ -3,7 +3,9 @@ package com.example.pawress import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.net.Uri import android.os.Build +import android.provider.Settings import androidx.core.content.FileProvider import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -19,7 +21,8 @@ import java.util.concurrent.Executors * Hosts the `pawress/updater` channel used by lib/core/app_update.dart: the * installed version, and downloading a GitHub release APK into the package * installer once it is checked to be a newer build of this app signed with - * the same key. + * the same key. It also opens the app's page in Android's notification + * settings for lib/core/updates.dart. */ class MainActivity : FlutterActivity() { private val executor = Executors.newSingleThreadExecutor() @@ -45,10 +48,26 @@ class MainActivity : FlutterActivity() { result.success(mapOf("name" to (info.versionName ?: ""), "build" to versionCode(info))) } "installUpdate" -> installUpdate(call, result) + "notificationSettings" -> result.success(openNotificationSettings()) else -> result.notImplemented() } } + private fun openNotificationSettings(): Boolean { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")) + } + return try { + startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (_: Exception) { + false + } + } + private fun installUpdate(call: MethodCall, result: MethodChannel.Result) { val url = call.argument("url") if (url == null || !isUpdateUrl(url)) { diff --git a/lib/core/app_state.dart b/lib/core/app_state.dart index c385a1e..728c708 100644 --- a/lib/core/app_state.dart +++ b/lib/core/app_state.dart @@ -7,6 +7,7 @@ import 'app_update.dart'; import 'backup.dart'; import 'creators.dart'; import 'downloads.dart'; +import 'for_you.dart'; import 'models.dart'; import 'store.dart'; import 'updates.dart'; @@ -41,7 +42,11 @@ class AppState extends ChangeNotifier { List follows = []; List favorites = []; List notices = []; + + /// Thumbs-up likes, newest first; they only feed For You. + List likes = []; Set _favoriteKeys = {}; + Set _likeKeys = {}; /// A foreground update check is running. bool checking = false; @@ -49,6 +54,10 @@ class AppState extends ChangeNotifier { /// Shown once by the home screen, e.g. after a session expired. String? message; + /// New posts the background check found while the app was open, shown + /// once by the home screen as an in-app alert. + List? _alert; + Future>? _allCreators; bool get signedIn => api.session != null; @@ -61,6 +70,7 @@ class AppState extends ChangeNotifier { accountName = await store.accountName(); follows = await store.follows(); _setFavorites(await store.favorites()); + _setLikes(await store.likes()); notices = await store.notices(); directory.remember(follows.map((f) => f.creator)); initialized = true; @@ -69,13 +79,45 @@ class AppState extends ChangeNotifier { if (signedIn) unawaited(syncFavorites()); } - /// Picks up follows and notices the background check wrote. - Future reload() async { + /// Picks up follows and notices the background check wrote. With + /// [alert], notices that are new since the last load become an in-app + /// alert when that is turned on. + Future reload({bool alert = false}) async { + final known = {for (final notice in notices) notice.id}; follows = await store.follows(); notices = await store.notices(); + if (alert && settings.notificationsEnabled && settings.inAppAlerts) { + final levels = {for (final follow in follows) follow.key: follow.notify}; + final fresh = [ + for (final notice in notices) + if (!notice.read && + !known.contains(notice.id) && + (levels[notice.creator.key] ?? NotifyLevel.off) != + NotifyLevel.off) + notice, + ]; + if (fresh.isNotEmpty) _alert = fresh; + } notifyListeners(); } + /// Reloads only when the stored notices changed, so the open app can look + /// for the background check's results cheaply. + Future pollNotices() async { + final stored = await store.notices(); + if (stored.length == notices.length && + stored.firstOrNull?.id == notices.firstOrNull?.id) { + return; + } + await reload(alert: true); + } + + List? takeAlert() { + final alert = _alert; + _alert = null; + return alert; + } + Future completeOnboarding() async { onboarded = true; await store.setOnboarded(true); @@ -97,7 +139,10 @@ class AppState extends ChangeNotifier { Future _schedule() async { try { - await BackgroundUpdates.apply(settings, hasFollows: follows.isNotEmpty); + await BackgroundUpdates.apply( + settings, + hasFollows: follows.any((f) => f.notify != NotifyLevel.off), + ); } catch (e) { debugPrint('Could not schedule background updates: $e'); } @@ -113,7 +158,11 @@ class AppState extends ChangeNotifier { Future follow(Creator creator) async { if (isFollowing(creator.key)) return; follows = [ - Follow(creator: creator, followedAt: DateTime.now().toUtc()), + Follow( + creator: creator, + followedAt: DateTime.now().toUtc(), + notify: settings.defaultNotify, + ), ...follows, ]; await _saveFollows(); @@ -141,6 +190,27 @@ class AppState extends ChangeNotifier { } } + /// Sets the bell for a followed creator. Coming back from off, posts + /// made meanwhile weren't checked, so the next check only records a new + /// baseline instead of reporting them all. + Future setNotify(String key, NotifyLevel level) async { + final follow = followOf(key); + if (follow == null || follow.notify == level) return; + _replace(key, (f) => _withNotify(f, level)); + await _saveFollows(); + } + + /// Sets every follow's bell at once. + Future setNotifyAll(NotifyLevel level) async { + follows = [for (final f in follows) _withNotify(f, level)]; + await _saveFollows(); + } + + static Follow _withNotify(Follow follow, NotifyLevel level) => + follow.notify == NotifyLevel.off && level != NotifyLevel.off + ? follow.copyWith(notify: level, seenPostIds: const []) + : follow.copyWith(notify: level); + /// Records a new follow's current posts so only later ones notify. Future _baseline(String key) async { final follow = followOf(key); @@ -222,7 +292,7 @@ class AppState extends ChangeNotifier { await _schedule(); } - // Favorites (liked posts). + // Favorites (the heart), synced with the account's favorite posts. bool isFavorite(Post post) => _favoriteKeys.contains(post.key); @@ -245,7 +315,7 @@ class AppState extends ChangeNotifier { ]); await _saveFavorites(); } on ApiException catch (e) { - _say('Liked here, but not on your Pawchive account: ${e.message}'); + _say('Favorited here, but not on your Pawchive account: ${e.message}'); } } } @@ -257,7 +327,9 @@ class AppState extends ChangeNotifier { try { await api.removeFavoritePost(post); } on ApiException catch (e) { - _say('Unliked here, but not on your Pawchive account: ${e.message}'); + _say( + 'Removed here, but not from your Pawchive favorites: ${e.message}', + ); } } } @@ -272,6 +344,39 @@ class AppState extends ChangeNotifier { await store.saveFavorites(favorites); } + // Likes (thumbs up). + + bool isLiked(Post post) => _likeKeys.contains(post.key); + + Future toggleLike(Post post) async { + _setLikes( + isLiked(post) + ? likes.where((like) => like.key != post.key).toList() + : [Like.of(post, DateTime.now().toUtc()), ...likes], + ); + notifyListeners(); + await store.saveLikes(likes); + } + + Future clearLikes() async { + _setLikes([]); + notifyListeners(); + await store.saveLikes(likes); + } + + void _setLikes(List value) { + likes = value; + _likeKeys = {for (final like in value) like.key}; + } + + /// What For You learns from, read for each page. + ForYouSignals forYouSignals() => ( + follows: follows, + likes: likes, + favorites: favorites, + followingShare: settings.forYouMix.followingShare, + ); + // Backup. /// Follows and likes, plus the sign-in sealed with [password] when one is @@ -279,6 +384,7 @@ class AppState extends ChangeNotifier { Future exportBackup({String? password}) async => Backup( follows: follows, favorites: favorites, + likes: likes, credentials: password == null || password.isEmpty || !signedIn ? null : await SealedCredentials.seal( @@ -299,6 +405,8 @@ class AppState extends ChangeNotifier { final favoriteCount = favorites.length; follows = mergeFollows(follows, backup.follows); _setFavorites(mergeFavorites(favorites, backup.favorites)); + _setLikes(mergeLikes(likes, backup.likes)); + await store.saveLikes(likes); directory.remember(follows.map((f) => f.creator)); await _saveFollows(); await _saveFavorites(); @@ -414,7 +522,7 @@ class AppState extends ChangeNotifier { ]); await _saveFavorites(); } on ApiException catch (e) { - if (!e.unauthorized) _say('Could not sync likes: ${e.message}'); + if (!e.unauthorized) _say('Could not sync favorites: ${e.message}'); } } diff --git a/lib/core/backup.dart b/lib/core/backup.dart index 59fadb7..adadb2e 100644 --- a/lib/core/backup.dart +++ b/lib/core/backup.dart @@ -22,12 +22,16 @@ class Backup { const Backup({ required this.follows, required this.favorites, + this.likes = const [], this.credentials, this.exportedAt, }); final List follows; final List favorites; + + /// Thumbs-up likes, which teach For You. + final List likes; final SealedCredentials? credentials; final DateTime? exportedAt; @@ -40,6 +44,7 @@ class Backup { 'exportedAt': (exportedAt ?? DateTime.now().toUtc()).toIso8601String(), 'follows': [for (final follow in follows) follow.toJson()], 'favorites': [for (final favorite in favorites) favorite.toJson()], + 'likes': [for (final like in likes) like.toJson()], if (credentials != null) 'credentials': credentials!.toJson(), }); @@ -68,6 +73,7 @@ class Backup { return Backup( follows: list('follows', Follow.fromJson), favorites: list('favorites', Favorite.fromJson), + likes: list('likes', Like.fromJson), credentials: credentials is Map ? SealedCredentials.fromJson(credentials) : null, @@ -185,7 +191,11 @@ List mergeFollows(List current, List imported) { ...current, for (final follow in imported) if (have.add(follow.key)) - Follow(creator: follow.creator, followedAt: follow.followedAt), + Follow( + creator: follow.creator, + followedAt: follow.followedAt, + notify: follow.notify, + ), ]; } @@ -198,3 +208,14 @@ List mergeFavorites(List current, List imported) { if (have.add(favorite.key)) favorite.copyWith(synced: false), ]..sort((a, b) => b.favoritedAt.compareTo(a.favoritedAt)); } + +/// [current] plus the thumbs-up likes in [imported] it doesn't have, newest +/// first. +List mergeLikes(List current, List imported) { + final have = {for (final like in current) like.key}; + return [ + ...current, + for (final like in imported) + if (have.add(like.key)) like, + ]..sort((a, b) => b.likedAt.compareTo(a.likedAt)); +} diff --git a/lib/core/feeds.dart b/lib/core/feeds.dart index fdccb38..7de1072 100644 --- a/lib/core/feeds.dart +++ b/lib/core/feeds.dart @@ -120,59 +120,6 @@ class PopularFeed extends Feed { } } -/// Site-wide recent posts, reordered page by page toward the creators and -/// sites the user follows. The API has no recommendation endpoint, so this -/// is the whole "algorithm": recency, a boost for followed creators, a -/// smaller one for followed sites, and no long runs from one creator. -class ForYouFeed extends OffsetFeed { - ForYouFeed(PawchiveApi api, this._follows) - : super((offset) => api.recentPosts(offset: offset)); - - final List Function() _follows; - - @override - List transform(List page) => rankForYou(page, _follows()); -} - -@visibleForTesting -List rankForYou(List page, List follows) { - if (follows.isEmpty || page.isEmpty) return _spread(page); - final followed = {for (final follow in follows) follow.key}; - final services = {}; - for (final follow in follows) { - services[follow.creator.service] = - (services[follow.creator.service] ?? 0) + 1; - } - // Each point is worth one position in the page: a followed creator's post - // can climb 20 places, a post from a followed site up to 6. - double score(int index, Post post) => - -index.toDouble() + - (followed.contains(post.creatorKey) ? 20 : 0) + - 6 * (services[post.service] ?? 0) / follows.length; - final scored = - [ - for (final (index, post) in page.indexed) - (post, index, score(index, post)), - ]..sort((a, b) { - final byScore = b.$3.compareTo(a.$3); - return byScore != 0 ? byScore : a.$2.compareTo(b.$2); - }); - return _spread([for (final entry in scored) entry.$1]); -} - -/// Moves posts so one creator does not fill several rows in a row, keeping -/// the order otherwise. -List _spread(List posts) { - final pending = [...posts]; - final result = []; - while (pending.isNotEmpty) { - final last = result.isEmpty ? null : result.last.creatorKey; - final index = pending.indexWhere((post) => post.creatorKey != last); - result.add(pending.removeAt(index < 0 ? 0 : index)); - } - return result; -} - /// Every followed creator's posts merged newest first. Each creator is /// paged on its own and only fetched again once its loaded posts have all /// been placed. diff --git a/lib/core/for_you.dart b/lib/core/for_you.dart new file mode 100644 index 0000000..a487cff --- /dev/null +++ b/lib/core/for_you.dart @@ -0,0 +1,385 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import 'api.dart'; +import 'config.dart'; +import 'feeds.dart'; +import 'models.dart'; + +/// What For You learns from, read again for every page so a thumbs up +/// changes what comes next. +typedef ForYouSignals = ({ + List follows, + List likes, + List favorites, + + /// The share of a page drawn from followed and liked artists. + double followingShare, +}); + +/// The user's taste as weights: per creator, per site and per tag. Follows +/// count fully; thumbs-up likes and favorites fade with age so the feed +/// follows what the user is into now. +class Taste { + Taste._({ + required this.creators, + required this.services, + required this.tags, + required this.followed, + }); + + factory Taste.from(ForYouSignals signals, {DateTime? now}) { + final at = now ?? DateTime.now().toUtc(); + final creators = {}; + final services = {}; + final tags = {}; + void add(Map map, String key, double value) => + map[key] = (map[key] ?? 0) + value; + double fade(DateTime when, int halfLifeDays) => pow( + .5, + max(0, at.difference(when).inHours) / (24 * halfLifeDays), + ).toDouble(); + + for (final follow in signals.follows) { + add(creators, follow.key, _followWeight); + add(services, follow.creator.service, 1); + } + for (final like in signals.likes) { + final weight = fade(like.likedAt, _likeHalfLifeDays); + add(creators, like.creatorKey, 2 * weight); + add(services, like.service, weight); + for (final tag in like.tags) { + add(tags, _tagKey(tag), weight); + } + } + for (final favorite in signals.favorites) { + final weight = fade(favorite.favoritedAt, _favoriteHalfLifeDays); + final post = favorite.post; + add(creators, post.creatorKey, 1.5 * weight); + add(services, post.service, .5 * weight); + for (final tag in post.tags) { + add(tags, _tagKey(tag), .75 * weight); + } + } + return Taste._( + creators: creators, + services: _normalized(services), + tags: _normalized(tags), + followed: {for (final follow in signals.follows) follow.key}, + ); + } + + /// A follow is worth this many fresh likes and a half. + static const _followWeight = 3.0; + static const _likeHalfLifeDays = 30; + static const _favoriteHalfLifeDays = 60; + + /// A post this many hours old has half the freshness of a new one. + static const _freshHalfLifeHours = 36.0; + + /// Raw affinity per creator key; grows with every follow, like and + /// favorite. + final Map creators; + + /// Per site and per tag, scaled so the strongest is 1. + final Map services; + final Map tags; + final Set followed; + + bool get isEmpty => creators.isEmpty; + + /// How much the user likes [creatorKey], with diminishing returns so one + /// artist cannot take over the feed. + double affinity(String creatorKey) => log(1 + (creators[creatorKey] ?? 0)); + + /// Higher is better: freshness (up to 2) plus taste for the creator, the + /// post's tags and its site. + double score(Post post, DateTime now) { + final date = post.date; + final hours = date == null + ? 24.0 * 30 + : max(0, now.difference(date).inMinutes) / 60; + final fresh = pow(.5, hours / _freshHalfLifeHours); + var tagMatch = 0.0; + for (final tag in post.tags) { + tagMatch += tags[_tagKey(tag)] ?? 0; + } + return 2 * fresh + + affinity(post.creatorKey) + + min(tagMatch, 1.5) + + .4 * (services[post.service] ?? 0); + } + + static String _tagKey(String tag) => tag.trim().toLowerCase(); + + static Map _normalized(Map weights) { + if (weights.isEmpty) return weights; + final top = weights.values.reduce(max); + if (top <= 0) return const {}; + return {for (final e in weights.entries) e.key: e.value / top}; + } +} + +/// The creators For You pulls posts from, in the order to fetch them: +/// followed artists plus artists the user liked or favorited without +/// following. Each is weighted by [Taste.affinity] and how recently they +/// updated, then sampled (weighted random order) so every refresh starts +/// with different artists instead of always the same top few. +@visibleForTesting +List pickSources( + Taste taste, + List follows, + Random random, { + DateTime? now, +}) { + final at = now ?? DateTime.now().toUtc(); + final candidates = { + for (final follow in follows) follow.key: follow.creator, + }; + final liked = + taste.creators.entries + .where((e) => !candidates.containsKey(e.key) && e.value >= .5) + .toList() + ..sort((a, b) => b.value.compareTo(a.value)); + for (final entry in liked.take(_likedSources)) { + final [service, id] = entry.key.split('/'); + candidates[entry.key] = Creator(id: id, service: service, name: id); + } + double activity(Creator creator) { + final updated = creator.updated; + if (updated == null) return .5; + final days = max(0, at.difference(updated).inHours) / 24; + return max(.15, pow(.5, days / 14).toDouble()); + } + + // Efraimidis–Spirakis: sorting by u^(1/w) samples without replacement in + // proportion to w. + final keyed = [ + for (final creator in candidates.values) + ( + creator, + pow( + random.nextDouble(), + 1 / max(1e-6, taste.affinity(creator.key) * activity(creator)), + ), + ), + ]..sort((a, b) => b.$2.compareTo(a.$2)); + return [for (final entry in keyed) entry.$1]; +} + +/// Most artists that are liked but not followed taken as sources. +const _likedSources = 30; + +/// Lays [a] and [b] out in proportion to their lengths (a then b on ties), +/// keeping each list's order. +@visibleForTesting +List blendForYou(List a, List b) { + final total = a.length + b.length; + final result = []; + var i = 0; + var j = 0; + for (var n = 0; n < total; n++) { + if (i < a.length && (j >= b.length || i * total < (n + 1) * a.length)) { + result.add(a[i++]); + } else { + result.add(b[j++]); + } + } + return result; +} + +/// Moves posts so one creator does not fill several rows in a row, keeping +/// the order otherwise. +@visibleForTesting +List spreadCreators(List posts) { + final pending = [...posts]; + final result = []; + while (pending.isNotEmpty) { + final last = result.isEmpty ? null : result.last.creatorKey; + final index = pending.indexWhere((post) => post.creatorKey != last); + result.add(pending.removeAt(index < 0 ? 0 : index)); + } + return result; +} + +/// The best [count] of [posts] by [score], at most [perCreator] from one +/// creator. The chosen posts are removed from [posts]. +@visibleForTesting +List takeBest( + List posts, + int count, + double Function(Post) score, { + int perCreator = 3, +}) { + if (count <= 0 || posts.isEmpty) return const []; + final scored = [for (final post in posts) (post, score(post))] + ..sort((a, b) => b.$2.compareTo(a.$2)); + final perKey = {}; + final chosen = []; + for (final (post, _) in scored) { + if (chosen.length >= count) break; + final used = perKey[post.creatorKey] ?? 0; + if (used >= perCreator) continue; + perKey[post.creatorKey] = used + 1; + chosen.add(post); + } + final picked = chosen.toSet(); + posts.removeWhere(picked.contains); + return chosen; +} + +/// The For You timeline. Pawchive has no recommendations, so it is built +/// here from two pools: +/// +/// - **From your artists**: the first page of followed artists and of +/// artists you gave a thumbs up or favorited, a few creators per page, +/// picked by [pickSources]. +/// - **Discovery**: site-wide recent posts. +/// +/// Both are ranked by [Taste.score] with a little jitter, capped per +/// creator, blended by [ForYouSignals.followingShare] and spread so one +/// artist never fills consecutive rows. Without follows or likes it is the +/// recent feed. +class ForYouFeed extends Feed { + ForYouFeed( + PawchiveApi api, + ForYouSignals Function() signals, { + Random? random, + }) : this.custom( + recent: (offset) => api.recentPosts(offset: offset), + creatorPosts: (creator) => + api.creatorPosts(creator.service, creator.id), + signals: signals, + random: random, + ); + + @visibleForTesting + ForYouFeed.custom({ + required this._recent, + required this._creatorPosts, + required this._signals, + Random? random, + }) : _random = random ?? Random(); + + final Future> Function(int offset) _recent; + final Future> Function(Creator creator) _creatorPosts; + final ForYouSignals Function() _signals; + final Random _random; + + /// Posts per page. + static const _batch = 30; + + /// Concurrent creator requests, and rounds of them per page, so a page + /// costs at most six creator requests (see docs/pawchive-api.md). + static const _parallel = 3; + static const _rounds = 2; + + /// Random points added to scores so equal posts trade places. + static const _jitter = .35; + + /// Candidates kept between pages; the weakest are dropped beyond this. + static const _maxPool = 400; + + List? _queue; + final _pool = []; + final _recentBuffer = []; + final _taken = {}; + int _offset = 0; + bool _recentDone = false; + + @override + void reset() { + _queue = null; + _pool.clear(); + _recentBuffer.clear(); + _taken.clear(); + _offset = 0; + _recentDone = false; + } + + @override + Future> fetchNext() async { + final signals = _signals(); + final now = DateTime.now().toUtc(); + final taste = Taste.from(signals, now: now); + final queue = _queue ??= pickSources( + taste, + signals.follows, + _random, + now: now, + ); + double score(Post post) => + taste.score(post, now) + _random.nextDouble() * _jitter; + + final wantFollowing = queue.isEmpty && _pool.isEmpty + ? 0 + : (_batch * signals.followingShare).round(); + for (var round = 0; round < _rounds; round++) { + if (_pool.length >= wantFollowing * 2 || queue.isEmpty) break; + final creators = queue.take(_parallel).toList(); + queue.removeRange(0, creators.length); + final pages = await Future.wait(creators.map(_loadCreator)); + for (final page in pages) { + _pool.addAll(page.where((post) => !_taken.contains(post.key))); + } + } + // Three posts per artist keeps a page varied, but with only a few + // artists to draw from they may fill more so the mix still holds. + final artists = {for (final post in _pool) post.creatorKey}.length; + final fromArtists = takeBest( + _pool, + wantFollowing, + score, + perCreator: max(3, (wantFollowing / max(1, artists)).ceil()), + ); + + final wantRecent = _batch - fromArtists.length; + var requests = 0; + while (_recentBuffer.length < wantRecent * 1.5 && + !_recentDone && + requests < 2) { + requests++; + final List page; + try { + page = await _recent(_offset); + } on ApiException { + if (fromArtists.isEmpty) rethrow; + break; + } + _offset += pageSize; + if (page.length < pageSize) _recentDone = true; + _recentBuffer.addAll(page.where((post) => !_taken.contains(post.key))); + } + final discovery = takeBest( + _recentBuffer..removeWhere((post) => _taken.contains(post.key)), + wantRecent, + score, + perCreator: 2, + ); + + final page = spreadCreators(blendForYou(fromArtists, discovery)); + _taken.addAll(page.map((post) => post.key)); + _pool.removeWhere((post) => _taken.contains(post.key)); + _trim(_pool, score); + _trim(_recentBuffer, score); + return page; + } + + /// One creator's newest posts; an unreachable creator just adds nothing. + Future> _loadCreator(Creator creator) async { + try { + return await _creatorPosts(creator); + } on ApiException { + return const []; + } + } + + static void _trim(List posts, double Function(Post) score) { + if (posts.length <= _maxPool) return; + final kept = takeBest(posts, _maxPool, score, perCreator: _maxPool); + posts + ..clear() + ..addAll(kept); + } +} diff --git a/lib/core/models.dart b/lib/core/models.dart index 6e4d01f..8cdd25d 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -701,6 +701,7 @@ class Follow { this.seenUpdated, this.unread = 0, this.synced = false, + this.notify = NotifyLevel.push, }); final Creator creator; @@ -721,6 +722,10 @@ class Follow { /// so its absence there means it was removed on the website. final bool synced; + /// What a new post from this creator does, chosen with the bell on their + /// profile. + final NotifyLevel notify; + String get key => creator.key; Follow copyWith({ @@ -729,6 +734,7 @@ class Follow { String? seenUpdated, int? unread, bool? synced, + NotifyLevel? notify, }) => Follow( creator: creator ?? this.creator, followedAt: followedAt, @@ -736,6 +742,7 @@ class Follow { seenUpdated: seenUpdated ?? this.seenUpdated, unread: unread ?? this.unread, synced: synced ?? this.synced, + notify: notify ?? this.notify, ); factory Follow.fromJson(Map json) => Follow( @@ -745,6 +752,8 @@ class Follow { seenUpdated: _optStr(json['seenUpdated']), unread: _int(json['unread']), synced: _bool(json['synced']), + // Follows saved before the bell existed kept notifying. + notify: NotifyLevel.values.asNameMap()[json['notify']] ?? NotifyLevel.push, ); Map toJson() => { @@ -754,6 +763,69 @@ class Follow { 'seenUpdated': seenUpdated, 'unread': unread, 'synced': synced, + 'notify': notify.name, + }; +} + +/// What a followed creator's new posts do, like the bell on an X profile. +enum NotifyLevel { + /// Not checked at all, so it costs no requests. + off('Off', 'No notifications from this artist'), + + /// Listed in Notifications with an unread badge, and an in-app alert + /// while Pawress is open. + inApp('In app', 'Notifications tab and in-app alerts only'), + + /// Also an Android notification, with a pop-up banner when that is on. + push('Push', 'Android notifications, plus everything in app'); + + const NotifyLevel(this.label, this.description); + + final String label; + final String description; +} + +/// A thumbs up on a post. It never leaves the device (Pawchive has no +/// likes); it only teaches For You what to show more of, so it keeps just +/// what the ranking reads. +class Like { + const Like({ + required this.key, + required this.service, + required this.user, + required this.likedAt, + this.tags = const [], + }); + + Like.of(Post post, this.likedAt) + : key = post.key, + service = post.service, + user = post.user, + tags = post.tags; + + /// The post's [Post.key]. + final String key; + final String service; + final String user; + final List tags; + final DateTime likedAt; + + String get creatorKey => '$service/$user'; + + factory Like.fromJson(Map json) => Like( + key: _str(json['key']), + service: _str(json['service']), + user: _str(json['user']), + tags: parseTags(json['tags']), + likedAt: parseDate(json['likedAt']) ?? DateTime.now().toUtc(), + ); + + Map toJson() => { + 'key': key, + 'service': service, + 'user': user, + 'tags': tags, + 'likedAt': likedAt.toIso8601String(), }; } diff --git a/lib/core/store.dart b/lib/core/store.dart index 7487212..b023f41 100644 --- a/lib/core/store.dart +++ b/lib/core/store.dart @@ -7,11 +7,30 @@ import 'models.dart'; enum ThemeChoice { plum, amoled } +/// How much of For You comes from followed artists. +enum ForYouMix { + following('Following', .85), + balanced('Balanced', .65), + discover('Discover', .4); + + const ForYouMix(this.label, this.followingShare); + + final String label; + + /// The share of each page taken from followed and liked artists; the rest + /// is site-wide recent posts ranked by taste. + final double followingShare; +} + class AppSettings { const AppSettings({ this.theme = ThemeChoice.plum, this.notificationsEnabled = true, + this.notifyBanners = true, + this.inAppAlerts = true, + this.defaultNotify = NotifyLevel.push, this.checkIntervalMinutes = 60, + this.forYouMix = ForYouMix.balanced, this.autoplayVideos = true, this.downloadFolderUri, this.downloadFolderName, @@ -24,6 +43,19 @@ class AppSettings { /// Background checks for followed artists' new posts. final bool notificationsEnabled; + /// Push notifications pop up over other apps (Android's high importance + /// channel) instead of only appearing in the shade. + final bool notifyBanners; + + /// A banner inside Pawress when the background check finds new posts + /// while the app is open. + final bool inAppAlerts; + + /// The bell setting a new follow starts with. + final NotifyLevel defaultNotify; + + final ForYouMix forYouMix; + /// How often Android runs the background check; 15 is its minimum. final int checkIntervalMinutes; final bool autoplayVideos; @@ -43,7 +75,11 @@ class AppSettings { AppSettings copyWith({ ThemeChoice? theme, bool? notificationsEnabled, + bool? notifyBanners, + bool? inAppAlerts, + NotifyLevel? defaultNotify, int? checkIntervalMinutes, + ForYouMix? forYouMix, bool? autoplayVideos, ({String uri, String name})? downloadFolder, bool clearDownloadFolder = false, @@ -52,7 +88,11 @@ class AppSettings { }) => AppSettings( theme: theme ?? this.theme, notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, + notifyBanners: notifyBanners ?? this.notifyBanners, + inAppAlerts: inAppAlerts ?? this.inAppAlerts, + defaultNotify: defaultNotify ?? this.defaultNotify, checkIntervalMinutes: checkIntervalMinutes ?? this.checkIntervalMinutes, + forYouMix: forYouMix ?? this.forYouMix, autoplayVideos: autoplayVideos ?? this.autoplayVideos, downloadFolderUri: clearDownloadFolder ? null @@ -67,7 +107,14 @@ class AppSettings { factory AppSettings.fromJson(Map json) => AppSettings( theme: ThemeChoice.values.asNameMap()[json['theme']] ?? ThemeChoice.plum, notificationsEnabled: json['notificationsEnabled'] as bool? ?? true, + notifyBanners: json['notifyBanners'] as bool? ?? true, + inAppAlerts: json['inAppAlerts'] as bool? ?? true, + defaultNotify: + NotifyLevel.values.asNameMap()[json['defaultNotify']] ?? + NotifyLevel.push, checkIntervalMinutes: json['checkIntervalMinutes'] as int? ?? 60, + forYouMix: + ForYouMix.values.asNameMap()[json['forYouMix']] ?? ForYouMix.balanced, autoplayVideos: json['autoplayVideos'] as bool? ?? true, downloadFolderUri: json['downloadFolderUri'] as String?, downloadFolderName: json['downloadFolderName'] as String?, @@ -78,7 +125,11 @@ class AppSettings { Map toJson() => { 'theme': theme.name, 'notificationsEnabled': notificationsEnabled, + 'notifyBanners': notifyBanners, + 'inAppAlerts': inAppAlerts, + 'defaultNotify': defaultNotify.name, 'checkIntervalMinutes': checkIntervalMinutes, + 'forYouMix': forYouMix.name, 'autoplayVideos': autoplayVideos, 'downloadFolderUri': downloadFolderUri, 'downloadFolderName': downloadFolderName, @@ -102,6 +153,7 @@ class Store { static const _settings = 'settings'; static const _follows = 'follows'; static const _favorites = 'favorites'; + static const _likes = 'likes'; static const _notices = 'notices'; static const _accountName = 'accountName'; static const _session = 'session'; @@ -109,6 +161,9 @@ class Store { /// The newest notices kept; older ones are dropped. static const maxNotices = 200; + /// The newest likes kept; older taste fades out of For You anyway. + static const maxLikes = 1000; + Future onboarded() async => await _prefs.getBool(_onboarded) ?? false; Future setOnboarded(bool value) => _prefs.setBool(_onboarded, value); @@ -137,6 +192,13 @@ class Store { jsonEncode([for (final favorite in favorites) favorite.toJson()]), ); + Future> likes() async => _list(_likes, Like.fromJson); + + Future saveLikes(List likes) => _prefs.setString( + _likes, + jsonEncode([for (final like in likes.take(maxLikes)) like.toJson()]), + ); + Future> notices() async => _list(_notices, UpdateNotice.fromJson); diff --git a/lib/core/updates.dart b/lib/core/updates.dart index f8d3e42..bad47ef 100644 --- a/lib/core/updates.dart +++ b/lib/core/updates.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:workmanager/workmanager.dart'; @@ -24,14 +25,15 @@ class UpdateChecker { /// Pause between creators to stay well under Pawchive's rate limits. static const _spacing = Duration(milliseconds: 350); - /// Checks every follow and returns the new notices, newest first. A - /// creator's first check only records what exists, so following someone - /// never notifies about their back catalogue. + /// Checks every follow whose bell isn't off and returns the new notices, + /// newest first. A creator's first check only records what exists, so + /// following someone never notifies about their back catalogue. Future> check() async { final follows = await store.follows(); final updated = {}; final found = []; for (final follow in follows) { + if (follow.notify == NotifyLevel.off) continue; try { final result = await _checkOne(follow); updated[follow.key] = result.follow; @@ -119,6 +121,19 @@ class UpdateChecker { } } +/// The notices whose creator's bell is set to push, for Android +/// notifications; the rest stay in the app. +List pushable(List notices, List follows) { + final push = { + for (final follow in follows) + if (follow.notify == NotifyLevel.push) follow.key, + }; + return [ + for (final notice in notices) + if (push.contains(notice.creator.key)) notice, + ]; +} + /// System notifications for new posts. class Notifier { Notifier._(); @@ -137,6 +152,21 @@ class Notifier { priority: Priority.defaultPriority, ); + /// The same notifications as pop-up banners. Android fixes a channel's + /// importance when it is created, so banners need a channel of their own. + static const _bannerChannel = AndroidNotificationDetails( + 'artist_updates_banner', + 'Artist updates (pop-up)', + channelDescription: 'New posts from artists you follow, as banners', + importance: Importance.high, + priority: Priority.high, + ); + + static AndroidFlutterLocalNotificationsPlugin? get _android => _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + static Future init() async { if (_ready) return; _ready = true; @@ -166,15 +196,35 @@ class Notifier { } static Future requestPermission() async => - await _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() - ?.requestNotificationsPermission() ?? - false; - - /// One notification per creator, replacing that creator's previous one. - static Future show(List notices) async { + await _android?.requestNotificationsPermission() ?? false; + + /// Whether Android lets Pawress post notifications at all. + static Future permitted() async { + await init(); + return await _android?.areNotificationsEnabled() ?? false; + } + + /// Opens Pawress in Android's notification settings, where sound, + /// vibration and the lock screen are chosen. Returns false if it could + /// not. + static Future openSystemSettings() async { + try { + return await const MethodChannel('pawress/updater') + .invokeMethod('notificationSettings') ?? + false; + } on PlatformException { + return false; + } on MissingPluginException { + return false; + } + } + + /// One notification per creator, replacing that creator's previous one; + /// with [banners], they pop up over the screen. + static Future show( + List notices, { + bool banners = false, + }) async { await init(); final byCreator = >{}; for (final notice in notices) { @@ -192,7 +242,9 @@ class Notifier { id: creator.key.hashCode & 0x7fffffff, title: title, body: body, - notificationDetails: const NotificationDetails(android: _channel), + notificationDetails: NotificationDetails( + android: banners ? _bannerChannel : _channel, + ), payload: creator.key, ); } @@ -240,8 +292,13 @@ void backgroundDispatcher() { if (!settings.notificationsEnabled) return true; final api = PawchiveApi(session: await store.session()); try { - final notices = await UpdateChecker(api, store).check(); - if (notices.isNotEmpty) await Notifier.show(notices); + final notices = pushable( + await UpdateChecker(api, store).check(), + await store.follows(), + ); + if (notices.isNotEmpty) { + await Notifier.show(notices, banners: settings.notifyBanners); + } return true; } catch (_) { return false; diff --git a/lib/ui/backup.dart b/lib/ui/backup.dart index 357c017..951e6f6 100644 --- a/lib/ui/backup.dart +++ b/lib/ui/backup.dart @@ -133,9 +133,10 @@ class _ExportDialogState extends State<_ExportDialog> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${_count(state.follows.length, 'follow')} and ' - '${_count(state.favorites.length, 'liked post')} go into a JSON ' - 'file in a folder you choose.', + '${_count(state.follows.length, 'follow')}, ' + '${_count(state.favorites.length, 'favorite')} and ' + '${_count(state.likes.length, 'like')} go into a JSON file in ' + 'a folder you choose.', style: TextStyle(color: ink(.75)), ), if (state.signedIn) ...[ @@ -234,7 +235,7 @@ class _ImportDialogState extends State<_ImportDialog> { final added = await state.importBackup(widget.backup, account: account); navigator.pop( 'Added ${_count(added.follows, 'follow')} and ' - '${_count(added.favorites, 'liked post')}' + '${_count(added.favorites, 'favorite')}' '${account == null ? '' : ', and signed in'}.', ); } on BackupException catch (e) { @@ -260,8 +261,9 @@ class _ImportDialogState extends State<_ImportDialog> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${_count(backup.follows.length, 'follow')} and ' - '${_count(backup.favorites.length, 'liked post')}' + '${_count(backup.follows.length, 'follow')}, ' + '${_count(backup.favorites.length, 'favorite')} and ' + '${_count(backup.likes.length, 'like')}' '${backup.exportedAt == null ? '' : ' from ${longDate(backup.exportedAt)}'}. ' 'Ones you already have are kept as they are.', style: TextStyle(color: ink(.75)), diff --git a/lib/ui/creator_page.dart b/lib/ui/creator_page.dart index 5d2cc77..c4ed9d2 100644 --- a/lib/ui/creator_page.dart +++ b/lib/ui/creator_page.dart @@ -5,6 +5,7 @@ import '../core/feeds.dart'; import '../core/models.dart'; import 'app_scope.dart'; import 'community_page.dart'; +import 'notify_bell.dart'; import 'post_card.dart'; import 'theme.dart'; import 'widgets.dart'; @@ -375,6 +376,10 @@ class _ProfileHeader extends StatelessWidget { icon: const Icon(Icons.group_add_outlined, size: 20), ), const SizedBox(width: 8), + if (AppScope.of(context).isFollowing(c.key)) ...[ + NotifyBell(creator: c), + const SizedBox(width: 8), + ], FollowButton(creator: c), ], ), diff --git a/lib/ui/favorites_page.dart b/lib/ui/favorites_page.dart index e896db3..0cddc33 100644 --- a/lib/ui/favorites_page.dart +++ b/lib/ui/favorites_page.dart @@ -5,8 +5,8 @@ import 'post_card.dart'; import 'theme.dart'; import 'widgets.dart'; -/// Every liked post, newest like first. Likes are kept on the device and, -/// when signed in, synced with the account's favorite posts. +/// Every favorited (hearted) post, newest first. Favorites are kept on the +/// device and, when signed in, synced with the account's favorite posts. class FavoritesPage extends StatelessWidget { const FavoritesPage({super.key}); diff --git a/lib/ui/following_page.dart b/lib/ui/following_page.dart index be106d4..4c72368 100644 --- a/lib/ui/following_page.dart +++ b/lib/ui/following_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../core/models.dart'; import 'app_scope.dart'; import 'creator_page.dart'; +import 'notify_bell.dart'; import 'post_card.dart'; import 'search_page.dart'; import 'theme.dart'; @@ -178,6 +179,8 @@ class _FollowTile extends StatelessWidget { label: Text('${follow.unread} new'), ), ), + NotifyBell(creator: c), + const SizedBox(width: 6), FollowButton(creator: c, compact: true), ], ), diff --git a/lib/ui/home.dart b/lib/ui/home.dart index 95b93ed..a068522 100644 --- a/lib/ui/home.dart +++ b/lib/ui/home.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../core/feeds.dart'; +import '../core/for_you.dart'; +import '../core/models.dart'; import '../core/updates.dart'; import 'app_scope.dart'; import 'creator_page.dart'; @@ -44,11 +46,15 @@ class _HomeScreenState extends State StreamSubscription? _taps; String _followSignature = ''; + /// Looks for notices the background check wrote while the app is open. + Timer? _poll; + static const _pollInterval = Duration(minutes: 1); + @override void initState() { super.initState(); final state = AppScope.read(context); - _forYou = ForYouFeed(state.api, () => state.follows); + _forYou = ForYouFeed(state.api, state.forYouSignals); _following = FollowingFeed(state.api, () => state.follows); _followSignature = _signature(); state.addListener(_onState); @@ -58,6 +64,15 @@ class _HomeScreenState extends State if (key != null) _openFromNotification(key); }); unawaited(_checkForUpdate()); + _startPolling(); + } + + void _startPolling() { + _poll?.cancel(); + _poll = Timer.periodic( + _pollInterval, + (_) => AppScope.read(context).pollNotices(), + ); } /// Looks for a newer release once per launch, unless turned off in @@ -78,6 +93,7 @@ class _HomeScreenState extends State AppScope.read(context).removeListener(_onState); WidgetsBinding.instance.removeObserver(this); _taps?.cancel(); + _poll?.cancel(); _tabs.dispose(); _forYou.dispose(); _following.dispose(); @@ -87,7 +103,12 @@ class _HomeScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { // The background check may have found posts while the app was away. - if (state == AppLifecycleState.resumed) AppScope.read(context).reload(); + if (state == AppLifecycleState.resumed) { + AppScope.read(context).reload(alert: true); + _startPolling(); + } else if (state == AppLifecycleState.paused) { + _poll?.cancel(); + } } void _onTab() { @@ -116,6 +137,8 @@ class _HomeScreenState extends State ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(message))); } + final alert = state.takeAlert(); + if (alert != null && mounted) _showAlert(alert); final signature = _signature(); if (signature != _followSignature) { _followSignature = signature; @@ -123,6 +146,56 @@ class _HomeScreenState extends State } } + /// An in-app banner for new posts, X style: the artist, what they posted + /// and a way to see it. + void _showAlert(List notices) { + final first = notices.first; + final creators = {for (final notice in notices) notice.creator.key}; + final title = creators.length > 1 + ? '${notices.length} new posts from artists you follow' + : notices.length == 1 + ? 'New post from ${first.creator.name}' + : '${notices.length} new posts from ${first.creator.name}'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 6), + content: Row( + children: [ + CreatorAvatar(first.creator, size: 32), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + if (notices.length == 1 && first.postTitle.isNotEmpty) + Text( + first.postTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + action: SnackBarAction( + label: 'View', + onPressed: () => creators.length == 1 + ? _openFromNotification(first.creator.key) + : _push((_) => const NotificationsPage()), + ), + ), + ); + } + void _openFromNotification(String key) { if (!mounted) return; final follow = AppScope.read(context).followOf(key); diff --git a/lib/ui/notification_settings.dart b/lib/ui/notification_settings.dart new file mode 100644 index 0000000..db626f9 --- /dev/null +++ b/lib/ui/notification_settings.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; + +import '../core/models.dart'; +import '../core/store.dart'; +import '../core/updates.dart'; +import 'app_scope.dart'; +import 'notify_bell.dart'; +import 'post_card.dart'; +import 'settings_cards.dart'; +import 'theme.dart'; +import 'widgets.dart'; + +String _interval(int minutes) => minutes < 60 + ? '$minutes min' + : minutes == 60 + ? '1 hour' + : '${minutes ~/ 60} hours'; + +/// Notification preferences, laid out like X's: push (Android) and in-app +/// delivery, the bell new follows start with, and each artist's bell. +class NotificationSettingsPage extends StatefulWidget { + const NotificationSettingsPage({super.key}); + + @override + State createState() => + _NotificationSettingsPageState(); +} + +class _NotificationSettingsPageState extends State + with WidgetsBindingObserver { + late Future _permitted = Notifier.permitted(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// The user may have changed the permission in Android's settings. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + setState(() => _permitted = Notifier.permitted()); + } + } + + Future _allow() async { + final granted = await Notifier.requestPermission(); + // Android stops asking after two refusals; only its settings can help. + if (!granted) await Notifier.openSystemSettings(); + if (mounted) setState(() => _permitted = Notifier.permitted()); + } + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final settings = state.settings; + final on = settings.notificationsEnabled; + void update(AppSettings value) => state.updateSettings(value); + final counts = {for (final level in NotifyLevel.values) level: 0}; + for (final follow in state.follows) { + counts[follow.notify] = counts[follow.notify]! + 1; + } + return Scaffold( + appBar: AppBar( + title: const Text( + 'Notifications', + style: TextStyle(fontWeight: FontWeight.w800), + ), + ), + body: SettingsList( + children: [ + const SettingsHeadline( + kicker: 'Preferences', + title: 'Choose what reaches you, and how.', + ), + FutureBuilder( + future: _permitted, + builder: (context, snapshot) => snapshot.data == false && on + ? SettingsWarningCard( + title: 'Blocked by Android', + body: + 'Pawress can\'t post notifications. In-app alerts ' + 'still work.', + action: FilledButton( + onPressed: _allow, + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + child: const Text('Allow'), + ), + ) + : const SizedBox.shrink(), + ), + const SettingsSection('New posts'), + SettingsSwitchCard( + icon: Icons.notifications_active_outlined, + title: 'New post notifications', + subtitle: + 'Check the artists you follow for new posts, even while ' + 'Pawress is closed.', + value: on, + onChanged: (value) async { + if (value) await Notifier.requestPermission(); + update(settings.copyWith(notificationsEnabled: value)); + if (mounted) setState(() => _permitted = Notifier.permitted()); + }, + ), + SettingsGroup( + children: [ + ListTile( + enabled: on, + leading: const Icon(Icons.schedule_rounded), + title: const Text('Check for new posts'), + subtitle: const Text('Android may run it a little later.'), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + trailing: DropdownButton( + value: settings.checkIntervalMinutes, + underline: const SizedBox.shrink(), + onChanged: on + ? (minutes) => update( + settings.copyWith(checkIntervalMinutes: minutes), + ) + : null, + items: [ + for (final minutes in AppSettings.intervals) + DropdownMenuItem( + value: minutes, + child: Text(_interval(minutes)), + ), + ], + ), + ), + ], + ), + const SettingsSection('Push notifications'), + SettingsGroup( + children: [ + SettingsSwitch( + icon: Icons.web_asset_rounded, + title: 'Pop-up banners', + subtitle: + 'Push notifications slide down over whatever is on ' + 'screen instead of waiting in the shade.', + value: settings.notifyBanners, + onChanged: on + ? (value) => update(settings.copyWith(notifyBanners: value)) + : null, + ), + SettingsTile( + icon: Icons.tune_rounded, + title: 'Sound, vibration and lock screen', + subtitle: 'Opens Android\'s notification settings for Pawress', + trailing: const Icon(Icons.open_in_new_rounded, size: 18), + onTap: () async { + final messenger = ScaffoldMessenger.of(context); + if (!await Notifier.openSystemSettings()) { + messenger.showSnackBar( + const SnackBar( + content: Text('Could not open Android\'s settings.'), + ), + ); + } + }, + ), + ], + ), + const SettingsSection('In app'), + SettingsSwitchCard( + icon: Icons.campaign_outlined, + title: 'In-app alerts', + subtitle: + 'A banner at the bottom of Pawress when new posts arrive ' + 'while you\'re using it. Notifications always lists them.', + value: settings.inAppAlerts, + onChanged: on + ? (value) => update(settings.copyWith(inAppAlerts: value)) + : null, + ), + const SettingsSection('Artists'), + SettingsChoiceCard( + icon: Icons.person_add_alt_outlined, + title: 'New follows start with', + subtitle: switch (settings.defaultNotify) { + NotifyLevel.push => + 'Every new post from an artist you follow is a push ' + 'notification.', + NotifyLevel.inApp => + 'New posts only show in Notifications and as in-app alerts.', + NotifyLevel.off => + 'Turn on the bell on an artist\'s profile to hear from them.', + }, + values: const [ + NotifyLevel.push, + NotifyLevel.inApp, + NotifyLevel.off, + ], + label: (level) => level.label, + icons: notifyIcon, + selected: settings.defaultNotify, + onChanged: (level) => + update(settings.copyWith(defaultNotify: level)), + ), + SettingsTileCard( + tile: SettingsTile( + icon: Icons.notifications_none_rounded, + title: 'Notifications per artist', + subtitle: state.follows.isEmpty + ? 'Follow artists to choose here' + : '${counts[NotifyLevel.push]} push · ' + '${counts[NotifyLevel.inApp]} in app · ' + '${counts[NotifyLevel.off]} off', + onTap: state.follows.isEmpty + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const _ArtistBellsPage(), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// Every followed artist with their bell, plus "set all". +class _ArtistBellsPage extends StatelessWidget { + const _ArtistBellsPage(); + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final follows = [...state.follows] + ..sort( + (a, b) => a.creator.name.toLowerCase().compareTo( + b.creator.name.toLowerCase(), + ), + ); + return Scaffold( + appBar: AppBar( + title: const Text( + 'Notifications per artist', + style: TextStyle(fontWeight: FontWeight.w800), + ), + actions: [ + PopupMenuButton( + tooltip: 'Set all', + icon: const Icon(Icons.done_all_rounded), + onSelected: state.setNotifyAll, + itemBuilder: (_) => [ + for (final level in const [ + NotifyLevel.push, + NotifyLevel.inApp, + NotifyLevel.off, + ]) + PopupMenuItem( + value: level, + child: Row( + children: [ + Icon(notifyIcon(level), size: 20), + const SizedBox(width: 12), + Text('All ${level.label.toLowerCase()}'), + ], + ), + ), + ], + ), + ], + ), + body: ListView.builder( + padding: const EdgeInsets.only(bottom: 40), + itemCount: follows.length, + itemBuilder: (context, index) { + final follow = follows[index]; + final c = follow.creator; + return ListTile( + leading: CreatorAvatar(c, size: 42), + title: Text( + c.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + subtitle: Text( + '${c.site.label} · ${follow.notify.label}', + style: TextStyle(color: ink(.5)), + ), + trailing: NotifyBell(creator: c), + onTap: () => openCreator(context, c), + ); + }, + ), + ); + } +} diff --git a/lib/ui/notifications_page.dart b/lib/ui/notifications_page.dart index f36b15e..0799e33 100644 --- a/lib/ui/notifications_page.dart +++ b/lib/ui/notifications_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../core/models.dart'; import 'app_scope.dart'; +import 'notification_settings.dart'; import 'post_card.dart'; import 'post_page.dart'; import 'theme.dart'; @@ -49,6 +50,15 @@ class NotificationsPage extends StatelessWidget { child: CircularProgressIndicator(strokeWidth: 2), ), ), + IconButton( + tooltip: 'Notification settings', + icon: const Icon(Icons.settings_outlined), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const NotificationSettingsPage(), + ), + ), + ), PopupMenuButton( onSelected: (value) => switch (value) { 'read' => state.markAllNoticesRead(), diff --git a/lib/ui/notify_bell.dart b/lib/ui/notify_bell.dart new file mode 100644 index 0000000..40038c3 --- /dev/null +++ b/lib/ui/notify_bell.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../core/models.dart'; +import '../core/updates.dart'; +import 'app_scope.dart'; +import 'theme.dart'; + +IconData notifyIcon(NotifyLevel level) => switch (level) { + NotifyLevel.off => Icons.notifications_off_outlined, + NotifyLevel.inApp => Icons.notifications_none_rounded, + NotifyLevel.push => Icons.notifications_active_rounded, +}; + +/// The bell beside Following, as on an X profile: what this artist's new +/// posts do. Nothing shows until the artist is followed. +class NotifyBell extends StatelessWidget { + const NotifyBell({super.key, required this.creator}); + + final Creator creator; + + @override + Widget build(BuildContext context) { + final follow = AppScope.of(context).followOf(creator.key); + if (follow == null) return const SizedBox.shrink(); + final level = follow.notify; + return IconButton.outlined( + tooltip: 'Notifications: ${level.label}', + visualDensity: VisualDensity.compact, + onPressed: () => showNotifySheet(context, creator), + icon: Icon( + notifyIcon(level), + size: 20, + color: level == NotifyLevel.push ? pawPink : null, + ), + ); + } +} + +Future showNotifySheet(BuildContext context, Creator creator) => + showModalBottomSheet( + context: context, + showDragHandle: true, + backgroundColor: panel, + builder: (_) => _NotifySheet(creator), + ); + +class _NotifySheet extends StatelessWidget { + const _NotifySheet(this.creator); + + final Creator creator; + + Future _choose(BuildContext context, NotifyLevel? level) async { + if (level == null) return; + final state = AppScope.read(context); + if (level == NotifyLevel.push) await Notifier.requestPermission(); + await state.setNotify(creator.key, level); + } + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final follow = state.followOf(creator.key); + if (follow == null) return const SizedBox.shrink(); + final settings = state.settings; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Notifications from ${creator.name}', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 2), + Text( + 'Choose what happens when they post.', + style: TextStyle(color: ink(.55)), + ), + const SizedBox(height: 8), + RadioGroup( + groupValue: follow.notify, + onChanged: (level) => _choose(context, level), + child: Column( + children: [ + for (final level in const [ + NotifyLevel.push, + NotifyLevel.inApp, + NotifyLevel.off, + ]) + RadioListTile( + value: level, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.trailing, + secondary: Icon(notifyIcon(level)), + title: Text(switch (level) { + NotifyLevel.push => 'All posts', + NotifyLevel.inApp => 'In app only', + NotifyLevel.off => 'Off', + }), + subtitle: Text( + level == NotifyLevel.push && !settings.notifyBanners + ? '${level.description}, without pop-ups' + : level.description, + ), + ), + ], + ), + ), + if (!settings.notificationsEnabled && + follow.notify != NotifyLevel.off) + Row( + children: [ + Icon(Icons.info_outline_rounded, size: 18, color: ink(.55)), + const SizedBox(width: 10), + Expanded( + child: Text( + 'New post checks are off, so nothing arrives yet.', + style: TextStyle(color: ink(.55), fontSize: 13), + ), + ), + TextButton( + onPressed: () async { + await Notifier.requestPermission(); + await state.updateSettings( + state.settings.copyWith(notificationsEnabled: true), + ); + }, + child: const Text('Turn on'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/post_card.dart b/lib/ui/post_card.dart index 21928b9..2852de2 100644 --- a/lib/ui/post_card.dart +++ b/lib/ui/post_card.dart @@ -87,8 +87,9 @@ class PostCard extends StatelessWidget { } } -/// Comments, like (with the count when the popular page gave one) and the -/// attachment count, under a post. +/// Comments, thumbs up (a private like that tunes For You), favorite (with +/// the count when the popular page gave one) and the attachment count, +/// under a post. class PostActions extends StatelessWidget { const PostActions({ super.key, @@ -106,7 +107,8 @@ class PostActions extends StatelessWidget { @override Widget build(BuildContext context) { final state = AppScope.of(context); - final liked = state.isFavorite(post); + final liked = state.isLiked(post); + final favorite = state.isFavorite(post); return Row( children: [ _ActionButton( @@ -117,9 +119,32 @@ class PostActions extends StatelessWidget { ), const SizedBox(width: 12), _ActionButton( - tooltip: liked ? 'Unlike' : 'Like', - icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - color: liked ? pawPink : ink(.6), + tooltip: liked ? 'Remove like' : 'Like', + icon: liked + ? Icons.thumb_up_alt_rounded + : Icons.thumb_up_alt_outlined, + color: liked ? pawPinkLight : ink(.6), + onTap: () { + final first = state.likes.isEmpty && !liked; + state.toggleLike(post); + if (first) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Liked. For You will show more posts like this.', + ), + ), + ); + } + }, + ), + const SizedBox(width: 12), + _ActionButton( + tooltip: favorite ? 'Remove from favorites' : 'Favorite', + icon: favorite + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: favorite ? pawPink : ink(.6), label: post.favorites == null ? null : compactCount(post.favorites!), onTap: () => state.toggleFavorite(post), ), diff --git a/lib/ui/settings_cards.dart b/lib/ui/settings_cards.dart new file mode 100644 index 0000000..7136949 --- /dev/null +++ b/lib/ui/settings_cards.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// Rounded panels for settings screens, as in BooruPlus. Every change +/// applies as soon as it is made, so there is nothing to save. +const _radius = BorderRadius.all(Radius.circular(16)); +const _tilePadding = EdgeInsets.symmetric(horizontal: 16); + +/// The kicker and headline at the top of a settings screen. +class SettingsHeadline extends StatelessWidget { + const SettingsHeadline({ + super.key, + required this.kicker, + required this.title, + }); + + final String kicker; + final String title; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSection(kicker), + Text( + title, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.2, + letterSpacing: -.7, + ), + ), + ], + ); +} + +/// A section heading, flush with the cards under it. +class SettingsSection extends StatelessWidget { + const SettingsSection(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 24, bottom: 4), + child: Text( + text.toUpperCase(), + style: const TextStyle( + color: pawPink, + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 2, + ), + ), + ); +} + +/// One panel holding [children] (tiles), separated by hairlines. +class SettingsGroup extends StatelessWidget { + const SettingsGroup({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 8), + child: Material( + color: panel, + borderRadius: _radius, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (final (index, child) in children.indexed) ...[ + if (index > 0) Divider(height: 1, indent: 56, color: hairline), + child, + ], + ], + ), + ), + ); +} + +/// A switch in its own panel. +class SettingsSwitchCard extends StatelessWidget { + const SettingsSwitchCard({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.value, + required this.onChanged, + }); + + final IconData icon; + final String title; + final String subtitle; + final bool value; + + /// Null disables the switch. + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) => SettingsGroup( + children: [ + SettingsSwitch( + icon: icon, + title: title, + subtitle: subtitle, + value: value, + onChanged: onChanged, + ), + ], + ); +} + +/// A switch tile for use inside a [SettingsGroup]. +class SettingsSwitch extends StatelessWidget { + const SettingsSwitch({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.value, + required this.onChanged, + }); + + final IconData icon; + final String title; + final String subtitle; + final bool value; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) => SwitchListTile.adaptive( + value: value, + onChanged: onChanged, + secondary: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + contentPadding: _tilePadding, + ); +} + +/// A tappable row for use inside a [SettingsGroup], or alone in a +/// [SettingsTileCard]. +class SettingsTile extends StatelessWidget { + const SettingsTile({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + this.iconColor, + }); + + final IconData icon; + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + final Color? iconColor; + + @override + Widget build(BuildContext context) => ListTile( + leading: Icon(icon, color: iconColor), + title: Text(title), + subtitle: subtitle == null ? null : Text(subtitle!), + trailing: + trailing ?? + (onTap == null ? null : const Icon(Icons.chevron_right_rounded)), + onTap: onTap, + contentPadding: _tilePadding, + ); +} + +class SettingsTileCard extends StatelessWidget { + const SettingsTileCard({super.key, required this.tile}); + + final SettingsTile tile; + + @override + Widget build(BuildContext context) => SettingsGroup(children: [tile]); +} + +/// A setting with a few choices, as a button group with an explanation of +/// the current one underneath. +class SettingsChoiceCard extends StatelessWidget { + const SettingsChoiceCard({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.values, + required this.label, + required this.selected, + required this.onChanged, + this.icons, + }); + + final IconData icon; + final String title; + final String subtitle; + final List values; + final String Function(T) label; + final T selected; + + /// Null disables the buttons. + final ValueChanged? onChanged; + + /// An icon per choice, if any. + final IconData Function(T)? icons; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: panel, borderRadius: _radius), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: ink(.8)), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + for (final value in values) + ButtonSegment( + value: value, + label: Text(label(value)), + icon: icons == null ? null : Icon(icons!(value), size: 16), + ), + ], + selected: {selected}, + onSelectionChanged: onChanged == null + ? null + : (choice) => onChanged!(choice.single), + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: TextStyle(color: ink(.54), fontSize: 12, height: 1.5), + ), + ], + ), + ), + ); +} + +/// Explanatory text and buttons in a panel, like BooruPlus's backup card. +class SettingsActionsCard extends StatelessWidget { + const SettingsActionsCard({ + super.key, + required this.text, + required this.actions, + }); + + final String text; + final List actions; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: panel, borderRadius: _radius), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + style: TextStyle(color: ink(.54), fontSize: 12, height: 1.6), + ), + const SizedBox(height: 12), + Wrap(spacing: 8, runSpacing: 8, children: actions), + ], + ), + ), + ); +} + +/// A highlighted notice card, e.g. when Android blocks notifications. +class SettingsWarningCard extends StatelessWidget { + const SettingsWarningCard({ + super.key, + required this.title, + required this.body, + this.action, + }); + + final String title; + final String body; + final Widget? action; + + static const _amber = Color(0xFFFFB74D); + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: _amber.withValues(alpha: .1), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _amber.withValues(alpha: .45), width: 1.2), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _amber.withValues(alpha: .18), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.notifications_off_outlined, + color: _amber, + size: 18, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 13, + color: Color(0xFFFFE0B2), + ), + ), + const SizedBox(height: 2), + Text( + body, + style: const TextStyle( + fontSize: 11.5, + color: Color(0xFFD4B89B), + height: 1.3, + ), + ), + ], + ), + ), + if (action != null) ...[const SizedBox(width: 8), action!], + ], + ), + ); +} + +/// The scrolling body every settings screen shares: centered, at most +/// 720 wide, with BooruPlus's margins. +class SettingsList extends StatelessWidget { + const SettingsList({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) => SafeArea( + top: false, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 40), + children: children, + ), + ), + ), + ); +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index cd7a961..0fdda29 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -3,28 +3,35 @@ import 'package:webview_flutter/webview_flutter.dart'; import '../core/config.dart'; import '../core/store.dart'; -import '../core/updates.dart'; import 'app_scope.dart'; import 'backup.dart'; import 'downloads.dart'; -import 'update_dialog.dart'; import 'login_page.dart'; +import 'notification_settings.dart'; +import 'settings_cards.dart'; import 'theme.dart'; +import 'update_dialog.dart'; import 'widgets.dart'; +/// Settings in BooruPlus's layout: rounded panels under section labels. +/// Every change applies at once. class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); - static String _interval(int minutes) => minutes < 60 - ? 'Every $minutes minutes' - : minutes == 60 - ? 'Every hour' - : 'Every ${minutes ~/ 60} hours'; + static String _notificationSummary(AppSettings settings) { + if (!settings.notificationsEnabled) return 'Off'; + return [ + settings.notifyBanners ? 'Push with banners' : 'Push without banners', + if (settings.inAppAlerts) 'in-app alerts', + 'new follows: ${settings.defaultNotify.label.toLowerCase()}', + ].join(' · '); + } @override Widget build(BuildContext context) { final state = AppScope.of(context); final settings = state.settings; + void update(AppSettings value) => state.updateSettings(value); return Scaffold( appBar: AppBar( title: const Text( @@ -32,222 +39,281 @@ class SettingsPage extends StatelessWidget { style: TextStyle(fontWeight: FontWeight.w800), ), ), - body: ListView( - padding: const EdgeInsets.only(bottom: 48), + body: SettingsList( children: [ - const SectionLabel('Account'), - if (state.signedIn) ...[ - ListTile( - leading: const Icon(Icons.verified_user_outlined), - title: Text(state.accountName ?? 'Pawchive account'), - subtitle: const Text( - 'Follows and likes sync with your Pawchive favorites', - ), - ), - ListTile( - leading: const Icon(Icons.sync_rounded), - title: const Text('Sync follows and likes now'), - onTap: () async { - final messenger = ScaffoldMessenger.of(context); - await state.syncFavorites(); - messenger.showSnackBar( - SnackBar( - content: Text( - 'Following ${state.follows.length} artists, ' - '${state.favorites.length} liked posts.', + const SettingsHeadline( + kicker: 'Pawress preferences', + title: 'Check your settings and preferences!', + ), + const SettingsSection('Account'), + SettingsGroup( + children: state.signedIn + ? [ + SettingsTile( + icon: Icons.verified_user_outlined, + iconColor: pawPink, + title: state.accountName ?? 'Pawchive account', + subtitle: + 'Follows and favorites sync with your Pawchive ' + 'favorites', ), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.logout_rounded), - title: const Text('Sign out'), - subtitle: const Text( - 'Your follows and likes stay on this device', - ), - onTap: () async { - // Forget the website login too, or signing in again would - // skip straight back into the old session. - await WebViewCookieManager().clearCookies(); - await state.signOut(); - }, - ), - ] else - ListTile( - leading: const Icon(Icons.login_rounded), - title: const Text('Sign in to Pawchive'), - subtitle: const Text( - 'Optional. Syncs follows and likes with your favorites.', - ), + SettingsTile( + icon: Icons.sync_rounded, + title: 'Sync now', + trailing: const SizedBox.shrink(), + onTap: () async { + final messenger = ScaffoldMessenger.of(context); + await state.syncFavorites(); + messenger.showSnackBar( + SnackBar( + content: Text( + 'Following ${state.follows.length} artists, ' + '${state.favorites.length} favorites.', + ), + ), + ); + }, + ), + SettingsTile( + icon: Icons.logout_rounded, + title: 'Sign out', + subtitle: + 'Your follows and favorites stay on this device', + trailing: const SizedBox.shrink(), + onTap: () async { + // Forget the website login too, or signing in again + // would skip straight back into the old session. + await WebViewCookieManager().clearCookies(); + await state.signOut(); + }, + ), + ] + : [ + SettingsTile( + icon: Icons.login_rounded, + title: 'Sign in to Pawchive', + subtitle: + 'Optional. Syncs follows and favorites with your ' + 'account.', + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const LoginPage(), + ), + ), + ), + ], + ), + const SettingsSection('Notifications'), + SettingsTileCard( + tile: SettingsTile( + icon: settings.notificationsEnabled + ? Icons.notifications_active_outlined + : Icons.notifications_off_outlined, + title: 'Notifications', + subtitle: _notificationSummary(settings), onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const LoginPage()), + MaterialPageRoute( + builder: (_) => const NotificationSettingsPage(), + ), ), ), - const SectionLabel('Notifications'), - SwitchListTile( - secondary: const Icon(Icons.notifications_active_outlined), - title: const Text('New post notifications'), - subtitle: const Text( - 'Checks your followed artists in the background', - ), - value: settings.notificationsEnabled, - onChanged: (value) async { - if (value) await Notifier.requestPermission(); - await state.updateSettings( - settings.copyWith(notificationsEnabled: value), - ); - }, ), - ListTile( - enabled: settings.notificationsEnabled, - leading: const Icon(Icons.schedule_rounded), - title: const Text('Check for new posts'), - subtitle: Text(_interval(settings.checkIntervalMinutes)), - onTap: () async { - final minutes = await showDialog( - context: context, - builder: (context) => RadioGroup( - groupValue: settings.checkIntervalMinutes, - onChanged: (value) => Navigator.pop(context, value), - child: SimpleDialog( - title: const Text('Check for new posts'), - children: [ - for (final minutes in AppSettings.intervals) - RadioListTile( - value: minutes, - title: Text(_interval(minutes)), - ), - ], - ), - ), - ); - if (minutes != null) { - await state.updateSettings( - settings.copyWith(checkIntervalMinutes: minutes), - ); - } + const SettingsSection('For You'), + SettingsChoiceCard( + icon: Icons.auto_awesome_outlined, + title: 'Timeline mix', + subtitle: switch (settings.forYouMix) { + ForYouMix.following => + 'Almost everything comes from artists you follow or like, ' + 'with a little discovery.', + ForYouMix.balanced => + 'Mostly your artists, with recent posts that match your ' + 'likes mixed in.', + ForYouMix.discover => + 'More new artists picked by your likes, with your artists ' + 'still in the mix.', }, + values: ForYouMix.values, + label: (mix) => mix.label, + selected: settings.forYouMix, + onChanged: (mix) => update(settings.copyWith(forYouMix: mix)), ), - const SectionLabel('Appearance'), - RadioGroup( - groupValue: settings.theme, - onChanged: (value) => - state.updateSettings(settings.copyWith(theme: value)), - child: const Column( - children: [ - RadioListTile( - value: ThemeChoice.plum, - title: Text('Plum'), - subtitle: Text('The Pawress dark palette'), + SettingsGroup( + children: [ + SettingsTile( + icon: Icons.thumb_up_alt_outlined, + title: state.likes.length == 1 + ? '1 like' + : '${state.likes.length} likes', + subtitle: + 'The thumbs up on a post teaches For You. Likes stay on ' + 'this device.', + trailing: TextButton( + onPressed: state.likes.isEmpty + ? null + : () => _confirmClearLikes(context), + child: const Text('Reset'), ), - RadioListTile( - value: ThemeChoice.amoled, - title: Text('AMOLED black'), - subtitle: Text('Pure black background'), - ), - ], - ), - ), - const SectionLabel('Playback'), - SwitchListTile( - secondary: const Icon(Icons.play_circle_outline_rounded), - title: const Text('Autoplay videos and GIFs'), - subtitle: const Text( - 'Muted in feeds, one at a time; with sound in the viewer', - ), - value: settings.autoplayVideos, - onChanged: (value) => - state.updateSettings(settings.copyWith(autoplayVideos: value)), + ), + ], ), - const SectionLabel('Downloads'), - ListTile( - leading: const Icon(Icons.folder_outlined), - title: const Text('Download folder'), - subtitle: Text( - settings.downloadFolderName ?? 'Chosen on the first download', - ), - onTap: () async { - final folder = await pickDownloadFolder(); - if (folder != null) { - await state.updateSettings( - state.settings.copyWith(downloadFolder: folder), - ); - } + const SettingsSection('Appearance'), + SettingsChoiceCard( + icon: Icons.palette_outlined, + title: 'Theme', + subtitle: switch (settings.theme) { + ThemeChoice.plum => 'The Pawress dark palette.', + ThemeChoice.amoled => 'Pure black, easy on OLED screens.', }, + values: ThemeChoice.values, + label: (choice) => switch (choice) { + ThemeChoice.plum => 'Plum', + ThemeChoice.amoled => 'AMOLED', + }, + selected: settings.theme, + onChanged: (choice) => update(settings.copyWith(theme: choice)), ), - SwitchListTile( - secondary: const Icon(Icons.drive_folder_upload_outlined), - title: const Text('Ask where to save each time'), - value: settings.askDownloadFolder, - onChanged: (value) => state.updateSettings( - settings.copyWith(askDownloadFolder: value), - ), + const SettingsSection('Playback'), + SettingsSwitchCard( + icon: Icons.play_circle_outline_rounded, + title: 'Autoplay videos and GIFs', + subtitle: + 'Muted in feeds, one at a time; with sound in the viewer.', + value: settings.autoplayVideos, + onChanged: (value) => + update(settings.copyWith(autoplayVideos: value)), ), - ListTile( - leading: const Icon(Icons.download_rounded), - title: const Text('Downloads'), - subtitle: const Text('Progress and finished files'), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const DownloadsPage()), - ), + const SettingsSection('Downloads'), + SettingsGroup( + children: [ + SettingsTile( + icon: Icons.folder_outlined, + title: 'Download folder', + subtitle: + settings.downloadFolderName ?? + 'Chosen on the first download', + onTap: () async { + final folder = await pickDownloadFolder(); + if (folder != null) { + await state.updateSettings( + state.settings.copyWith(downloadFolder: folder), + ); + } + }, + ), + SettingsSwitch( + icon: Icons.drive_folder_upload_outlined, + title: 'Ask where to save each time', + subtitle: 'Pick a folder for every download.', + value: settings.askDownloadFolder, + onChanged: (value) => + update(settings.copyWith(askDownloadFolder: value)), + ), + SettingsTile( + icon: Icons.download_rounded, + title: 'Downloads', + subtitle: 'Progress and finished files', + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DownloadsPage(), + ), + ), + ), + ], ), - const SectionLabel('Backup'), - ListTile( - leading: const Icon(Icons.upload_file_rounded), - title: const Text('Export backup'), - subtitle: const Text( - 'Follows, likes and, with a password, your sign-in', - ), - onTap: () => exportBackup(context), + const SettingsSection('Backup'), + SettingsActionsCard( + text: + 'Save your follows (with their notification settings), ' + 'favorites and likes to a readable JSON file, or restore them ' + 'from one. Importing adds to what is already here. With a ' + 'password, your Pawchive sign-in is included, encrypted.', + actions: [ + OutlinedButton.icon( + onPressed: () => exportBackup(context), + icon: const Icon(Icons.upload_file_outlined, size: 17), + label: const Text('Export backup'), + ), + OutlinedButton.icon( + onPressed: () => importBackup(context), + icon: const Icon(Icons.restore_rounded, size: 17), + label: const Text('Import backup'), + ), + ], ), - ListTile( - leading: const Icon(Icons.file_open_outlined), - title: const Text('Import backup'), - subtitle: const Text('Adds to what is already here'), - onTap: () => importBackup(context), + const SettingsSection('Updates'), + const _VersionCard(), + SettingsSwitchCard( + icon: Icons.system_update_outlined, + title: 'Check for updates at launch', + subtitle: + 'Look for a newer GitHub release when Pawress starts and offer ' + 'to install it.', + value: settings.checkUpdates, + onChanged: (value) => + update(settings.copyWith(checkUpdates: value)), ), - const SectionLabel('About'), - ListTile( - leading: const Icon(Icons.replay_rounded), - title: const Text('Show the introduction again'), - onTap: () { - Navigator.of(context).popUntil((route) => route.isFirst); - state.replayOnboarding(); - }, + const SettingsSection('About'), + SettingsGroup( + children: [ + SettingsTile( + icon: Icons.replay_rounded, + title: 'Show the introduction again', + onTap: () { + Navigator.of(context).popUntil((route) => route.isFirst); + state.replayOnboarding(); + }, + ), + SettingsTile( + icon: Icons.public_rounded, + title: 'Open Pawchive', + subtitle: siteHost, + trailing: const Icon(Icons.open_in_new_rounded, size: 18), + onTap: () => openExternal(siteUrl), + ), + ], ), - ListTile( - leading: const Icon(Icons.public_rounded), - title: const Text('Open Pawchive'), - subtitle: const Text(siteHost), - onTap: () => openExternal(siteUrl), + ], + ), + ); + } + + static Future _confirmClearLikes(BuildContext context) async { + final state = AppScope.read(context); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: panel, + title: const Text('Reset likes?'), + content: const Text( + 'For You forgets what you liked and goes back to your follows and ' + 'favorites. Favorites are not affected.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), ), - const _VersionTile(), - SwitchListTile( - secondary: const Icon(Icons.system_update_outlined), - title: const Text('Check for updates at launch'), - subtitle: const Text( - 'Look for a newer GitHub release when Pawress starts', - ), - value: settings.checkUpdates, - onChanged: (value) => - state.updateSettings(settings.copyWith(checkUpdates: value)), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Reset'), ), ], ), ); + if (confirmed == true) await state.clearLikes(); } } /// The installed version and build, with "Check now" for a newer release. -class _VersionTile extends StatefulWidget { - const _VersionTile(); +class _VersionCard extends StatefulWidget { + const _VersionCard(); @override - State<_VersionTile> createState() => _VersionTileState(); + State<_VersionCard> createState() => _VersionCardState(); } -class _VersionTileState extends State<_VersionTile> { +class _VersionCardState extends State<_VersionCard> { late final _installed = AppScope.read(context).updater.installedVersion(); bool _checking = false; @@ -279,20 +345,26 @@ class _VersionTileState extends State<_VersionTile> { @override Widget build(BuildContext context) => FutureBuilder( future: _installed, - builder: (context, snapshot) => ListTile( - leading: const BrandMark(size: 28), - title: Text( - snapshot.data == null ? appName : '$appName ${snapshot.data!.label}', - ), - subtitle: Text( - 'An unofficial client for $siteHost. Updates come from GitHub ' - 'releases.', - style: TextStyle(color: ink(.5)), - ), - trailing: TextButton( - onPressed: _checking ? null : _check, - child: Text(_checking ? 'Checking…' : 'Check now'), - ), + builder: (context, snapshot) => SettingsGroup( + children: [ + ListTile( + leading: const BrandMark(size: 28), + title: Text( + snapshot.data == null + ? appName + : '$appName ${snapshot.data!.label}', + ), + subtitle: const Text( + 'An unofficial client for $siteHost. Updates come from GitHub ' + 'releases.', + ), + trailing: TextButton( + onPressed: _checking ? null : _check, + child: Text(_checking ? 'Checking…' : 'Check now'), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + ], ), ); } diff --git a/test/backup_test.dart b/test/backup_test.dart index 7f77af8..f25980a 100644 --- a/test/backup_test.dart +++ b/test/backup_test.dart @@ -175,5 +175,55 @@ void main() { expect(state.favorites.map((f) => f.key), ['fanbox/8/web']); expect(state.favorites.single.post.title, 'From web'); }); + + test('a thumbs up is stored apart from favorites', () async { + final post = _like('5').post; + await state.toggleLike(post); + expect(state.isLiked(post), isTrue); + expect(state.isFavorite(post), isFalse); + expect((await state.store.likes()).single.key, post.key); + expect(state.forYouSignals().likes.single.creatorKey, 'patreon/7'); + await state.toggleLike(post); + expect(await state.store.likes(), isEmpty); + expect(calls, isEmpty); + }); + + test('turning a bell back on records a new baseline', () async { + await state.importBackup( + Backup( + follows: [_follow('1').copyWith(notify: NotifyLevel.off)], + favorites: const [], + ), + ); + // Imported follows keep their bell. + expect(state.follows.single.notify, NotifyLevel.off); + await state.store.saveFollows([ + state.follows.single.copyWith(seenPostIds: ['old']), + ]); + await state.reload(); + await state.setNotify('patreon/1', NotifyLevel.inApp); + final follow = (await state.store.follows()).single; + expect(follow.notify, NotifyLevel.inApp); + expect(follow.seenPostIds, isEmpty); + }); + }); + + test('Backup carries likes and bells', () { + final like = Like( + key: 'patreon/7/1', + service: 'patreon', + user: '7', + likedAt: DateTime.utc(2026, 11), + ); + final copy = Backup.decode( + Backup( + follows: [_follow('1').copyWith(notify: NotifyLevel.inApp)], + favorites: const [], + likes: [like], + ).encode(), + ); + expect(copy.follows.single.notify, NotifyLevel.inApp); + expect(copy.likes.single.key, like.key); + expect(mergeLikes([like], copy.likes), hasLength(1)); }); } diff --git a/test/core_test.dart b/test/core_test.dart index 5f9c955..2cc08da 100644 --- a/test/core_test.dart +++ b/test/core_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pawress/core/api.dart'; -import 'package:pawress/core/feeds.dart'; import 'package:pawress/core/models.dart'; // Shapes copied from live Pawchive responses (trimmed). @@ -39,9 +38,6 @@ const _recentPost = { 'attachments': [], }; -Post _post(String id, String user, {String service = 'patreon'}) => - Post(id: id, user: user, service: service, title: ''); - Follow _follow(String user, {String service = 'patreon'}) => Follow( creator: Creator(id: user, service: service, name: user), followedAt: DateTime.utc(2026), @@ -138,26 +134,29 @@ void main() { expect(copy.seenPostIds, ['1', '2']); expect(copy.unread, 3); expect(copy.synced, isTrue); + expect(copy.notify, NotifyLevel.push); }); - group('rankForYou', () { - test( - 'keeps recency without follows but breaks up runs from one creator', - () { - final ranked = rankForYou([ - _post('1', 'a'), - _post('2', 'a'), - _post('3', 'b'), - ], []); - expect(ranked.map((p) => p.id), ['1', '3', '2']); - }, - ); + test('Follow keeps its bell, and older saves default to push', () { + final follow = _follow('7').copyWith(notify: NotifyLevel.inApp); + expect(Follow.fromJson(follow.toJson()).notify, NotifyLevel.inApp); + final old = follow.toJson()..remove('notify'); + expect(Follow.fromJson(old).notify, NotifyLevel.push); + }); - test('lifts followed creators toward the top', () { - final page = [for (var i = 0; i < 20; i++) _post('$i', 'u$i')]; - final ranked = rankForYou(page, [_follow('u15')]); - expect(ranked.first.id, '15'); - }); + test('Like keeps what For You reads', () { + final post = Post( + id: '5', + user: '9', + service: 'fanbox', + title: 'Hi', + tags: const ['Cats'], + ); + final like = Like.fromJson(Like.of(post, DateTime.utc(2026, 11)).toJson()); + expect(like.key, post.key); + expect(like.creatorKey, 'fanbox/9'); + expect(like.tags, ['Cats']); + expect(like.likedAt, DateTime.utc(2026, 11)); }); test('parseDirectMessages reads DM cards from the DMs page', () { diff --git a/test/for_you_test.dart b/test/for_you_test.dart new file mode 100644 index 0000000..ca7af90 --- /dev/null +++ b/test/for_you_test.dart @@ -0,0 +1,264 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pawress/core/api.dart'; +import 'package:pawress/core/config.dart'; +import 'package:pawress/core/for_you.dart'; +import 'package:pawress/core/models.dart'; + +final _now = DateTime.utc(2026, 11, 3, 12); + +Post _post( + String id, + String user, { + String service = 'patreon', + int hoursAgo = 1, + List tags = const [], +}) => Post( + id: id, + user: user, + service: service, + title: '', + published: _now.subtract(Duration(hours: hoursAgo)), + tags: tags, +); + +Follow _follow(String user, {String service = 'patreon', DateTime? updated}) => + Follow( + creator: Creator( + id: user, + service: service, + name: user, + updated: updated, + ), + followedAt: DateTime.utc(2026), + ); + +Like _like(String user, {int daysAgo = 0, List tags = const []}) => + Like( + key: 'patreon/$user/x$daysAgo', + service: 'patreon', + user: user, + tags: tags, + likedAt: _now.subtract(Duration(days: daysAgo)), + ); + +ForYouSignals _signals({ + List follows = const [], + List likes = const [], + List favorites = const [], + double share = .65, +}) => ( + follows: follows, + likes: likes, + favorites: favorites, + followingShare: share, +); + +void main() { + group('Taste', () { + test('a fresh like counts more than an old one', () { + final taste = Taste.from( + _signals(likes: [_like('new'), _like('old', daysAgo: 90)]), + now: _now, + ); + expect( + taste.affinity('patreon/new'), + greaterThan(taste.affinity('patreon/old')), + ); + expect(taste.affinity('patreon/old'), greaterThan(0)); + expect(taste.affinity('patreon/nobody'), 0); + }); + + test('tags are case-insensitive and scaled to the strongest', () { + final taste = Taste.from( + _signals( + likes: [ + _like('a', tags: ['Cats']), + _like('b', tags: ['cats', 'Dogs']), + ], + ), + now: _now, + ); + expect(taste.tags['cats'], 1); + expect(taste.tags['dogs'], closeTo(.5, .01)); + }); + + test('ranks followed, liked-tag and fresh posts higher', () { + final taste = Taste.from( + _signals( + follows: [_follow('f')], + likes: [ + _like('x', tags: ['cats']), + ], + ), + now: _now, + ); + final followed = taste.score(_post('1', 'f'), _now); + final stranger = taste.score(_post('2', 's'), _now); + final tagged = taste.score(_post('3', 's', tags: ['Cats']), _now); + final stale = taste.score(_post('4', 's', hoursAgo: 24 * 14), _now); + expect(followed, greaterThan(stranger)); + expect(tagged, greaterThan(stranger)); + expect(stranger, greaterThan(stale)); + }); + }); + + group('pickSources', () { + test('includes liked artists that are not followed', () { + final taste = Taste.from( + _signals(follows: [_follow('f')], likes: [_like('l')]), + now: _now, + ); + final sources = pickSources(taste, [_follow('f')], Random(1), now: _now); + expect( + sources.map((c) => c.key), + unorderedEquals(['patreon/f', 'patreon/l']), + ); + }); + + test('favors artists with more affinity who posted recently', () { + final follows = [ + _follow('loved', updated: _now), + _follow('quiet', updated: _now.subtract(const Duration(days: 120))), + ]; + final taste = Taste.from( + _signals( + follows: follows, + likes: [for (var i = 0; i < 5; i++) _like('loved', daysAgo: i)], + ), + now: _now, + ); + final random = Random(7); + var lovedFirst = 0; + for (var i = 0; i < 200; i++) { + final first = pickSources(taste, follows, random, now: _now).first; + if (first.id == 'loved') lovedFirst++; + } + // Weighted, not fixed: the quieter artist still leads sometimes. + expect(lovedFirst, greaterThan(150)); + expect(lovedFirst, lessThan(200)); + }); + }); + + test('blendForYou spreads the smaller list through the larger', () { + final a = [for (var i = 0; i < 3; i++) _post('a$i', 'a')]; + final b = [_post('b0', 'b')]; + expect(blendForYou(a, b).map((p) => p.id), ['a0', 'a1', 'a2', 'b0']); + expect( + blendForYou(a.take(2).toList(), [ + _post('b0', 'b'), + _post('b1', 'b'), + ]).map((p) => p.id), + ['a0', 'b0', 'a1', 'b1'], + ); + expect(blendForYou([], b).single.id, 'b0'); + }); + + test('spreadCreators breaks up runs from one creator', () { + final spread = spreadCreators([ + _post('1', 'a'), + _post('2', 'a'), + _post('3', 'b'), + ]); + expect(spread.map((p) => p.id), ['1', '3', '2']); + }); + + test('takeBest caps each creator and removes what it takes', () { + final posts = [ + for (var i = 0; i < 5; i++) _post('a$i', 'a'), + _post('b0', 'b'), + ]; + final best = takeBest( + posts, + 4, + (p) => p.user == 'a' ? 2 : 1, + perCreator: 2, + ); + expect(best.map((p) => p.id), ['a0', 'a1', 'b0']); + expect(posts.map((p) => p.id), ['a2', 'a3', 'a4']); + }); + + group('ForYouFeed', () { + late List requests; + + Future> recent(int offset) async { + requests.add('recent $offset'); + return [ + for (var i = 0; i < pageSize; i++) + _post('r${offset + i}', 'stranger${(offset + i) % 25}', hoursAgo: i), + ]; + } + + Future> creatorPosts(Creator creator) async { + requests.add(creator.key); + return [ + for (var i = 0; i < 10; i++) + _post('${creator.id}-$i', creator.id, hoursAgo: 2 + i * 12), + ]; + } + + setUp(() => requests = []); + + test('is the recent feed without follows or likes', () async { + final feed = ForYouFeed.custom( + recent: recent, + creatorPosts: creatorPosts, + signals: _signals, + random: Random(1), + ); + await feed.loadMore(); + expect(feed.posts, hasLength(30)); + expect(feed.posts.every((p) => p.id.startsWith('r')), isTrue); + expect(requests, ['recent 0']); + }); + + test('draws mostly from followed artists, a few per page', () async { + final follows = [for (var i = 0; i < 20; i++) _follow('f$i')]; + final feed = ForYouFeed.custom( + recent: recent, + creatorPosts: creatorPosts, + signals: () => _signals(follows: follows), + random: Random(2), + ); + await feed.loadMore(); + final fromArtists = feed.posts.where((p) => p.user.startsWith('f')); + expect(fromArtists.length, (30 * .65).round()); + expect(requests.where((r) => r.startsWith('patreon/')).length, 6); + // No artist fills two rows in a row. + for (var i = 1; i < feed.posts.length; i++) { + expect(feed.posts[i].creatorKey, isNot(feed.posts[i - 1].creatorKey)); + } + + await feed.loadMore(); + expect(feed.posts, hasLength(60)); + expect(feed.posts.map((p) => p.key).toSet(), hasLength(60)); + }); + + test('a thumbs up pulls in an artist you do not follow', () async { + final likes = []; + final feed = ForYouFeed.custom( + recent: recent, + creatorPosts: creatorPosts, + signals: () => _signals(likes: likes), + random: Random(3), + ); + likes.add(_like('liked')); + await feed.loadMore(); + expect(requests, contains('patreon/liked')); + expect(feed.posts.any((p) => p.user == 'liked'), isTrue); + }); + + test('shows the error when nothing could load', () async { + final feed = ForYouFeed.custom( + recent: (_) async => throw const ApiException('Offline'), + creatorPosts: creatorPosts, + signals: _signals, + random: Random(4), + ); + await feed.loadMore(); + expect(feed.error, 'Offline'); + expect(feed.posts, isEmpty); + }); + }); +} diff --git a/test/update_checker_test.dart b/test/update_checker_test.dart index dc9c6cc..4ef5448 100644 --- a/test/update_checker_test.dart +++ b/test/update_checker_test.dart @@ -110,4 +110,32 @@ void main() { await pending; expect(await store.follows(), isEmpty); }); + + test('a follow with its bell off is not checked at all', () async { + final follow = (await store.follows()).single; + await store.saveFollows([follow.copyWith(notify: NotifyLevel.off)]); + expect(await checker.check(), isEmpty); + expect(server.postRequests, 0); + expect((await store.follows()).single.seenPostIds, isEmpty); + }); + + test('only push follows become Android notifications', () { + UpdateNotice notice(String id) => UpdateNotice( + id: 'patreon/$id/1', + creator: Creator(id: id, service: 'patreon', name: id), + postId: '1', + postTitle: '', + at: DateTime.utc(2026), + ); + Follow follow(String id, NotifyLevel level) => Follow( + creator: Creator(id: id, service: 'patreon', name: id), + followedAt: DateTime.utc(2026), + notify: level, + ); + final shown = pushable( + [notice('1'), notice('2'), notice('3')], + [follow('1', NotifyLevel.push), follow('2', NotifyLevel.inApp)], + ); + expect(shown.map((n) => n.creator.id), ['1']); + }); }