492 lines
15 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|