worldhopper/lib/widgets/downloaded_series_card.dart
Felipe M. d2497f8aed
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
feat: add background download service with error handling improvements
Add Android foreground service support for downloads to prevent OS
process killing during long operations. Fix multiple error handling
issues: foreground service leak on exceptions, polling stuck forever
on failure, double-counting chapters on retry, silent retry failures
without user feedback, and app crash if background service init fails.
Replace inaccurate failed chapter count heuristic with actual data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 11:44:37 +01:00

260 lines
8.2 KiB
Dart

import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worldhopper/l10n/app_localizations.dart';
import 'package:worldhopper/models/downloaded_series.dart';
import 'package:worldhopper/providers/download_provider.dart';
import 'package:worldhopper/services/series_download_service.dart'
as download_service;
/// Card for displaying a downloaded series in the library
class DownloadedSeriesCard extends ConsumerWidget {
final DownloadedSeries series;
final VoidCallback onTap;
final VoidCallback? onLongPress;
const DownloadedSeriesCard({
super.key,
required this.series,
required this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
// Watch live progress for this specific series
final progressAsync = ref.watch(activeDownloadProgressProvider);
final liveProgress = progressAsync.whenOrNull(
data: (progress) => progress.seriesId == series.id ? progress : null,
);
final isActivelyDownloading = liveProgress != null &&
liveProgress.status == DownloadStatus.downloading;
final chaptersAsync = ref.watch(downloadedChaptersProvider(series.id));
final failedCount = chaptersAsync.whenOrNull(
data: (chapters) =>
chapters.where((c) => c.status == DownloadStatus.failed).length,
) ??
0;
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Cover image with overlays
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
_buildCover(context),
_buildStatusBadge(context),
if (isActivelyDownloading)
_buildDownloadOverlay(context, liveProgress),
],
),
),
// Title and metadata
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
series.title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.auto_stories,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
l10n.downloadChaptersCount(
series.downloadedChaptersCount,
series.totalChapters,
),
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (failedCount > 0) ...[
const SizedBox(height: 2),
Row(
children: [
const Icon(
Icons.error_outline,
size: 14,
color: Colors.red,
),
const SizedBox(width: 4),
Expanded(
child: Text(
l10n.downloadFailedCount(failedCount),
style:
Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.red,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),
],
),
),
);
}
Widget _buildCover(BuildContext context) {
// Try local path first, then network URL
if (series.coverPath != null) {
return Image.file(
File(series.coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPlaceholder(context),
);
}
if (series.thumbnailUrl != null || series.coverUrl != null) {
return CachedNetworkImage(
imageUrl: series.thumbnailUrl ?? series.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => _buildPlaceholder(context),
errorWidget: (_, __, ___) => _buildPlaceholder(context),
);
}
return _buildPlaceholder(context);
}
Widget _buildPlaceholder(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.download_done,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
),
);
}
Widget _buildStatusBadge(BuildContext context) {
if (series.isComplete) {
return Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const Icon(Icons.download_done, size: 14, color: Colors.white),
),
);
}
if (series.isDownloading) {
return Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
),
);
}
return const SizedBox.shrink();
}
Widget _buildDownloadOverlay(
BuildContext context, download_service.DownloadProgress progress) {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black.withValues(alpha: 0.7),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (progress.chapterTitle != null)
Text(
progress.chapterTitle!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: progress.chapterProgress > 0
? progress.chapterProgress
: null,
minHeight: 3,
),
],
),
),
);
}
}