worldhopper/lib/screens/publication/publication_detail_screen.dart
2026-04-17 11:45:41 +02:00

492 lines
15 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/l10n/app_localizations.dart';
import 'package:worldhopper/config/constants.dart';
import 'package:worldhopper/models/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 Entry 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(
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) {
final l10n = AppLocalizations.of(context);
// 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.withValues(alpha: 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: l10n.offlineSemanticLabel,
),
),
]
: 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.withValues(alpha: 0.3),
),
);
}
return CachedNetworkImage(
imageUrl: imageUrl,
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) {
final l10n = AppLocalizations.of(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(l10n.publicationPages(entry.streamLink!.pageCount)),
),
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 _hasServerProgress => entry.hasServerProgress;
Widget _buildProgressCard(
BuildContext context,
WidgetRef ref,
AsyncValue<ReadingProgress?> progressAsync,
) {
final l10n = AppLocalizations.of(context);
return progressAsync.when(
data: (progress) {
if (progress == null) {
if (_hasServerProgress) {
final percentage = entry.serverProgress!;
final label = entry.isEpub
? '${(percentage * 100).toInt()}%'
: l10n.publicationPageOfTotal(
entry.serverCurrentPage! + 1,
entry.serverTotalPages!,
);
return _buildProgressCardContent(
context,
percentage: percentage,
label: label,
);
}
return const SizedBox.shrink();
}
return _buildProgressCardContent(
context,
percentage: progress.progressPercentage,
label: entry.isEpub
? l10n.publicationLastRead(
_formatDateTime(context, progress.lastReadAt))
: l10n.publicationPageOfTotal(
progress.currentPage + 1, progress.totalPages),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
Widget _buildProgressCardContent(
BuildContext context, {
required double percentage,
required String label,
}) {
final l10n = AppLocalizations.of(context);
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(
l10n.publicationReadingProgress,
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,
) {
final l10n = AppLocalizations.of(context);
// 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(
l10n.publicationCannotRead,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
),
),
),
);
}
return progressAsync.when(
data: (progress) {
final hasServer = _hasServerProgress;
// Two-button "continue from page X" resume is only meaningful for
// image streams — EPUB page indices from the server do not map to
// CFI positions in the EPUB reader.
final showPageResume =
progress == null && hasServer && entry.isImageStream;
final isStarted = (progress?.isStarted ?? false) ||
(progress == null && hasServer && entry.isEpub);
final isCompleted = progress?.isCompleted ?? false;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showPageResume) ...[
FilledButton.icon(
onPressed: () => _navigateToReader(
context,
ref,
entry.streamLink!.lastRead!,
),
icon: const Icon(Icons.play_arrow),
label: Text(
l10n.publicationContinueFromPage(
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: Text(l10n.publicationStartReading),
),
] else ...[
FilledButton.icon(
onPressed: () => _startReading(context, ref, progress),
icon: Icon(isStarted ? Icons.play_arrow : Icons.play_circle),
label: Text(
isStarted
? (isCompleted
? l10n.publicationReadAgain
: l10n.publicationContinueReading)
: l10n.publicationStartReading,
),
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: Text(l10n.publicationStartFromBeginning),
),
],
],
],
),
);
},
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(BuildContext context, DateTime dateTime) {
final l10n = AppLocalizations.of(context);
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays == 0) {
if (difference.inHours == 0) {
if (difference.inMinutes == 0) {
return l10n.timeJustNow;
}
return l10n.timeMinAgo(difference.inMinutes);
}
return l10n.timeHoursAgoLong(difference.inHours);
} else if (difference.inDays == 1) {
return l10n.timeYesterday;
} else if (difference.inDays < 7) {
return l10n.timeDaysAgoLong(difference.inDays);
} else {
return _formatDate(dateTime);
}
}
}