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
338 lines
11 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|