worldhopper/lib/screens/publication/publication_detail_screen.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();
}
}