Adds real-time connectivity monitoring and visual feedback across the app. Offline icon appears in all app bars when disconnected, cached content shows download badges, and access control prevents browsing servers or opening uncached content without internet connection. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
443 lines
14 KiB
Dart
443 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;
|
|
|
|
const PublicationDetailScreen({
|
|
super.key,
|
|
required this.entry,
|
|
required this.serverId,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final progressKey = ProgressKey(
|
|
publicationId: entry.id,
|
|
serverId: serverId,
|
|
);
|
|
final progressAsync = ref.watch(readingProgressProvider(progressKey));
|
|
|
|
return Scaffold(
|
|
body: CustomScrollView(
|
|
slivers: [
|
|
_buildAppBar(context, ref),
|
|
SliverToBoxAdapter(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildHeader(context),
|
|
const SizedBox(height: 24),
|
|
_buildMetadata(context),
|
|
const SizedBox(height: 24),
|
|
if (entry.summary != null || entry.content != null) ...[
|
|
_buildDescription(context),
|
|
const SizedBox(height: 24),
|
|
],
|
|
_buildProgressCard(context, ref, progressAsync),
|
|
const SizedBox(height: 24),
|
|
_buildActionButtons(context, ref, progressAsync),
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _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 SliverAppBar(
|
|
expandedHeight: 300,
|
|
pinned: true,
|
|
actions: isOffline
|
|
? [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Icon(
|
|
Icons.cloud_off,
|
|
color: Theme.of(context).colorScheme.error,
|
|
semanticLabel: 'Offline',
|
|
),
|
|
),
|
|
]
|
|
: null,
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
background: _buildCoverImage(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 _buildHeader(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
entry.title,
|
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
if (entry.authors.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
entry.authors.join(', '),
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withOpacity(0.7),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMetadata(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Wrap(
|
|
spacing: 12,
|
|
runSpacing: 8,
|
|
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!)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDescription(BuildContext context) {
|
|
final description = entry.content ?? entry.summary ?? '';
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Description',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
_stripHtml(description),
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildProgressCard(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
AsyncValue<ReadingProgress?> progressAsync,
|
|
) {
|
|
return progressAsync.when(
|
|
data: (progress) {
|
|
if (progress == null) return const SizedBox.shrink();
|
|
|
|
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(
|
|
'${(progress.progressPercentage * 100).toInt()}%',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
LinearProgressIndicator(
|
|
value: progress.progressPercentage,
|
|
minHeight: 8,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
entry.isEpub
|
|
? 'Last read: ${_formatDateTime(progress.lastReadAt)}'
|
|
: 'Page ${progress.currentPage + 1} of ${progress.totalPages}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
loading: () => const SizedBox.shrink(),
|
|
error: (_, __) => const SizedBox.shrink(),
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
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 _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,
|
|
},
|
|
);
|
|
} else if (entry.hasStreamLink) {
|
|
// Navigate to image reader
|
|
final initialPage = progress?.currentPage ?? 0;
|
|
|
|
context.push(
|
|
AppConstants.routeReader,
|
|
extra: {
|
|
'entry': entry,
|
|
'serverId': serverId,
|
|
'initialPage': initialPage,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
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(),
|
|
);
|
|
|
|
ref.read(readingProgressNotifierProvider.notifier).saveProgress(progress);
|
|
|
|
// Navigate to EPUB reader from beginning
|
|
context.push(
|
|
AppConstants.routeEpubReader,
|
|
extra: {
|
|
'entry': entry,
|
|
'serverId': serverId,
|
|
},
|
|
);
|
|
} 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(),
|
|
);
|
|
|
|
ref.read(readingProgressNotifierProvider.notifier).saveProgress(progress);
|
|
|
|
// Navigate to image reader from beginning
|
|
context.push(
|
|
AppConstants.routeReader,
|
|
extra: {
|
|
'entry': entry,
|
|
'serverId': serverId,
|
|
'initialPage': 0,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|