diff --git a/apps/desktop/lib/src/schedule/ui/requests_sheet.dart b/apps/desktop/lib/src/schedule/ui/requests_sheet.dart new file mode 100644 index 0000000..12ed13c --- /dev/null +++ b/apps/desktop/lib/src/schedule/ui/requests_sheet.dart @@ -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? requests; + final String? error; + + /// Our own name, for requests without a user name. + final String selfName; + + /// Requests with a call in flight. + final Set 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 []) _tile(context, r), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/schedule/ui/schedule_actions.dart b/apps/desktop/lib/src/schedule/ui/schedule_actions.dart new file mode 100644 index 0000000..72bcf6a --- /dev/null +++ b/apps/desktop/lib/src/schedule/ui/schedule_actions.dart @@ -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 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 hits})> findOnAniDb( + AnimeFlowClient client, + Airing a, +) async { + final hits = []; + 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 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 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); +} diff --git a/apps/desktop/lib/src/schedule/ui/schedule_card.dart b/apps/desktop/lib/src/schedule/ui/schedule_card.dart new file mode 100644 index 0000000..a8159a3 --- /dev/null +++ b/apps/desktop/lib/src/schedule/ui/schedule_card.dart @@ -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 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)], + ], + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/schedule/ui/schedule_page.dart b/apps/desktop/lib/src/schedule/ui/schedule_page.dart new file mode 100644 index 0000000..5ba3472 --- /dev/null +++ b/apps/desktop/lib/src/schedule/ui/schedule_page.dart @@ -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 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 createState() => _SchedulePageState(); +} + +class _SchedulePageState extends State { + 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 = {}; + + /// Requested in this run: "anilistId#episode" or "series:anilistId". + final _requested = {}; + + /// Cards with a call in flight (same keys as [_requested], "play:..."). + final _busy = {}; + + List? _requests; + String? _requestsError; + final _reqBusy = {}; + + 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 _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 _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 []) { + 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 _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 _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 _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 _chooseHit(Airing a, List hits) => showDialog( + 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 _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 _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( + 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 = >{}; + 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 []; + 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(), + ), + ), + ), + ], + ); +} diff --git a/apps/desktop/test/schedule_page_test.dart b/apps/desktop/test/schedule_page_test.dart new file mode 100644 index 0000000..711367e --- /dev/null +++ b/apps/desktop/test/schedule_page_test.dart @@ -0,0 +1,557 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/jellyfin/accounts.dart'; +import 'package:watch_together/src/schedule/anilist.dart'; +import 'package:watch_together/src/schedule/animeflow.dart'; +import 'package:watch_together/src/schedule/schedule_model.dart'; +import 'package:watch_together/src/schedule/ui/schedule_page.dart'; +import 'package:watch_together/src/screens/home/picked_media.dart'; + +import 'schedule_fake_server.dart'; + +/// Serves [snap] without any network; [animeFlow] is a real client (over +/// the fake server) for the page's actions. +class FakeRepo extends ScheduleRepository { + FakeRepo(this.snap, {super.animeFlow}) : super(aniList: AniListClient(cacheDir: () async => null)); + ScheduleSnapshot Function() snap; + final forced = []; + + @override + Future load({DateTime? from, DateTime? to, bool force = false}) async { + forced.add(force); + return snap(); + } +} + +class FakeJf implements JellyfinHttp { + final seen = []; + @override + Future send( + String method, + Uri uri, { + Map headers = const {}, + String? body, + }) async { + seen.add(uri); + if (uri.path.endsWith('/Shows/jf7/Episodes')) { + return JellyfinHttpResponse( + 200, + jsonEncode({ + 'Items': [ + { + 'Id': 'ep2', + 'Name': 'Two', + 'Type': 'Episode', + 'SeriesName': 'Avail Show', + 'IndexNumber': 2, + 'ParentIndexNumber': 1, + }, + { + 'Id': 'ep3', + 'Name': 'Three', + 'Type': 'Episode', + 'SeriesName': 'Avail Show', + 'IndexNumber': 3, + 'ParentIndexNumber': 1, + }, + ], + 'TotalRecordCount': 2, + }), + ); + } + return const JellyfinHttpResponse(404, ''); + } + + @override + void close() {} +} + +final now = DateTime.now(); +DateTime day(int offset, [int hour = 12]) => DateTime(now.year, now.month, now.day + offset, hour).toUtc(); + +Airing airing(int id, String title, int ep, DateTime at, {int popularity = 50000}) => Airing( + anilistId: id, + title: title, + titleRomaji: '$title romaji', + episode: ep, + airingAt: at, + popularity: popularity, +); + +List entries() => [ + ScheduleEntry( + airing: airing(111, 'Avail Show', 3, day(0, 9)), + status: ScheduleStatus.available, + tracked: true, + followed: true, + seriesId: 7, + season: 1, + jellyfinSeriesId: 'jf7', + ), + ScheduleEntry( + airing: airing(222, 'Missing Show', 5, day(0, 10)), + status: ScheduleStatus.missing, + tracked: true, + seriesId: 8, + ), + ScheduleEntry( + airing: airing(333, 'Failed Show', 2, day(0, 11)), + status: ScheduleStatus.failed, + reason: 'no seeders', + tracked: true, + seriesId: 9, + season: 1, + ), + ScheduleEntry(airing: airing(444, 'Popular Untracked', 1, day(1, 8)), status: ScheduleStatus.notTracked), + ScheduleEntry( + airing: airing(555, 'Niche Untracked', 1, day(1, 9), popularity: 100), + status: ScheduleStatus.notTracked, + ), + ScheduleEntry( + airing: airing(666, 'Downloading Show', 4, day(1, 10)), + status: ScheduleStatus.downloading, + progress: 0.42, + tracked: true, + seriesId: 10, + season: 1, + ), +]; + +const owner = AnimeFlowMe( + userId: 'u1', + name: 'Foxy', + scopes: {AnimeFlowScope.view, AnimeFlowScope.request, AnimeFlowScope.manage}, +); +const viewer = AnimeFlowMe(userId: 'u2', name: 'Viewer', scopes: {AnimeFlowScope.view}); + +ScheduleSnapshot snapshot({ + AnimeFlowMe? me = owner, + List? list, + String? aniListError, + DateTime? staleSince, + String? animeFlowError, +}) { + final w = ScheduleRepository.defaultWindow(now); + return ScheduleSnapshot( + from: w.from, + to: w.to, + entries: list ?? (aniListError != null ? const [] : entries()), + aniListError: aniListError, + aniListStaleSince: staleSince, + animeFlowError: animeFlowError, + me: me, + ); +} + +Map requestJson(int id, String status, {String kind = 'episode', String? user, String? reason}) => { + 'id': id, + 'kind': kind, + 'status': status, + 'title': 'Req $id', + 'series_id': 8, + 'season': 1, + 'episode': id, + 'user_name': ?user, + 'reason': ?reason, +}; + +void main() { + late FakeServer server; + late AnimeFlowClient flow; + var requestList = >[]; + Map? postError; + var searchHits = >[]; + + // The test binding answers every HttpClient with 400; the clients here + // talk to the localhost fake server for real. + setUpAll(() => HttpOverrides.global = null); + + setUp(() async { + requestList = []; + postError = null; + searchHits = []; + server = await FakeServer.start((req, res) async { + final p = req.uri.path.replaceFirst('/api/app/v1', ''); + switch ((req.method, p)) { + case ('GET', '/requests'): + json(res, {'items': requestList}); + case ('POST', '/requests'): + if (postError != null) return json(res, postError, status: 409); + json(res, {'id': 50, 'kind': 'episode', 'status': 'approved', 'title': 'x'}, status: 201); + case ('GET', '/series/8'): + json(res, { + 'series_id': 8, + 'title': 'Missing Show', + 'episodes': [ + {'season': 2, 'episode': 1, 'absolute': 5, 'anilist_id': 222, 'status': 'missing'}, + ], + }); + case ('GET', '/series/7'): + json(res, { + 'series_id': 7, + 'title': 'Avail Show', + 'episodes': [ + {'season': 1, 'episode': 3, 'anilist_id': 111, 'status': 'available'}, + ], + }); + case ('GET', '/search'): + json(res, {'items': searchHits}); + case ('PUT' || 'DELETE', '/series/7/follow'): + res.statusCode = 204; + case ('POST', '/requests/1/approve' || '/requests/2/reject'): + json(res, {}); + default: + json(res, {'detail': 'nope', 'code': 'not_found'}, status: 404); + } + }); + flow = AnimeFlowClient(baseUrl: '${server.url}/api/app/v1', token: 't'); + }); + + tearDown(() async { + flow.close(); + await server.close(); + }); + + final toasts = []; + final putUp = []; + + /// Lets real socket IO (the fake server) complete, pumping frames. + Future io(WidgetTester tester, {int rounds = 12}) async { + for (var i = 0; i < rounds; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 15))); + await tester.pump(); + } + } + + Future pumpPage( + WidgetTester tester, { + Size size = const Size(2560, 1080), + ScheduleSnapshot Function()? snap, + bool online = true, + JellyfinAccounts? accounts, + }) async { + toasts.clear(); + putUp.clear(); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final repo = FakeRepo(snap ?? snapshot, animeFlow: flow); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark(), + home: Scaffold( + body: SchedulePage( + online: online, + repository: repo, + accounts: accounts, + onToast: toasts.add, + onPutUp: (m) async { + putUp.add(m); + return null; + }, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + await io(tester, rounds: 4); + return repo; + } + + /// Unmounts the page and lets the HttpClient's idle keep-alive timers + /// (made in the fake clock) run out. + Future done(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 20)); + } + + Finder card(int id, int ep) => find.byKey(ValueKey('card-$id-$ep')); + Finder action(int id, int ep) => find.byKey(ValueKey('action-$id-$ep')); + T widgetOf(WidgetTester tester, Finder f) => + tester.widget(find.descendant(of: f, matching: find.byWidgetPredicate((w) => w is T)).first); + + testWidgets('renders the columns, opens on today, shows tracked statuses', (tester) async { + await pumpPage(tester); + expect(find.text('Today'), findsWidgets); + final today = DateTime(now.year, now.month, now.day); + expect(find.byKey(ValueKey('day-${today.year}-${today.month}-${today.day}')), findsOneWidget); + // Scrolled to today: last week's Monday is off screen. + final w = ScheduleRepository.defaultWindow(now).from; + expect(find.byKey(ValueKey('day-${w.year}-${w.month}-${w.day}')), findsNothing); + expect(card(111, 3), findsOneWidget); + expect(find.text('on Jellyfin'), findsOneWidget); + expect(find.text('missing'), findsOneWidget); + expect(find.text('failed · no seeders'), findsOneWidget); + expect(find.text('downloading 42%'), findsOneWidget); + expect(find.text('Put it up'), findsOneWidget); + // On Jellyfin: untracked entries are hidden. + expect(card(444, 1), findsNothing); + expect(find.byKey(const ValueKey('schedule-animeflow')), findsOneWidget); + expect(find.textContaining('AnimeFlow · Foxy · view, request, manage'), findsOneWidget); + expect(find.text('Requests'), findsOneWidget); + await done(tester); + }); + + testWidgets('filters: following, all airing, niche too, compact', (tester) async { + await pumpPage(tester); + await tester.tap(find.text('Following')); + await tester.pump(); + expect(card(111, 3), findsOneWidget); + expect(card(222, 5), findsNothing); + + await tester.tap(find.text('All airing')); + await tester.pump(); + expect(card(444, 1), findsOneWidget); + expect(card(555, 1), findsNothing); + expect(find.text('Request series'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('schedule-niche'))); + await tester.pump(); + expect(card(555, 1), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('schedule-compact'))); + await tester.pump(); + expect(tester.getSize(card(111, 3)).height, lessThan(80)); + expect(find.text('Put it up'), findsNothing); + expect(tester.takeException(), isNull); + await done(tester); + }); + + testWidgets('request on a tracked episode sends AnimeFlow numbering', (tester) async { + await pumpPage(tester); + await tester.tap(action(222, 5)); + await io(tester); + final post = server.seen.where((r) => r.method == 'POST' && r.uri.path.endsWith('/requests')).single; + expect(jsonDecode(post.body), {'series_id': 8, 'season': 2, 'episode': 1}); + expect(toasts, ['Requested Missing Show, episode 5']); + expect(find.descendant(of: card(222, 5), matching: find.text('Requested')), findsOneWidget); + await done(tester); + }); + + testWidgets('request errors become friendly text', (tester) async { + await pumpPage(tester); + postError = {'detail': 'dup', 'code': 'already_requested'}; + await tester.tap(action(333, 2)); + await io(tester); + expect(toasts, ['Already requested']); + expect(find.descendant(of: card(333, 2), matching: find.text('Requested')), findsOneWidget); + await done(tester); + }); + + testWidgets('request series finds the AniDB id by AniList id', (tester) async { + await pumpPage(tester); + searchHits = [ + {'anidb_id': 1, 'title': 'Other', 'anilist_id': 999}, + {'anidb_id': 4444, 'title': 'Popular Untracked', 'anilist_id': 444}, + ]; + await tester.tap(find.text('All airing')); + await tester.pump(); + await tester.tap(action(444, 1)); + await io(tester); + final search = server.seen.firstWhere((r) => r.uri.path.endsWith('/search')); + expect(search.uri.queryParameters['q'], 'Popular Untracked'); + final post = server.seen.where((r) => r.method == 'POST' && r.uri.path.endsWith('/requests')).single; + expect(jsonDecode(post.body), {'anidb_id': 4444}); + expect(toasts, ['Requested Popular Untracked']); + await done(tester); + }); + + testWidgets('request series without a match lets the user choose', (tester) async { + await pumpPage(tester); + searchHits = [ + {'anidb_id': 77, 'title': 'Close Enough'}, + ]; + await tester.tap(find.text('All airing')); + await tester.pump(); + await tester.tap(action(444, 1)); + await io(tester); + expect(find.byKey(const ValueKey('anidb-choose')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('anidb-77'))); + await io(tester); + final post = server.seen.where((r) => r.method == 'POST' && r.uri.path.endsWith('/requests')).single; + expect(jsonDecode(post.body), {'anidb_id': 77}); + + // Nothing at all: "not found on AniDB", nothing sent. + searchHits = []; + await tester.tap(find.byKey(const ValueKey('schedule-niche'))); + await tester.pump(); + await tester.tap(action(555, 1)); + await io(tester); + expect(find.text('Not found on AniDB'), findsOneWidget); + await tester.tap(find.text('OK')); + await io(tester, rounds: 3); + expect(server.seen.where((r) => r.method == 'POST' && r.uri.path.endsWith('/requests')), hasLength(1)); + await done(tester); + }); + + testWidgets('without the request scope the button is disabled with a hint', (tester) async { + await pumpPage(tester, snap: () => snapshot(me: viewer)); + final b = widgetOf(tester, action(222, 5)); + expect(b.onPressed, isNull); + expect(find.byWidgetPredicate((w) => w is Tooltip && (w.message ?? '').contains('owner')), findsWidgets); + expect(find.text('My requests'), findsOneWidget); + await done(tester); + }); + + testWidgets('follow toggles through AnimeFlow', (tester) async { + final repo = await pumpPage(tester); + await tester.tap(find.byKey(const ValueKey('follow-111-3'))); + await io(tester); + expect(server.seen.where((r) => r.method == 'DELETE' && r.uri.path.endsWith('/series/7/follow')), hasLength(1)); + expect(repo.forced.length, greaterThanOrEqualTo(2)); // reloaded + await done(tester); + }); + + testWidgets('put it up resolves the Jellyfin episode', (tester) async { + final jf = FakeJf(); + final accounts = JellyfinAccounts( + store: MemoryJellyfinStore({ + 'accounts': jsonEncode([ + { + 'serverId': 'srv', + 'serverName': 'Home', + 'urls': ['http://jf.test'], + 'userId': 'uid', + 'userName': 'me', + 'token': 'tok', + }, + ]), + }), + httpFactory: () => jf, + deviceName: 'Test', + ); + await pumpPage(tester, accounts: accounts); + await tester.tap(find.text('Put it up')); + await io(tester); + expect(putUp, hasLength(1)); + expect(putUp.single.itemId, 'ep3'); + expect(putUp.single.serverId, 'srv'); + expect(toasts, isEmpty); + await done(tester); + }); + + testWidgets('offline: Play is disabled', (tester) async { + await pumpPage(tester, online: false); + expect(find.text('Put it up'), findsNothing); + final b = widgetOf(tester, action(111, 3)); + expect(b.onPressed, isNull); + expect(find.text('Play'), findsOneWidget); + await done(tester); + }); + + testWidgets('owner sheet: retry approves, decline rejects', (tester) async { + requestList = [ + requestJson(1, 'failed', user: 'Alice', reason: 'no release'), + requestJson(2, 'searching', user: 'Bob'), + requestJson(3, 'done', user: 'Carol'), + ]; + await pumpPage(tester); + await tester.tap(find.byKey(const ValueKey('schedule-requests'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('no release'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + expect(find.byKey(const ValueKey('retry-1')), findsOneWidget); + expect(find.byKey(const ValueKey('decline-1')), findsNothing); + expect(find.byKey(const ValueKey('decline-2')), findsOneWidget); + expect(find.byKey(const ValueKey('retry-3')), findsNothing); + expect(find.byKey(const ValueKey('decline-3')), findsNothing); + // The owner sees everyone's. + expect(server.seen.any((r) => r.uri.path.endsWith('/requests') && r.uri.queryParameters['scope'] == 'all'), isTrue); + + await tester.tap(find.byKey(const ValueKey('retry-1'))); + await io(tester); + await tester.tap(find.byKey(const ValueKey('decline-2'))); + await io(tester); + expect(server.seen.where((r) => r.method == 'POST' && r.uri.path.endsWith('/requests/1/approve')), hasLength(1)); + expect(server.seen.where((r) => r.method == 'POST' && r.uri.path.endsWith('/requests/2/reject')), hasLength(1)); + expect(toasts, ['Retrying Req 1', 'Declined Req 2']); + await done(tester); + }); + + testWidgets('a viewer\'s sheet has no owner buttons', (tester) async { + requestList = [requestJson(1, 'failed')]; + await pumpPage(tester, snap: () => snapshot(me: viewer)); + await tester.tap(find.byKey(const ValueKey('schedule-requests'))); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('Req 1 · S1E1'), findsOneWidget); + expect(find.text('Viewer'), findsOneWidget); + expect(find.byKey(const ValueKey('retry-1')), findsNothing); + expect(server.seen.any((r) => r.uri.queryParameters['scope'] == 'all'), isFalse); + await done(tester); + }); + + testWidgets('stale AniList data shows its time', (tester) async { + final at = DateTime(now.year, now.month, now.day, 7, 5); + await pumpPage(tester, snap: () => snapshot(staleSince: at)); + expect(find.text('AniList busy · showing data from 07:05'), findsOneWidget); + await done(tester); + }); + + testWidgets('AniList failure shows the error and retries', (tester) async { + var fail = true; + final repo = await pumpPage( + tester, + snap: () => fail ? snapshot(aniListError: 'AniList unreachable: boom') : snapshot(), + ); + expect(find.byKey(const ValueKey('schedule-error')), findsOneWidget); + expect(find.textContaining('AniList unreachable: boom'), findsOneWidget); + fail = false; + await tester.tap(find.text('Try again')); + await io(tester, rounds: 3); + expect(repo.forced.last, isTrue); + expect(find.byKey(const ValueKey('schedule-error')), findsNothing); + expect(card(111, 3), findsOneWidget); + await done(tester); + }); + + testWidgets('AnimeFlow failure: error pill, all airing, no requests', (tester) async { + await pumpPage( + tester, + snap: () => snapshot( + me: null, + animeFlowError: 'Link the Jellyfin server to see AnimeFlow statuses', + list: [for (final e in entries()) untrackedEntry(e.airing, now)], + ), + ); + expect(find.text('Link the Jellyfin server to see AnimeFlow statuses'), findsOneWidget); + expect(card(444, 1), findsOneWidget); // switched to All airing + expect(find.text('Request series'), findsNothing); + final b = tester.widget(find.byKey(const ValueKey('schedule-requests'))); + expect(b.onPressed, isNull); + await done(tester); + }); + + testWidgets('refreshes every minute and on the button', (tester) async { + final repo = await pumpPage(tester); + expect(repo.forced, [false]); + await tester.pump(SchedulePage.refreshEvery); + await io(tester, rounds: 3); + expect(repo.forced, [false, false]); + await tester.tap(find.byKey(const ValueKey('schedule-refresh'))); + await io(tester, rounds: 3); + expect(repo.forced, [false, false, true]); + await done(tester); + }); + + for (final size in const [Size(2560, 1080), Size(1100, 700), Size(900, 600)]) { + testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}', (tester) async { + requestList = [requestJson(1, 'failed', user: 'Alice', reason: 'a long reason ' * 6)]; + await pumpPage(tester, size: size); + expect(tester.takeException(), isNull); + await tester.tap(find.text('All airing')); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('schedule-requests'))); + await tester.pump(const Duration(milliseconds: 400)); + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const ValueKey('schedule-compact'))); + await tester.pump(); + expect(tester.takeException(), isNull); + await done(tester); + }); + } +}