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:
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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 = <bool>[];
|
||||
|
||||
@override
|
||||
Future<ScheduleSnapshot> load({DateTime? from, DateTime? to, bool force = false}) async {
|
||||
forced.add(force);
|
||||
return snap();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeJf implements JellyfinHttp {
|
||||
final seen = <Uri>[];
|
||||
@override
|
||||
Future<JellyfinHttpResponse> send(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Map<String, String> 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<ScheduleEntry> 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<ScheduleEntry>? 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<String, Object?> 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<String, Object?>>[];
|
||||
Map<String, Object?>? postError;
|
||||
var searchHits = <Map<String, Object?>>[];
|
||||
|
||||
// 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 = <String>[];
|
||||
final putUp = <JellyfinMedia>[];
|
||||
|
||||
/// Lets real socket IO (the fake server) complete, pumping frames.
|
||||
Future<void> io(WidgetTester tester, {int rounds = 12}) async {
|
||||
for (var i = 0; i < rounds; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 15)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<FakeRepo> 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<void> 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<T extends Widget>(WidgetTester tester, Finder f) =>
|
||||
tester.widget<T>(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<OutlinedButton>(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<ButtonStyleButton>(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<ButtonStyleButton>(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user