474 lines
14 KiB
Dart
474 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:worldhopper/config/constants.dart';
|
|
import 'package:worldhopper/models/opds_entry.dart';
|
|
import 'package:worldhopper/providers/reading_progress_provider.dart';
|
|
import 'package:worldhopper/providers/connectivity_provider.dart';
|
|
import 'package:worldhopper/models/reading_progress.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
/// Screen displaying publication details
|
|
class PublicationDetailScreen extends ConsumerWidget {
|
|
final OPDSEntry entry;
|
|
final String serverId;
|
|
final String? feedUrl;
|
|
|
|
const PublicationDetailScreen({
|
|
super.key,
|
|
required this.entry,
|
|
required this.serverId,
|
|
this.feedUrl,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final progressKey = ProgressKey(
|
|
publicationId: entry.id,
|
|
serverId: serverId,
|
|
);
|
|
final progressAsync = ref.watch(readingProgressProvider(progressKey));
|
|
|
|
return ColoredBox(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: Scaffold(
|
|
// extendBodyBehindAppBar: true,
|
|
appBar: _buildAppBar(context, ref),
|
|
body: ListView(
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
SizedBox(
|
|
height: 300,
|
|
width: double.infinity,
|
|
child: _buildCoverImage(context),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildMetadata(context),
|
|
_buildProgressCard(context, ref, progressAsync),
|
|
const SizedBox(height: 12),
|
|
_buildActionButtons(context, ref, progressAsync),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
PreferredSizeWidget _buildAppBar(BuildContext context, WidgetRef ref) {
|
|
// Watch connectivity state
|
|
final connectivityState = ref.watch(connectivityStateProvider);
|
|
|
|
// Check if offline (default to online while loading)
|
|
final isOffline = connectivityState.whenOrNull(
|
|
data: (isOnline) => !isOnline,
|
|
) ??
|
|
false;
|
|
|
|
return AppBar(
|
|
backgroundColor: Theme.of(context).colorScheme.surface.withOpacity(0.75),
|
|
title: Text(entry.title),
|
|
actions: isOffline
|
|
? [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Icon(
|
|
Icons.cloud_off,
|
|
color: Theme.of(context).colorScheme.error,
|
|
semanticLabel: 'Offline',
|
|
),
|
|
),
|
|
]
|
|
: null,
|
|
);
|
|
}
|
|
|
|
Widget _buildCoverImage(BuildContext context) {
|
|
final imageUrl = entry.coverUrl ?? entry.thumbnailUrl;
|
|
|
|
if (imageUrl == null) {
|
|
return Container(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.book,
|
|
size: 100,
|
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
|
),
|
|
);
|
|
}
|
|
|
|
return CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
// fit: BoxFit.cover,
|
|
placeholder: (context, url) => Container(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
child: const Center(child: CircularProgressIndicator()),
|
|
),
|
|
errorWidget: (context, url, error) => Container(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.broken_image,
|
|
size: 100,
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMetadata(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Wrap(
|
|
spacing: 12,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
if (entry.hasStreamLink)
|
|
Chip(
|
|
avatar: const Icon(Icons.auto_stories, size: 18),
|
|
label: Text('${entry.streamLink!.pageCount} pages'),
|
|
),
|
|
if (entry.categories.isNotEmpty)
|
|
...entry.categories.map(
|
|
(category) => Chip(
|
|
label: Text(category),
|
|
),
|
|
),
|
|
if (entry.published != null)
|
|
Chip(
|
|
avatar: const Icon(Icons.calendar_today, size: 18),
|
|
label: Text(_formatDate(entry.published!)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
bool get _hasOpdsProgress =>
|
|
entry.streamLink?.lastRead != null && entry.streamLink!.lastRead! > 0;
|
|
|
|
Widget _buildProgressCard(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
AsyncValue<ReadingProgress?> progressAsync,
|
|
) {
|
|
return progressAsync.when(
|
|
data: (progress) {
|
|
if (progress == null) {
|
|
// Fallback: show OPDS-PS server-side progress if available
|
|
if (_hasOpdsProgress) {
|
|
final lastRead = entry.streamLink!.lastRead!;
|
|
final pageCount = entry.streamLink!.pageCount;
|
|
final percentage = (lastRead + 1) / pageCount;
|
|
|
|
return _buildProgressCardContent(
|
|
context,
|
|
percentage: percentage,
|
|
label: 'Page ${lastRead + 1} of $pageCount',
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return _buildProgressCardContent(
|
|
context,
|
|
percentage: progress.progressPercentage,
|
|
label: entry.isEpub
|
|
? 'Last read: ${_formatDateTime(progress.lastReadAt)}'
|
|
: 'Page ${progress.currentPage + 1} of ${progress.totalPages}',
|
|
);
|
|
},
|
|
loading: () => const SizedBox.shrink(),
|
|
error: (_, __) => const SizedBox.shrink(),
|
|
);
|
|
}
|
|
|
|
Widget _buildProgressCardContent(
|
|
BuildContext context, {
|
|
required double percentage,
|
|
required String label,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Reading Progress',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
'${(percentage * 100).toInt()}%',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
LinearProgressIndicator(
|
|
value: percentage,
|
|
minHeight: 8,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButtons(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
AsyncValue<ReadingProgress?> progressAsync,
|
|
) {
|
|
// Check if entry can be read (either as stream or EPUB)
|
|
final canRead = entry.hasStreamLink || entry.isEpub;
|
|
|
|
if (!canRead) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Card(
|
|
color: Theme.of(context).colorScheme.errorContainer,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
'This publication cannot be read in the app',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return progressAsync.when(
|
|
data: (progress) {
|
|
final isStarted = progress != null && progress.isStarted;
|
|
final isCompleted = progress != null && progress.isCompleted;
|
|
|
|
// No local progress but OPDS-PS has server-side progress
|
|
final showOpdsResume = progress == null && _hasOpdsProgress;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (showOpdsResume) ...[
|
|
FilledButton.icon(
|
|
onPressed: () => _navigateToReader(
|
|
context,
|
|
ref,
|
|
entry.streamLink!.lastRead!,
|
|
),
|
|
icon: const Icon(Icons.play_arrow),
|
|
label: Text(
|
|
'Continue reading from page ${entry.streamLink!.lastRead! + 1}',
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _navigateToReader(context, ref, 0),
|
|
icon: const Icon(Icons.restart_alt),
|
|
label: const Text('Start reading'),
|
|
),
|
|
] else ...[
|
|
FilledButton.icon(
|
|
onPressed: () => _startReading(context, ref, progress),
|
|
icon: Icon(isStarted ? Icons.play_arrow : Icons.play_circle),
|
|
label: Text(
|
|
isStarted
|
|
? (isCompleted ? 'Read Again' : 'Continue Reading')
|
|
: 'Start Reading',
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
),
|
|
),
|
|
if (isStarted && !isCompleted) ...[
|
|
const SizedBox(height: 12),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _startFromBeginning(context, ref),
|
|
icon: const Icon(Icons.restart_alt),
|
|
label: const Text('Start from Beginning'),
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (_, __) => const SizedBox.shrink(),
|
|
);
|
|
}
|
|
|
|
void _navigateToReader(BuildContext context, WidgetRef ref, int initialPage) {
|
|
if (entry.isEpub) {
|
|
context.push(
|
|
AppConstants.routeEpubReader,
|
|
extra: {
|
|
'entry': entry,
|
|
'serverId': serverId,
|
|
'feedUrl': feedUrl,
|
|
},
|
|
);
|
|
} else if (entry.hasStreamLink) {
|
|
context.push(
|
|
AppConstants.routeReader,
|
|
extra: {
|
|
'entry': entry,
|
|
'serverId': serverId,
|
|
'initialPage': initialPage,
|
|
'feedUrl': feedUrl,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
void _startReading(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
ReadingProgress? progress,
|
|
) {
|
|
// Route to appropriate reader based on content type
|
|
if (entry.isEpub) {
|
|
// Navigate to EPUB reader (CosmosEpub handles position restoration)
|
|
context.push(
|
|
AppConstants.routeEpubReader,
|
|
extra: {
|
|
'entry': entry,
|
|
'serverId': serverId,
|
|
'feedUrl': feedUrl,
|
|
},
|
|
);
|
|
} else if (entry.hasStreamLink) {
|
|
// Navigate to image reader
|
|
final initialPage =
|
|
progress?.currentPage ?? entry.streamLink?.lastRead ?? 0;
|
|
|
|
context.push(
|
|
AppConstants.routeReader,
|
|
extra: {
|
|
'entry': entry,
|
|
'serverId': serverId,
|
|
'initialPage': initialPage,
|
|
'feedUrl': feedUrl,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
void _startFromBeginning(BuildContext context, WidgetRef ref) {
|
|
// Reset progress based on content type
|
|
if (entry.isEpub) {
|
|
final progress = ReadingProgress(
|
|
id: const Uuid().v4(),
|
|
publicationId: entry.id,
|
|
serverId: serverId,
|
|
currentPage: 0,
|
|
totalPages: 100, // Percentage-based for EPUB
|
|
epubLocation: null, // Clear location to start from beginning
|
|
lastReadAt: DateTime.now(),
|
|
seriesFeedUrl: feedUrl,
|
|
);
|
|
|
|
ref.read(readingProgressNotifierProvider.notifier).saveProgress(progress);
|
|
|
|
// Navigate to EPUB reader from beginning
|
|
context.push(
|
|
AppConstants.routeEpubReader,
|
|
extra: {
|
|
'entry': entry,
|
|
'serverId': serverId,
|
|
'feedUrl': feedUrl,
|
|
},
|
|
);
|
|
} else if (entry.hasStreamLink) {
|
|
final progress = ReadingProgress(
|
|
id: const Uuid().v4(),
|
|
publicationId: entry.id,
|
|
serverId: serverId,
|
|
currentPage: 0,
|
|
totalPages: entry.streamLink!.pageCount,
|
|
lastReadAt: DateTime.now(),
|
|
seriesFeedUrl: feedUrl,
|
|
);
|
|
|
|
ref.read(readingProgressNotifierProvider.notifier).saveProgress(progress);
|
|
|
|
// Navigate to image reader from beginning
|
|
context.push(
|
|
AppConstants.routeReader,
|
|
extra: {
|
|
'entry': entry,
|
|
'serverId': serverId,
|
|
'initialPage': 0,
|
|
'feedUrl': feedUrl,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
String _formatDate(DateTime date) {
|
|
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
String _formatDateTime(DateTime dateTime) {
|
|
final now = DateTime.now();
|
|
final difference = now.difference(dateTime);
|
|
|
|
if (difference.inDays == 0) {
|
|
if (difference.inHours == 0) {
|
|
if (difference.inMinutes == 0) {
|
|
return 'Just now';
|
|
}
|
|
return '${difference.inMinutes} min ago';
|
|
}
|
|
return '${difference.inHours} hour${difference.inHours > 1 ? 's' : ''} ago';
|
|
} else if (difference.inDays == 1) {
|
|
return 'Yesterday';
|
|
} else if (difference.inDays < 7) {
|
|
return '${difference.inDays} days ago';
|
|
} else {
|
|
return _formatDate(dateTime);
|
|
}
|
|
}
|
|
|
|
String _stripHtml(String html) {
|
|
return html.replaceAll(RegExp(r'<[^>]*>'), '').trim();
|
|
}
|
|
}
|