worldhopper/lib/screens/publication/publication_detail_screen.dart
Felipe M. 64eee6b81b
feat: implement internet connectivity tracking with offline indicators
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>
2026-01-28 20:13:21 +01:00

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();
}
}