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']); + }); }