worldhopper/lib/widgets/next_in_series_overlay.dart
Felipe M. b3466fd359
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
fix: remove completed chapters from reccently read automatically
2026-02-11 10:17:19 +01:00

262 lines
8.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worldhopper/config/constants.dart';
import 'package:worldhopper/models/opds_entry.dart';
import 'package:worldhopper/providers/connectivity_provider.dart';
import 'package:worldhopper/providers/series_navigation_provider.dart';
/// Overlay widget shown when reading is complete.
///
/// - If a next entry exists in the series feed, shows "Next: [title]".
/// - If there is no next entry (last in series or no feed context),
/// shows "Finished" with a link back to the OPDS entry (online)
/// or the library (offline).
///
/// Can be dismissed by swiping down or tapping the close button.
class NextInSeriesOverlay extends ConsumerStatefulWidget {
final String serverId;
final String? feedUrl;
final OPDSEntry entry;
final bool isVisible;
final Future<void> Function()? onBeforeNavigate;
const NextInSeriesOverlay({
super.key,
required this.serverId,
this.feedUrl,
required this.entry,
required this.isVisible,
this.onBeforeNavigate,
});
@override
ConsumerState<NextInSeriesOverlay> createState() =>
_NextInSeriesOverlayState();
}
class _NextInSeriesOverlayState extends ConsumerState<NextInSeriesOverlay> {
bool _dismissed = false;
@override
Widget build(BuildContext context) {
if (_dismissed || !widget.isVisible) {
return const SizedBox.shrink();
}
// If we have a feed URL, try to resolve the next entry
if (widget.feedUrl != null) {
final request = NextInSeriesRequest(
serverId: widget.serverId,
feedUrl: widget.feedUrl!,
currentEntryId: widget.entry.id,
);
final nextEntryAsync = ref.watch(nextInSeriesProvider(request));
return nextEntryAsync.when(
data: (nextEntry) {
if (nextEntry != null) {
return _buildNextOverlay(context, nextEntry);
}
return _buildFinishedOverlay(context);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => _buildFinishedOverlay(context),
);
}
// No feed context — just show "Finished"
return _buildFinishedOverlay(context);
}
// ---------------------------------------------------------------------------
// "Next in series" bar
// ---------------------------------------------------------------------------
Widget _buildNextOverlay(BuildContext context, OPDSEntry nextEntry) {
return _buildDismissibleBar(
key: 'next-in-series-${nextEntry.id}',
color: Theme.of(context).colorScheme.primaryContainer,
onColor: Theme.of(context).colorScheme.onPrimaryContainer,
onTap: () => _navigateToNext(context, nextEntry),
icon: Icons.skip_next,
label: 'Next',
title: nextEntry.title,
trailingIcon: Icons.arrow_forward,
);
}
// ---------------------------------------------------------------------------
// "Finished reading" bar
// ---------------------------------------------------------------------------
Widget _buildFinishedOverlay(BuildContext context) {
final connectivityState = ref.watch(connectivityStateProvider);
final isOnline = connectivityState.whenOrNull(
data: (online) => online,
) ??
true;
return _buildDismissibleBar(
key: 'finished-reading-${widget.entry.id}',
color: Theme.of(context).colorScheme.secondaryContainer,
onColor: Theme.of(context).colorScheme.onSecondaryContainer,
onTap: () => _navigateBack(context, isOnline),
icon: Icons.check_circle_outline,
label: 'Finished',
title: isOnline ? 'Back to details' : 'Back to library',
trailingIcon: Icons.arrow_back,
);
}
// ---------------------------------------------------------------------------
// Shared bar builder
// ---------------------------------------------------------------------------
Widget _buildDismissibleBar({
required String key,
required Color color,
required Color onColor,
required VoidCallback onTap,
required IconData icon,
required String label,
required String title,
required IconData trailingIcon,
}) {
return Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Dismissible(
key: ValueKey(key),
direction: DismissDirection.down,
onDismissed: (_) {
setState(() {
_dismissed = true;
});
},
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: color,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(
left: 16, top: 12, bottom: 12, right: 4),
child: Row(
children: [
Icon(icon, color: onColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style:
Theme.of(context).textTheme.labelSmall?.copyWith(
color: onColor.withOpacity(0.7),
),
),
Text(
title,
style:
Theme.of(context).textTheme.titleSmall?.copyWith(
color: onColor,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 4),
Icon(trailingIcon, color: onColor),
SizedBox(
width: 36,
height: 36,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 20,
icon: Icon(
Icons.close,
color: onColor.withOpacity(0.7),
),
onPressed: () {
setState(() {
_dismissed = true;
});
},
),
),
],
),
),
),
),
),
);
}
// ---------------------------------------------------------------------------
// Navigation helpers
// ---------------------------------------------------------------------------
void _navigateToNext(BuildContext context, OPDSEntry nextEntry) async {
await widget.onBeforeNavigate?.call();
if (!context.mounted) return;
if (nextEntry.isEpub) {
context.pushReplacement(
AppConstants.routeEpubReader,
extra: {
'entry': nextEntry,
'serverId': widget.serverId,
'feedUrl': widget.feedUrl,
},
);
} else if (nextEntry.hasStreamLink) {
context.pushReplacement(
AppConstants.routeReader,
extra: {
'entry': nextEntry,
'serverId': widget.serverId,
'initialPage': 0,
'feedUrl': widget.feedUrl,
},
);
} else {
context.pushReplacement(
AppConstants.routePublicationDetail,
extra: {
'entry': nextEntry,
'serverId': widget.serverId,
'feedUrl': widget.feedUrl,
},
);
}
}
void _navigateBack(BuildContext context, bool isOnline) async {
await widget.onBeforeNavigate?.call();
if (!context.mounted) return;
if (isOnline) {
// Go to the publication detail screen for the current entry
context.pushReplacement(
AppConstants.routePublicationDetail,
extra: {
'entry': widget.entry,
'serverId': widget.serverId,
'feedUrl': widget.feedUrl,
},
);
} else {
// Go to the library
context.go(AppConstants.routeLibrary);
}
}
}