worldhopper/lib/widgets/next_in_series_overlay.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

338 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worldhopper/l10n/app_localizations.dart';
import 'package:worldhopper/config/constants.dart';
import 'package:worldhopper/models/downloaded_chapter.dart';
import 'package:worldhopper/models/downloaded_series.dart';
import 'package:worldhopper/models/entry.dart';
import 'package:worldhopper/providers/connectivity_provider.dart';
import 'package:worldhopper/providers/download_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 Entry entry;
final bool isVisible;
final bool einkMode;
final bool isDownloadedContent;
final Future<void> Function()? onBeforeNavigate;
const NextInSeriesOverlay({
super.key,
required this.serverId,
this.feedUrl,
required this.entry,
required this.isVisible,
this.einkMode = false,
this.isDownloadedContent = false,
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();
}
// For downloaded content, resolve next chapter from local DB
if (widget.isDownloadedContent) {
final key = ChapterDownloadKey(
serverId: widget.serverId,
entryId: widget.entry.id,
);
final nextChapterAsync = ref.watch(nextDownloadedChapterProvider(key));
return nextChapterAsync.when(
data: (nextChapter) {
if (nextChapter != null) {
return _buildNextDownloadedOverlay(context, nextChapter);
}
return _buildFinishedOverlay(context);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => _buildFinishedOverlay(context),
);
}
// If we have a feed URL, try to resolve the next entry from the server
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, Entry 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: AppLocalizations.of(context).nextInSeriesNext,
title: nextEntry.title,
trailingIcon: Icons.arrow_forward,
);
}
Widget _buildNextDownloadedOverlay(
BuildContext context, DownloadedChapter nextChapter) {
return _buildDismissibleBar(
key: 'next-downloaded-${nextChapter.id}',
color: Theme.of(context).colorScheme.primaryContainer,
onColor: Theme.of(context).colorScheme.onPrimaryContainer,
onTap: () => _navigateToNextDownloaded(context, nextChapter),
icon: Icons.skip_next,
label: AppLocalizations.of(context).nextInSeriesNext,
title: nextChapter.title,
trailingIcon: Icons.arrow_forward,
);
}
// ---------------------------------------------------------------------------
// "Finished reading" bar
// ---------------------------------------------------------------------------
Widget _buildFinishedOverlay(BuildContext context) {
final l10n = AppLocalizations.of(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: l10n.nextInSeriesFinished,
title: isOnline
? l10n.nextInSeriesBackToDetails
: l10n.nextInSeriesBackToLibrary,
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,
}) {
final barContent = 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.withValues(alpha: 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.withValues(alpha: 0.7),
),
onPressed: () {
setState(() {
_dismissed = true;
});
},
),
),
],
),
),
),
);
return Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: widget.einkMode
? barContent
: Dismissible(
key: ValueKey(key),
direction: DismissDirection.down,
onDismissed: (_) {
setState(() {
_dismissed = true;
});
},
child: barContent,
),
);
}
// ---------------------------------------------------------------------------
// Navigation helpers
// ---------------------------------------------------------------------------
void _navigateToNext(BuildContext context, Entry 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 _navigateToNextDownloaded(
BuildContext context, DownloadedChapter chapter) async {
await widget.onBeforeNavigate?.call();
if (!context.mounted) return;
final entry = chapter.toEntry();
if (chapter.contentType == ChapterContentType.epub) {
context.pushReplacement(
AppConstants.routeEpubReader,
extra: {
'entry': entry,
'serverId': chapter.serverId,
'localFilePath': chapter.filePath,
},
);
} else {
context.pushReplacement(
AppConstants.routeReader,
extra: {
'entry': entry,
'serverId': chapter.serverId,
'initialPage': 0,
'localContentPath': chapter.filePath,
},
);
}
}
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);
}
}
}