All checks were successful
ci/woodpecker/tag/release Pipeline was successful
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>
260 lines
8.2 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|