Schedule page: day columns on AniList + AnimeFlow, real requests and owner inbox

Standalone SchedulePage (plugged into the shell later): cover-first
cards in day columns from last week's Monday, filters (On Jellyfin /
Following / All airing, niche toggle, compact), requests that really
call AnimeFlow (episode with AnimeFlow's own numbering, series via
AniDB search by AniList id), follow, Put it up resolving the Jellyfin
episode, the requests sheet with the owner's Retry/Decline, refresh
every 60 s while visible, stale-AniList and AnimeFlow-error states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-08 23:59:22 +03:00
1 parent caa0765890
commit 677ece81c4
5 files changed
+1845

No files matched your search

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import '../../shell/avatar.dart';
import '../animeflow.dart';
// The Schedule page's side sheet: AnimeFlow requests (everyone's for the
// owner, who can retry failed ones and decline the rest; own otherwise).
Color requestColor(RequestStatus s) => switch (s) {
RequestStatus.pending || RequestStatus.searching => Colors.amber,
RequestStatus.approved => Colors.lightBlueAccent,
RequestStatus.rejected || RequestStatus.failed => Colors.redAccent,
RequestStatus.done => Colors.greenAccent,
};
String requestWhat(AnimeFlowRequest r) => switch (r.kind) {
RequestKind.series => r.title,
RequestKind.season => '${r.title} · season ${r.season ?? '?'}',
RequestKind.episode => '${r.title} · S${r.season ?? '?'}E${r.episode ?? '?'}',
};
class RequestsSheet extends StatelessWidget {
/// Scope manage: everyone's requests, Retry and Decline.
final bool owner;
/// Null while loading.
final List<AnimeFlowRequest>? requests;
final String? error;
/// Our own name, for requests without a user name.
final String selfName;
/// Requests with a call in flight.
final Set<int> busy;
final void Function(AnimeFlowRequest r) onRetry;
final void Function(AnimeFlowRequest r) onDecline;
final VoidCallback onClose;
const RequestsSheet({
super.key,
required this.owner,
required this.requests,
required this.selfName,
required this.onRetry,
required this.onDecline,
required this.onClose,
this.error,
this.busy = const {},
});
Widget _tile(BuildContext context, AnimeFlowRequest r) {
final scheme = Theme.of(context).colorScheme;
final who = r.userName ?? (selfName.isEmpty ? 'you' : selfName);
final canRetry = owner && r.status == RequestStatus.failed;
final canDecline =
owner &&
(r.status == RequestStatus.pending ||
r.status == RequestStatus.approved ||
r.status == RequestStatus.searching);
final inFlight = busy.contains(r.id);
return Padding(
key: ValueKey('request-${r.id}'),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(
info: AvatarInfo(name: who, color: colorForName(who)),
size: 32,
ring: false,
badges: false,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(requestWhat(r), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(who, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
Pill(r.status.name, requestColor(r.status)),
],
),
if (r.reason != null && r.reason!.isNotEmpty)
Text(r.reason!, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
if (canRetry || canDecline)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
if (canRetry)
FilledButton.tonal(
key: ValueKey('retry-${r.id}'),
onPressed: inFlight ? null : () => onRetry(r),
child: const Text('Retry'),
),
if (canDecline)
TextButton(
key: ValueKey('decline-${r.id}'),
onPressed: inFlight ? null : () => onDecline(r),
child: const Text('Decline'),
),
if (inFlight) ...[
const SizedBox(width: 8),
const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
],
],
),
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final reqs = requests;
return Material(
color: scheme.surfaceContainerLow,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 10, 8, 16),
children: [
Row(
children: [
Text(
owner ? 'Requests' : 'My requests',
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const Spacer(),
IconButton(
key: const ValueKey('requests-close'),
onPressed: onClose,
tooltip: 'Close',
icon: const Icon(Icons.close, size: 18),
),
],
),
Text(
owner
? 'Everyone\'s requests. AnimeFlow approves them automatically; you can decline or retry.'
: 'AnimeFlow downloads requests automatically; they appear on Jellyfin when done.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 12),
if (error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(error!, style: TextStyle(color: scheme.error, fontSize: 12)),
),
if (reqs == null && error == null)
const Padding(
padding: EdgeInsets.only(top: 24),
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
)
else if (reqs != null && reqs.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 24),
child: Center(
child: Text('No requests yet', style: TextStyle(color: scheme.onSurfaceVariant)),
),
),
for (final r in reqs ?? const <AnimeFlowRequest>[]) _tile(context, r),
],
),
);
}
}
@@ -0,0 +1,143 @@
import 'package:sync_relay_core/core.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/format.dart' show pickFor;
import '../../screens/home/picked_media.dart';
import '../anilist.dart';
import '../animeflow.dart';
import '../schedule_model.dart';
// What the Schedule page's buttons do beyond one client call: friendly
// AnimeFlow errors, finding AnimeFlow's own numbering of an airing, the
// AniDB id of an untracked AniList entry, and the Jellyfin episode to put
// up.
/// A failure meant for the user as is.
class ScheduleActionException implements Exception {
final String message;
const ScheduleActionException(this.message);
@override
String toString() => message;
}
/// [e] as one short sentence for a toast.
String friendlyError(Object e) => switch (e) {
ScheduleActionException(:final message) => message,
AnimeFlowException(:final code, :final detail) => switch (code) {
AnimeFlowErrorCode.alreadyRequested => 'Already requested',
AnimeFlowErrorCode.alreadyTracked => 'AnimeFlow already tracks this series',
AnimeFlowErrorCode.alreadyAvailable => 'Already on Jellyfin',
AnimeFlowErrorCode.missingScope => 'The owner has not allowed you to do that on AnimeFlow',
AnimeFlowErrorCode.revoked => 'Your AnimeFlow access was revoked',
AnimeFlowErrorCode.missingToken => 'Link the Jellyfin server to use AnimeFlow',
AnimeFlowErrorCode.badToken => 'AnimeFlow: sign in to Jellyfin again',
AnimeFlowErrorCode.ownAccount => 'You cannot change your own account',
AnimeFlowErrorCode.searchUnavailable => 'AniDB search is unavailable right now',
AnimeFlowErrorCode.seriesNotFound => 'AnimeFlow does not know this series',
AnimeFlowErrorCode.episodeNotFound => 'AnimeFlow does not know this episode',
AnimeFlowErrorCode.requestNotFound => 'That request is gone',
AnimeFlowErrorCode.notFound => 'AnimeFlow: not found',
AnimeFlowErrorCode.invalidState => 'That cannot be done in its current state',
AnimeFlowErrorCode.rateLimited => 'AnimeFlow is busy, try again shortly',
AnimeFlowErrorCode.network => 'AnimeFlow is unreachable',
AnimeFlowErrorCode.jellyfinUnreachable => 'AnimeFlow cannot reach Jellyfin right now',
_ => 'AnimeFlow: $detail',
},
JellyfinException(:final message) => 'Jellyfin: $message',
_ => '$e',
};
bool _sameDay(DateTime a, DateTime b) {
final x = a.toLocal(), y = b.toLocal();
return x.year == y.year && x.month == y.month && x.day == y.day;
}
/// AnimeFlow's episode for [e] (its season and numbering), from the
/// series detail: same AniList id and episode, else absolute number, else
/// the same air day. Null when the entry is not tracked or nothing fits.
Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async {
final id = e.seriesId;
if (id == null) return null;
final eps = (await client.seriesDetail(id)).episodes;
final a = e.airing;
final mine = eps.where((x) => x.anilistId == null || x.anilistId == a.anilistId).toList();
return mine.where((x) => x.anilistId == a.anilistId && x.episode == a.episode).firstOrNull ??
mine.where((x) => x.absolute == a.episode).firstOrNull ??
mine.where((x) => x.airDate != null && _sameDay(x.airDate!, a.airingAt)).firstOrNull;
}
/// The (season, episode) AnimeFlow uses for [e]; falls back to the
/// matched season and AniList's number when the detail has nothing.
Future<({int season, int episode})> flowNumbering(AnimeFlowClient? client, ScheduleEntry e) async {
AnimeFlowEpisode? ep;
if (client != null) {
try {
ep = await flowEpisodeFor(client, e);
} on AnimeFlowException catch (err) {
if (err.isUnauthorized || err.isForbidden) rethrow;
}
}
if (ep != null) return (season: ep.season, episode: ep.episode);
return (season: e.season ?? 1, episode: e.airing.episode);
}
/// The AniDB id of an untracked AniList entry, from AnimeFlow's search:
/// the hit with the same AniList id, searched by English then romaji
/// title. Otherwise every hit seen, for the user to choose from.
Future<({AnimeFlowSearchHit? match, List<AnimeFlowSearchHit> hits})> findOnAniDb(
AnimeFlowClient client,
Airing a,
) async {
final hits = <AnimeFlowSearchHit>[];
final queries = {a.title, if (a.titleRomaji.isNotEmpty) a.titleRomaji};
for (final q in queries) {
final found = await client.search(q);
final match = found.where((h) => h.anilistId == a.anilistId).firstOrNull;
if (match != null) return (match: match, hits: found);
for (final h in found) {
if (hits.every((x) => x.anidbId != h.anidbId)) hits.add(h);
}
}
return (match: null, hits: hits);
}
/// The linked Jellyfin account AnimeFlow's series ids belong to: the one
/// AnimeFlow authenticates with, else the default.
JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient? flow) {
if (flow != null) {
final token = AnimeFlowClient.tokenFor(accounts.accounts, flow.baseUrl);
final a = accounts.accounts.where((a) => token != null && a.token == token).firstOrNull;
if (a != null) return a;
}
return accounts.defaultAccount;
}
/// The Jellyfin episode of an available [e] as a media pick. Throws
/// [ScheduleActionException] (or a client error) when it cannot be found.
Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async {
final seriesId = e.jellyfinSeriesId;
if (seriesId == null) throw const ScheduleActionException('This series is not on Jellyfin yet');
await accounts.ensureLoaded(validate: false);
final account = jellyfinAccountFor(accounts, flow);
final client = account == null ? null : accounts.clientFor(account.serverId);
if (account == null || client == null) {
throw const ScheduleActionException('Link the Jellyfin server first (Settings)');
}
final n = await flowNumbering(flow, e);
final List<JellyfinItem> eps;
try {
eps = await client.episodes(seriesId);
} on JellyfinException catch (err) {
if (err.isUnauthorized) accounts.markExpired(account.serverId);
rethrow;
}
final a = e.airing;
final item =
eps.where((x) => x.parentIndexNumber == n.season && x.indexNumber == n.episode).firstOrNull ??
eps.where((x) => x.premiereDate != null && _sameDay(x.premiereDate!, a.airingAt)).singleOrNull ??
eps.where((x) => x.indexNumber == a.episode).singleOrNull;
if (item == null) {
throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet');
}
return pickFor(account, item);
}
@@ -0,0 +1,386 @@
import 'package:flutter/material.dart';
import '../schedule_model.dart';
// One day of the Schedule board and its airing cards: cover poster on the
// left (air time on it, download progress under it), title, episode and
// status on the right, one full-width action below. Compact mode is a
// slim row.
/// What a card's action does, decided by the page.
enum CardAction {
none,
putUp,
/// Available, but not in a server: shown disabled with a hint.
playOffline,
request,
requestSeries,
requested,
}
String _two(int n) => n.toString().padLeft(2, '0');
String clockText(DateTime t) {
final l = t.toLocal();
return '${_two(l.hour)}:${_two(l.minute)}';
}
String countdownText(DateTime at, DateTime now) {
final d = at.difference(now);
if (d.isNegative) return 'aired';
if (d.inDays > 0) return 'in ${d.inDays}d ${d.inHours % 24}h';
if (d.inHours > 0) return 'in ${d.inHours}h ${d.inMinutes % 60}m';
return 'in ${d.inMinutes}m';
}
/// Status line: text, colour, icon.
(String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => switch (e.status) {
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
ScheduleStatus.downloading => (
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
Colors.lightBlueAccent,
Icons.downloading,
),
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred),
ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline),
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
};
class DayColumn extends StatelessWidget {
final DateTime day;
final bool today, past;
final List<Widget> cards;
final bool compact;
const DayColumn({
super.key,
required this.day,
required this.today,
required this.past,
required this.cards,
required this.compact,
});
static const _wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: today ? scheme.primary.withValues(alpha: 0.06) : scheme.surfaceContainerLow.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(14),
border: today ? Border.all(color: scheme.primary.withValues(alpha: 0.5)) : null,
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
child: Row(
children: [
Text(
today ? 'Today' : _wd[day.weekday - 1],
style: TextStyle(fontWeight: FontWeight.w700, color: today ? scheme.primary : null),
),
const SizedBox(width: 6),
Text('${day.day}.${day.month}.', style: TextStyle(color: scheme.onSurfaceVariant)),
const Spacer(),
if (cards.isNotEmpty)
Text('${cards.length}', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
),
Expanded(
child: Opacity(
opacity: past ? 0.85 : 1,
child: cards.isEmpty
? Center(
child: Text('—', style: TextStyle(color: scheme.onSurfaceVariant)),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 10),
itemCount: cards.length,
separatorBuilder: (_, _) => SizedBox(height: compact ? 6 : 10),
itemBuilder: (context, i) => cards[i],
),
),
),
],
),
);
}
}
class AiringCard extends StatelessWidget {
final ScheduleEntry entry;
final DateTime now;
final bool compact;
final bool followed;
final CardAction action;
/// Why [action] is disabled (no request scope, not on Jellyfin, ...);
/// null = enabled.
final String? disabledHint;
/// A call for this card is in flight.
final bool busy;
final VoidCallback onAction;
/// Null hides the bookmark (untracked series).
final VoidCallback? onFollow;
const AiringCard({
super.key,
required this.entry,
required this.now,
required this.compact,
required this.followed,
required this.action,
required this.onAction,
this.disabledHint,
this.busy = false,
this.onFollow,
});
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
Widget _image(String? url, Widget fallback) =>
url == null ? fallback : Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
Widget? _button(BuildContext context) {
final enabled = disabledHint == null && !busy;
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
Widget? b = switch (action) {
CardAction.none => null,
CardAction.putUp => FilledButton.tonalIcon(
onPressed: enabled ? onAction : null,
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
label: const Text('Put it up'),
),
CardAction.playOffline => FilledButton.tonalIcon(
onPressed: null,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Play'),
),
CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
onPressed: enabled ? onAction : null,
icon: busy ? spinner : const Icon(Icons.download, size: 18),
label: Text(action == CardAction.request ? 'Request' : 'Request series'),
),
CardAction.requested => const OutlinedButton(onPressed: null, child: Text('Requested')),
};
if (b == null) return null;
b = KeyedSubtree(key: ValueKey('action-$_id'), child: b);
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
}
Widget? _follow(BuildContext context, double size) {
final f = onFollow;
if (f == null) return null;
return IconButton(
key: ValueKey('follow-$_id'),
onPressed: f,
tooltip: followed ? 'Stop following' : 'Follow',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: BoxConstraints.tightFor(width: size + 10, height: size + 10),
iconSize: size,
color: followed ? Theme.of(context).colorScheme.primary : null,
icon: Icon(followed ? Icons.bookmark : Icons.bookmark_outline),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final a = entry.airing;
final (label, color, icon) = statusBadge(entry, now);
final time = clockText(a.airingAt);
final tintColor = a.color ?? Colors.blueGrey;
final tint = DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [tintColor.withValues(alpha: 0.85), tintColor.withValues(alpha: 0.35)],
),
),
);
final tap = action == CardAction.putUp && disabledHint == null && !busy ? onAction : null;
if (compact) {
final requestable = action == CardAction.request || action == CardAction.requestSeries;
return Material(
key: ValueKey('card-$_id'),
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: tap,
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(width: 34, height: 48, child: _image(a.cover, tint)),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
a.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
Text('$time · E${a.episode}', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
),
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
if (requestable)
Tooltip(
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
child: IconButton(
key: ValueKey('action-$_id'),
onPressed: disabledHint == null && !busy ? onAction : null,
visualDensity: VisualDensity.compact,
iconSize: 18,
icon: Icon(Icons.download, color: color),
),
)
else
Tooltip(
message: label,
child: Icon(icon, size: 16, color: color),
),
],
),
),
),
);
}
final button = _button(context);
final follow = _follow(context, 16);
// The cover poster leads: it is what people recognise at a glance.
return Material(
key: ValueKey('card-$_id'),
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: tap,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 84,
height: 120,
child: Stack(
fit: StackFit.expand,
children: [
_image(a.cover, tint),
Positioned(
left: 4,
top: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(5),
),
child: Text(
time,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12),
),
),
),
if (entry.status == ScheduleStatus.downloading)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: entry.progress,
minHeight: 3,
color: Colors.lightBlueAccent,
),
),
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: SizedBox(
height: 120,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
a.title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
),
const SizedBox(height: 2),
Row(
children: [
Flexible(
child: Text(
'Episode ${a.episode}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
),
const SizedBox(width: 2),
if (follow != null)
follow
else if (followed)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.bookmark, size: 14, color: scheme.primary),
),
],
),
const Spacer(),
Row(
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Expanded(
child: Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color, fontSize: 12),
),
),
],
),
],
),
),
),
],
),
if (button != null) ...[const SizedBox(height: 8), SizedBox(width: double.infinity, child: button)],
],
),
),
),
);
}
}
@@ -0,0 +1,584 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../jellyfin/accounts.dart';
import '../../screens/home/picked_media.dart';
import '../../shell/avatar.dart';
import '../../shell/motion.dart';
import '../anilist.dart';
import '../animeflow.dart';
import '../schedule_model.dart';
import 'requests_sheet.dart';
import 'schedule_actions.dart';
import 'schedule_card.dart';
// The Schedule tab: one column per day from last week's Monday to four
// weeks out (AniList), each airing with what AnimeFlow has of it, and the
// one thing to do about it (put it up, request it). Refreshes every
// minute while visible; ETags keep that cheap.
enum ScheduleFilter { jellyfin, following, all }
class SchedulePage extends StatefulWidget {
/// In a server: available episodes can be put up.
final bool online;
/// Puts a Jellyfin episode up in the server; returns an error to show.
final Future<String?> Function(JellyfinMedia pick) onPutUp;
final void Function(String message) onToast;
/// Null = the page makes (and closes) its own.
final ScheduleRepository? repository;
/// Linked Jellyfin servers for Put it up; null = [JellyfinAccounts.instance].
final JellyfinAccounts? accounts;
const SchedulePage({
super.key,
required this.online,
required this.onPutUp,
required this.onToast,
this.repository,
this.accounts,
});
static const columnWidth = 252.0;
static const columnGap = 12.0;
static const refreshEvery = Duration(seconds: 60);
/// "All airing" hides entries below this AniList popularity unless
/// "Niche too" is on (tracked ones always show).
static const nicheBelow = 5000;
static const sheetWidth = 360.0;
@override
State<SchedulePage> createState() => _SchedulePageState();
}
class _SchedulePageState extends State<SchedulePage> {
late final ScheduleRepository _repo;
late final bool _ownsRepo;
ScheduleSnapshot? _snap;
bool _refreshing = false;
int _gen = 0;
Timer? _timer;
final _scroll = ScrollController();
bool _jumped = false;
ScheduleFilter _filter = ScheduleFilter.jellyfin;
bool _compact = false;
bool _niche = false;
bool _sheet = false;
/// The sheet stays built while it slides out.
bool _sheetBuilt = false;
/// Follow toggles not yet confirmed by a reload (series id -> followed).
final _followOverride = <int, bool>{};
/// Requested in this run: "anilistId#episode" or "series:anilistId".
final _requested = <String>{};
/// Cards with a call in flight (same keys as [_requested], "play:...").
final _busy = <String>{};
List<AnimeFlowRequest>? _requests;
String? _requestsError;
final _reqBusy = <int>{};
JellyfinAccounts get _accounts => widget.accounts ?? JellyfinAccounts.instance;
AnimeFlowClient? get _flow => _repo.animeFlow;
AnimeFlowMe? get _me => _snap?.me;
bool get _owner => _me?.can(AnimeFlowScope.manage) ?? false;
@override
void initState() {
super.initState();
_ownsRepo = widget.repository == null;
_repo =
widget.repository ??
ScheduleRepository(
aniList: AniListClient(),
animeFlow: AnimeFlowClient.fromAccounts(JellyfinAccounts.instance),
);
_load();
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
});
}
@override
void dispose() {
_timer?.cancel();
_scroll.dispose();
if (_ownsRepo) {
_repo.aniList.close();
_repo.animeFlow?.close();
}
super.dispose();
}
void _toast(String s) {
if (mounted) widget.onToast(s);
}
Future<void> _load({bool force = false}) async {
final gen = ++_gen;
if (_snap != null) setState(() => _refreshing = true);
final snap = await _repo.load(force: force);
if (!mounted || gen != _gen) return;
final first = _snap == null;
setState(() {
_snap = snap;
_refreshing = false;
// Without AnimeFlow nothing is "on Jellyfin": start on everything.
if (first && !snap.hasAnimeFlow) _filter = ScheduleFilter.all;
});
if (!_jumped && snap.entries.isNotEmpty) {
_jumped = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToday());
}
await _loadRequests();
}
Future<void> _loadRequests() async {
final c = _flow, me = _me;
if (c == null || me == null) {
if (_requests != null || _requestsError != null) {
setState(() {
_requests = null;
_requestsError = null;
});
}
return;
}
try {
final reqs = await c.requests(all: me.can(AnimeFlowScope.manage));
if (mounted) {
setState(() {
_requests = reqs;
_requestsError = null;
});
}
} catch (e) {
if (mounted) setState(() => _requestsError = friendlyError(e));
}
}
// ------------------------------------------------------------- layout
int _todayIndex(DateTime from) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final f = DateTime(from.year, from.month, from.day);
return (today.difference(f).inHours / 24).round();
}
void _jumpToday({bool animate = false}) {
final snap = _snap;
if (!_scroll.hasClients || snap == null) return;
final i = _todayIndex(snap.from.toLocal());
final x = (i * (SchedulePage.columnWidth + SchedulePage.columnGap))
.clamp(0.0, _scroll.position.maxScrollExtent)
.toDouble();
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
}
bool _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed;
bool _shows(ScheduleEntry e) => switch (_filter) {
ScheduleFilter.jellyfin => e.tracked,
ScheduleFilter.following => _followed(e),
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
};
static String _key(ScheduleEntry e) =>
e.tracked ? '${e.airing.anilistId}#${e.airing.episode}' : 'series:${e.airing.anilistId}';
static const _active = {RequestStatus.pending, RequestStatus.approved, RequestStatus.searching};
bool _isRequested(ScheduleEntry e) {
if (_requested.contains(_key(e))) return true;
final a = e.airing;
for (final r in _requests ?? const <AnimeFlowRequest>[]) {
if (!_active.contains(r.status)) continue;
if (e.tracked) {
if (r.kind == RequestKind.episode && r.seriesId == e.seriesId && r.episode == a.episode) return true;
if (r.kind == RequestKind.season && r.seriesId == e.seriesId && r.season == e.season) return true;
} else if (r.kind == RequestKind.series && (r.title == a.title || r.title == a.titleRomaji)) {
return true;
}
}
return false;
}
(CardAction, String?) _actionFor(ScheduleEntry e) {
if (e.status == ScheduleStatus.available) {
if (!widget.online) return (CardAction.playOffline, 'Join or host a server to put it up');
if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet');
return (CardAction.putUp, null);
}
final me = _me;
final CardAction kind;
if (e.tracked) {
kind = e.status == ScheduleStatus.missing || e.status == ScheduleStatus.failed
? CardAction.request
: CardAction.none;
} else {
kind = me != null ? CardAction.requestSeries : CardAction.none;
}
if (kind == CardAction.none) return (kind, null);
if (_isRequested(e)) return (CardAction.requested, null);
if (me == null || !me.can(AnimeFlowScope.request)) {
return (kind, 'Requests need the owner to allow them for you on AnimeFlow');
}
return (kind, null);
}
// ------------------------------------------------------------- actions
Future<void> _onAction(ScheduleEntry e, CardAction action) async {
switch (action) {
case CardAction.putUp:
return _putUp(e);
case CardAction.request:
case CardAction.requestSeries:
return _request(e);
default:
return;
}
}
Future<void> _putUp(ScheduleEntry e) async {
final key = 'play:${_key(e)}';
if (_busy.contains(key)) return;
setState(() => _busy.add(key));
try {
final pick = await resolveJellyfinEpisode(_accounts, _flow, e);
final err = await widget.onPutUp(pick);
if (err != null) _toast(err);
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _busy.remove(key));
}
}
Future<void> _request(ScheduleEntry e) async {
final c = _flow;
if (c == null) return;
final key = _key(e);
if (_busy.contains(key)) return;
setState(() => _busy.add(key));
final a = e.airing;
try {
final seriesId = e.seriesId;
if (e.tracked && seriesId != null) {
final n = await flowNumbering(c, e);
await c.requestEpisode(seriesId, n.season, n.episode);
_requested.add(key);
_toast('Requested ${a.title}, episode ${a.episode}');
} else {
final found = await findOnAniDb(c, a);
final hit = found.match ?? (mounted ? await _chooseHit(a, found.hits) : null);
if (hit == null) return;
await c.requestSeries(hit.anidbId);
_requested.add(key);
_toast('Requested ${hit.title}');
}
unawaited(_loadRequests());
} on AnimeFlowException catch (err) {
if (err.code == AnimeFlowErrorCode.alreadyRequested) _requested.add(key);
_toast(friendlyError(err));
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _busy.remove(key));
}
}
/// No search hit has [a]'s AniList id: let the user pick one (or learn
/// that AniDB does not know it).
Future<AnimeFlowSearchHit?> _chooseHit(Airing a, List<AnimeFlowSearchHit> hits) => showDialog<AnimeFlowSearchHit>(
context: context,
builder: (context) {
final scheme = Theme.of(context).colorScheme;
return AlertDialog(
key: const ValueKey('anidb-choose'),
title: Text(hits.isEmpty ? 'Not found on AniDB' : 'Which one is "${a.title}"?'),
content: SizedBox(
width: 420,
child: hits.isEmpty
? Text(
'AnimeFlow can only add series AniDB knows, and its search found nothing for this title.',
style: TextStyle(color: scheme.onSurfaceVariant),
)
: ListView(
shrinkWrap: true,
children: [
for (final h in hits)
ListTile(
key: ValueKey('anidb-${h.anidbId}'),
title: Text(h.title),
subtitle: Text('AniDB ${h.anidbId}${h.seriesId != null ? ' · already on AnimeFlow' : ''}'),
onTap: () => Navigator.of(context).pop(h),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(hits.isEmpty ? 'OK' : 'Cancel')),
],
);
},
);
Future<void> _toggleFollow(ScheduleEntry e) async {
final c = _flow, id = e.seriesId;
if (c == null || id == null) return;
final next = !_followed(e);
setState(() => _followOverride[id] = next);
try {
await c.setFollowed(id, next);
await _load();
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _followOverride.remove(id));
}
}
Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
final c = _flow;
if (c == null || _reqBusy.contains(r.id)) return;
setState(() => _reqBusy.add(r.id));
try {
approve ? await c.approve(r.id) : await c.reject(r.id);
_toast(approve ? 'Retrying ${r.title}' : 'Declined ${r.title}');
await _loadRequests();
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _reqBusy.remove(r.id));
}
}
// ------------------------------------------------------------- build
Widget _header(BuildContext context) {
final snap = _snap;
final me = _me;
final stale = snap?.aniListStaleSince;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SegmentedButton<ScheduleFilter>(
key: const ValueKey('schedule-filter'),
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: ScheduleFilter.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('On Jellyfin'),
),
ButtonSegment(
value: ScheduleFilter.following,
icon: Icon(Icons.bookmark_outline),
label: Text('Following'),
),
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
],
selected: {_filter},
onSelectionChanged: (s) => setState(() => _filter = s.first),
),
FilterChip(
key: const ValueKey('schedule-compact'),
label: const Text('Compact'),
selected: _compact,
onSelected: (v) => setState(() => _compact = v),
),
if (_filter == ScheduleFilter.all)
FilterChip(
key: const ValueKey('schedule-niche'),
label: const Text('Niche too'),
selected: _niche,
onSelected: (v) => setState(() => _niche = v),
),
TextButton.icon(
key: const ValueKey('schedule-today'),
onPressed: () => _jumpToday(animate: true),
icon: const Icon(Icons.today, size: 18),
label: const Text('Today'),
),
IconButton(
key: const ValueKey('schedule-refresh'),
tooltip: 'Refresh',
onPressed: _refreshing || snap == null ? null : () => _load(force: true),
icon: _refreshing
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh, size: 20),
),
Tooltip(
message: me == null ? (snap?.animeFlowError ?? 'AnimeFlow is not available') : '',
child: OutlinedButton.icon(
key: const ValueKey('schedule-requests'),
onPressed: me == null
? null
: () => setState(() {
_sheet = !_sheet;
if (_sheet) _sheetBuilt = true;
}),
icon: const Icon(Icons.inbox_outlined, size: 18),
label: Text(_owner ? 'Requests' : 'My requests'),
),
),
if (stale != null)
Pill(
key: const ValueKey('schedule-stale'),
'AniList busy · showing data from ${clockText(stale)}',
Colors.amber,
),
if (me != null)
Pill(
key: const ValueKey('schedule-animeflow'),
'AnimeFlow · ${me.name} · ${[for (final s in AnimeFlowScope.values)
if (me.can(s)) s.name].join(', ')}',
Colors.greenAccent.shade400,
)
else if (snap?.animeFlowError != null)
Pill(key: const ValueKey('schedule-animeflow'), snap!.animeFlowError!, Colors.orangeAccent),
],
),
);
}
Widget _board(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final snap = _snap;
if (snap == null) return const Center(child: CircularProgressIndicator());
if (snap.aniListError != null && snap.entries.isEmpty) {
return Center(
key: const ValueKey('schedule-error'),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off, color: scheme.error),
const SizedBox(height: 8),
Text(
'Could not load the schedule: ${snap.aniListError}',
textAlign: TextAlign.center,
style: TextStyle(color: scheme.error),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _refreshing ? null : () => _load(force: true),
child: const Text('Try again'),
),
],
),
),
);
}
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final from = snap.from.toLocal();
final start = DateTime(from.year, from.month, from.day);
final to = snap.to.toLocal();
final count = (DateTime(to.year, to.month, to.day).difference(start).inHours / 24).round().clamp(1, 60);
final days = [for (var d = 0; d < count; d++) DateTime(start.year, start.month, start.day + d)];
final byDay = <DateTime, List<ScheduleEntry>>{};
for (final e in snap.entries) {
if (!_shows(e)) continue;
final l = e.airing.airingAt.toLocal();
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
}
return HScroll(
controller: _scroll,
builder: (c) => ListView.separated(
key: const ValueKey('schedule-board'),
controller: c,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
itemCount: days.length,
separatorBuilder: (_, _) => const SizedBox(width: SchedulePage.columnGap),
itemBuilder: (context, i) {
final d = days[i];
final items = byDay[d] ?? const <ScheduleEntry>[];
return SizedBox(
key: ValueKey('day-${d.year}-${d.month}-${d.day}'),
width: SchedulePage.columnWidth,
child: DayColumn(
day: d,
today: d == today,
past: d.isBefore(today),
compact: _compact,
cards: [for (final e in items) _card(e, now)],
),
);
},
),
);
}
Widget _card(ScheduleEntry e, DateTime now) {
final (action, hint) = _actionFor(e);
final busy = _busy.contains(action == CardAction.putUp ? 'play:${_key(e)}' : _key(e));
return AiringCard(
entry: e,
now: now,
compact: _compact,
followed: _followed(e),
action: action,
disabledHint: hint,
busy: busy,
onAction: () => _onAction(e, action),
onFollow: e.tracked && e.seriesId != null && _flow != null ? () => _toggleFollow(e) : null,
);
}
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: Column(
children: [
_header(context),
Expanded(child: _board(context)),
],
),
),
ClipRect(
child: AnimatedContainer(
duration: easeTime,
curve: ease,
width: _sheet ? SchedulePage.sheetWidth : 0,
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: SchedulePage.sheetWidth,
maxWidth: SchedulePage.sheetWidth,
child: _sheetBuilt && _me != null
? RequestsSheet(
key: const ValueKey('requests-sheet'),
owner: _owner,
requests: _requests,
error: _requestsError,
selfName: _me?.name ?? '',
busy: _reqBusy,
onRetry: (r) => _decide(r, approve: true),
onDecline: (r) => _decide(r, approve: false),
onClose: () => setState(() => _sheet = false),
)
: const SizedBox.shrink(),
),
),
),
],
);
}