All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
262 lines
8.3 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|