feat: Implement upcoming manga calendar feature

- Added UpcomingUIModel for managing upcoming manga list items with headers and items.
- Introduced CalendarDay widget to display individual days with event indicators.
- Created CalendarHeader widget for navigating between months.
- Developed CalendarIndicator for visualizing events on specific days.
- Implemented UpcomingCalendar to manage the calendar view and event loading.
- Added UpcomingItem widget for displaying individual upcoming manga with cover images.
- Introduced FetchInterval utility to calculate fetch intervals based on chapter upload dates.
- Refactored updateMangaDetail to utilize FetchInterval for smart update days.
- Enhanced MedianExtension to ensure correct median calculation.
- Removed unused imports and commented-out code for cleaner implementation.
This commit is contained in:
Moustapha Kodjo Amadou 2026-03-05 12:05:29 +01:00
parent 7944c6f055
commit 95a55d5f81
11 changed files with 960 additions and 376 deletions

View file

@ -1,24 +1,21 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:isar_community/isar.dart'; import 'package:isar_community/isar.dart';
import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/modules/calendar/models/upcoming_ui_model.dart';
import 'package:mangayomi/modules/calendar/providers/calendar_provider.dart'; import 'package:mangayomi/modules/calendar/providers/calendar_provider.dart';
import 'package:mangayomi/modules/calendar/widgets/upcoming_calendar.dart';
import 'package:mangayomi/modules/calendar/widgets/upcoming_item.dart'
as widgets;
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart';
import 'package:mangayomi/modules/widgets/custom_sliver_grouped_list_view.dart';
import 'package:mangayomi/modules/widgets/progress_center.dart'; import 'package:mangayomi/modules/widgets/progress_center.dart';
import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/utils/constant.dart';
import 'package:mangayomi/utils/date.dart'; import 'package:mangayomi/utils/date.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/fetch_interval.dart';
import 'package:mangayomi/utils/headers.dart';
import 'package:mangayomi/utils/item_type_filters.dart'; import 'package:mangayomi/utils/item_type_filters.dart';
import 'package:mangayomi/utils/item_type_localization.dart'; import 'package:mangayomi/utils/item_type_localization.dart';
import 'package:table_calendar/table_calendar.dart';
class CalendarScreen extends ConsumerStatefulWidget { class CalendarScreen extends ConsumerStatefulWidget {
final ItemType? itemType; final ItemType? itemType;
@ -29,18 +26,15 @@ class CalendarScreen extends ConsumerStatefulWidget {
} }
class _CalendarScreenState extends ConsumerState<CalendarScreen> { class _CalendarScreenState extends ConsumerState<CalendarScreen> {
late final ValueNotifier<List<Manga>> _selectedEntries;
CalendarFormat _calendarFormat = CalendarFormat.month;
RangeSelectionMode _rangeSelectionMode = RangeSelectionMode.toggledOff;
final firstDay = DateTime.now();
final lastDay = DateTime.now().add(const Duration(days: 1000));
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay;
DateTime? _rangeStart;
DateTime? _rangeEnd;
late ItemType? itemType; late ItemType? itemType;
late List<ItemType> _visibleTypes; late List<ItemType> _visibleTypes;
/// Currently displayed year/month on the calendar.
late DateTime _selectedYearMonth;
/// Header GlobalKeys so clicking a calendar day scrolls to that date.
final Map<DateTime, GlobalKey> _headerKeys = {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -51,122 +45,54 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
} else { } else {
itemType = _visibleTypes.isNotEmpty ? _visibleTypes.first : null; itemType = _visibleTypes.isNotEmpty ? _visibleTypes.first : null;
} }
_selectedDay = _focusedDay; final now = DateTime.now();
_selectedEntries = ValueNotifier([]); _selectedYearMonth = DateTime(now.year, now.month);
}
@override
void dispose() {
_selectedEntries.dispose();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final locale = ref.watch(l10nLocaleStateProvider);
final data = ref.watch(getCalendarStreamProvider(itemType: itemType)); final data = ref.watch(getCalendarStreamProvider(itemType: itemType));
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(l10n.calendar)), appBar: AppBar(
title: Text(l10n.calendar),
actions: [
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: l10n.calendar_info,
onPressed: () {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Text(l10n.calendar_info),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(MaterialLocalizations.of(ctx).okButtonLabel),
),
],
),
);
},
),
],
),
body: data.when( body: data.when(
data: (data) { data: (mangaList) {
if (_selectedDay != null) { final upcomingData = _buildUpcomingData(mangaList);
_selectedEntries.value = _getEntriesForDay(_selectedDay!, data); final items = upcomingData.items;
final events = upcomingData.events;
// Build header keys
_headerKeys.clear();
for (final date in upcomingData.headerIndexes.keys) {
_headerKeys[date] = GlobalKey();
} }
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 15), return _buildContent(items, events);
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
_buildWarningTile(context),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Row(
children: [
Expanded(
child: SegmentedButton(
emptySelectionAllowed: true,
showSelectedIcon: false,
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
),
segments: _visibleTypes.map((type) {
return ButtonSegment(
value: type.index,
label: Padding(
padding: const EdgeInsets.all(12),
child: Text(type.localized(l10n)),
),
);
}).toList(),
selected: {itemType?.index},
onSelectionChanged: (newSelection) {
if (newSelection.isNotEmpty &&
newSelection.first != null) {
setState(() {
itemType =
ItemType.values[newSelection.first!];
});
}
},
),
),
],
),
),
_buildCalendar(data, locale),
const SizedBox(height: 15),
],
),
),
ValueListenableBuilder<List<Manga>>(
valueListenable: _selectedEntries,
builder: (context, value, _) {
return CustomSliverGroupedListView<Manga, String>(
elements: value,
groupBy: (element) {
return dateFormat(
_selectedDay?.millisecondsSinceEpoch.toString() ??
DateTime.now()
.add(Duration(days: element.smartUpdateDays!))
.millisecondsSinceEpoch
.toString(),
context: context,
ref: ref,
forHistoryValue: true,
useRelativeTimesTamps: false,
);
},
groupSeparatorBuilder: (String groupByValue) => Padding(
padding: const EdgeInsets.only(bottom: 8, left: 12),
child: Row(
children: [
Text(
"${dateFormat(null, context: context, stringDate: groupByValue, ref: ref, useRelativeTimesTamps: true, showInDaysFuture: true)} - ${dateFormat(null, context: context, stringDate: groupByValue, ref: ref, useRelativeTimesTamps: false)}",
),
],
),
),
itemBuilder: (context, element) {
return CalendarListTileWidget(
manga: element,
selectedDay: _selectedDay,
);
},
order: GroupedListOrder.ASC,
);
},
),
SliverToBoxAdapter(child: const SizedBox(height: 15)),
],
),
);
}, },
error: (Object error, StackTrace stackTrace) { error: (error, stackTrace) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
@ -174,254 +100,253 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
), ),
); );
}, },
loading: () { loading: () => const ProgressCenter(),
return const ProgressCenter();
},
), ),
); );
} }
Widget _buildWarningTile(BuildContext context) { Widget _buildContent(List<UpcomingUIModel> items, Map<DateTime, int> events) {
return ListTile( final l10n = context.l10n;
title: Padding(
padding: const EdgeInsets.symmetric(vertical: 3), return CustomScrollView(
child: Row( slivers: [
children: [ // Item type selector
Icon(Icons.warning_amber_outlined, color: context.secondaryColor), if (_visibleTypes.length > 1)
const SizedBox(width: 10), SliverToBoxAdapter(
Flexible( child: Padding(
child: Text( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
context.l10n.calendar_info, child: SegmentedButton(
softWrap: true, emptySelectionAllowed: true,
overflow: TextOverflow.clip, showSelectedIcon: false,
style: TextStyle(fontSize: 13, color: context.secondaryColor), style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
),
segments: _visibleTypes.map((type) {
return ButtonSegment(
value: type.index,
label: Padding(
padding: const EdgeInsets.all(12),
child: Text(type.localized(l10n)),
),
);
}).toList(),
selected: {itemType?.index},
onSelectionChanged: (newSelection) {
if (newSelection.isNotEmpty && newSelection.first != null) {
setState(() {
itemType = ItemType.values[newSelection.first!];
});
}
},
), ),
), ),
], ),
SliverToBoxAdapter(
child: UpcomingCalendar(
selectedYearMonth: _selectedYearMonth,
events: events,
setSelectedYearMonth: (yearMonth) {
setState(() {
_selectedYearMonth = yearMonth;
});
},
onClickDay: (date) => _scrollToDate(date),
),
), ),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
// All upcoming items (date headers + manga tiles)
if (items.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
l10n.calendar_no_data,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
return switch (item) {
UpcomingHeader(:final date, :final mangaCount) => _DateHeading(
key: _headerKeys[date],
date: date,
mangaCount: mangaCount,
),
UpcomingItem(:final manga) => widgets.UpcomingItem(
manga: manga,
onTap: () =>
context.push('/manga-reader/detail', extra: manga.id),
),
};
}, childCount: items.length),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
); );
} }
Widget _buildCalendar(List<Manga> data, Locale locale) { /// Scrolls to the header for [date] using Scrollable.ensureVisible.
return TableCalendar( void _scrollToDate(DateTime date) {
firstDay: firstDay, final key = _headerKeys[date];
lastDay: lastDay, if (key?.currentContext != null) {
focusedDay: _focusedDay, Scrollable.ensureVisible(
locale: locale.toLanguageTag(), key!.currentContext!,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day), duration: const Duration(milliseconds: 300),
rangeStartDay: _rangeStart, curve: Curves.easeInOut,
rangeEndDay: _rangeEnd, );
calendarFormat: _calendarFormat, }
rangeSelectionMode: _rangeSelectionMode, }
eventLoader: (day) => _getEntriesForDay(day, data),
startingDayOfWeek: StartingDayOfWeek.monday, /// Builds the upcoming UI model list, events map, and header indexes
calendarStyle: CalendarStyle( /// from the raw manga list.
outsideDaysVisible: true, _UpcomingData _buildUpcomingData(List<Manga> mangaList) {
weekendTextStyle: TextStyle(color: context.primaryColor), // 1. Compute expected next update date for each manga
), final List<({Manga manga, DateTime expectedDate})> mangaWithDates = [];
onDaySelected: (selectedDay, focusedDay) => for (final manga in mangaList) {
_onDaySelected(selectedDay, focusedDay, data), final expectedDate = _computeExpectedDate(manga);
onRangeSelected: (start, end, focusedDay) => if (expectedDate != null) {
_onRangeSelected(start, end, focusedDay, data), mangaWithDates.add((manga: manga, expectedDate: expectedDate));
onFormatChanged: (format) { }
if (_calendarFormat != format) { }
setState(() => _calendarFormat = format);
// 2. Sort by date ascending
mangaWithDates.sort((a, b) => a.expectedDate.compareTo(b.expectedDate));
// 3. Group by date and build UI models with headers
final List<UpcomingUIModel> items = [];
final Map<DateTime, int> events = {};
final Map<DateTime, int> headerIndexes = {};
DateTime? lastDate;
int countForGroup = 0;
int headerIndex = -1;
for (final entry in mangaWithDates) {
final date = DateTime(
entry.expectedDate.year,
entry.expectedDate.month,
entry.expectedDate.day,
);
if (lastDate == null || date != lastDate) {
// Finalize count for previous group
if (headerIndex >= 0) {
final prevHeader = items[headerIndex] as UpcomingHeader;
items[headerIndex] = UpcomingHeader(
date: prevHeader.date,
mangaCount: countForGroup,
);
events[prevHeader.date] = countForGroup;
} }
},
onPageChanged: (focusedDay) => _focusedDay = focusedDay, // Start new group
headerIndex = items.length;
headerIndexes[date] = headerIndex;
items.add(UpcomingHeader(date: date, mangaCount: 0));
countForGroup = 0;
lastDate = date;
}
items.add(UpcomingItem(manga: entry.manga, expectedDate: date));
countForGroup++;
}
// Finalize last group
if (headerIndex >= 0 && headerIndex < items.length) {
final lastHeader = items[headerIndex] as UpcomingHeader;
items[headerIndex] = UpcomingHeader(
date: lastHeader.date,
mangaCount: countForGroup,
);
events[lastHeader.date] = countForGroup;
}
return _UpcomingData(
items: items,
events: events,
headerIndexes: headerIndexes,
); );
} }
final Map<String, List<Manga>> _dayCache = {}; /// Computes the expected next update date for a mang
DateTime? _computeExpectedDate(Manga manga) {
List<Manga> _getEntriesForDay(DateTime day, List<Manga> data) { if (manga.smartUpdateDays == null || manga.smartUpdateDays! <= 0) {
final key = "${day.year}-${day.month}-${day.day}"; return null;
if (_dayCache.containsKey(key)) return _dayCache[key]!;
final result = data.where((e) {
final lastChapter = e.chapters
.filter()
.sortByDateUploadDesc()
.findFirstSync();
final lastDate = int.tryParse(lastChapter?.dateUpload ?? "");
final start = lastDate != null
? DateTime.fromMillisecondsSinceEpoch(lastDate)
: DateTime.now();
final temp = start.add(Duration(days: e.smartUpdateDays!));
return temp.year == day.year &&
temp.month == day.month &&
temp.day == day.day;
}).toList();
_dayCache[key] = result;
return result;
}
List<Manga> _getEntriesForRange(
DateTime start,
DateTime end,
List<Manga> data,
) {
final days = _daysInRange(start, end);
return [for (final d in days) ..._getEntriesForDay(d, data)];
}
void _onDaySelected(
DateTime selectedDay,
DateTime focusedDay,
List<Manga> data,
) {
if (!isSameDay(_selectedDay, selectedDay)) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
_rangeStart = null;
_rangeEnd = null;
_rangeSelectionMode = RangeSelectionMode.toggledOff;
});
_selectedEntries.value = _getEntriesForDay(selectedDay, data);
} }
} final lastChapter = manga.chapters
.filter()
void _onRangeSelected( .sortByDateUploadDesc()
DateTime? start, .findFirstSync();
DateTime? end, final lastChapterMs = int.tryParse(lastChapter?.dateUpload ?? '');
DateTime focusedDay, return FetchInterval.computeExpectedDate(
List<Manga> data, lastChapterDateMs: lastChapterMs,
) { lastUpdateMs: manga.lastUpdate,
setState(() { interval: manga.smartUpdateDays,
_selectedDay = null;
_focusedDay = focusedDay;
_rangeStart = start;
_rangeEnd = end;
_rangeSelectionMode = RangeSelectionMode.toggledOn;
});
if (start != null && end != null) {
_selectedEntries.value = _getEntriesForRange(start, end, data);
} else if (start != null) {
_selectedEntries.value = _getEntriesForDay(start, data);
} else if (end != null) {
_selectedEntries.value = _getEntriesForDay(end, data);
}
}
List<DateTime> _daysInRange(DateTime first, DateTime last) {
final dayCount = last.difference(first).inDays + 1;
return List.generate(
dayCount,
(index) => DateTime.utc(first.year, first.month, first.day + index),
); );
} }
} }
class CalendarListTileWidget extends ConsumerWidget { /// Internal data class for computed upcoming state.
final Manga manga; class _UpcomingData {
final DateTime? selectedDay; final List<UpcomingUIModel> items;
const CalendarListTileWidget({ final Map<DateTime, int> events;
required this.manga, final Map<DateTime, int> headerIndexes;
required this.selectedDay,
super.key, const _UpcomingData({
required this.items,
required this.events,
required this.headerIndexes,
}); });
}
/// Date heading with relative date text + count badge.
class _DateHeading extends ConsumerWidget {
final DateTime date;
final int mangaCount;
const _DateHeading({super.key, required this.date, required this.mangaCount});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Material( final theme = Theme.of(context);
borderRadius: BorderRadius.circular(5),
color: Colors.transparent, return Padding(
clipBehavior: Clip.antiAliasWithSaveLayer, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: InkWell( child: Row(
onTap: () => context.push('/manga-reader/detail', extra: manga.id), children: [
onLongPress: () {}, Text(
onSecondaryTap: () {}, dateFormat(
child: Padding( date.millisecondsSinceEpoch.toString(),
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 5), context: context,
child: Container( ref: ref,
height: 45, useRelativeTimesTamps: true,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(5)), showInDaysFuture: true,
child: Row( ),
mainAxisAlignment: MainAxisAlignment.spaceBetween, style: theme.textTheme.bodyMedium?.copyWith(
children: [ fontWeight: FontWeight.w600,
Expanded( color: theme.colorScheme.onSurfaceVariant,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Material(
child: GestureDetector(
onTap: () {
context.push(
'/manga-reader/detail',
extra: manga.id,
);
},
child: Ink.image(
fit: BoxFit.cover,
width: 40,
height: 45,
image: manga.customCoverImage != null
? MemoryImage(
manga.customCoverImage as Uint8List,
)
as ImageProvider
: CustomExtendedNetworkImageProvider(
toImgUrl(
manga.customCoverFromTracker ??
manga.imageUrl!,
),
headers: ref.watch(
headersProvider(
source: manga.source!,
lang: manga.lang!,
sourceId: manga.sourceId,
),
),
),
child: InkWell(child: Container()),
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
manga.name!,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
color: Theme.of(
context,
).textTheme.bodyLarge!.color,
),
),
Text(
context.l10n.n_chapters(
manga.chapters.countSync(),
),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: context.secondaryColor,
),
),
],
),
),
),
],
),
),
],
), ),
), ),
), const SizedBox(width: 8),
Badge(
backgroundColor: theme.colorScheme.primary,
textColor: theme.colorScheme.onPrimary,
label: Text('$mangaCount'),
),
],
), ),
); );
} }

View file

@ -0,0 +1,41 @@
import 'package:mangayomi/models/manga.dart';
/// Sealed model for upcoming manga list items.
///
/// The list alternates between [Header] (date separator with count badge)
/// and [Item] (individual manga) entries.
sealed class UpcomingUIModel {
const UpcomingUIModel();
}
class UpcomingHeader extends UpcomingUIModel {
final DateTime date;
final int mangaCount;
const UpcomingHeader({required this.date, required this.mangaCount});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UpcomingHeader &&
date == other.date &&
mangaCount == other.mangaCount;
@override
int get hashCode => Object.hash(date, mangaCount);
}
class UpcomingItem extends UpcomingUIModel {
final Manga manga;
final DateTime expectedDate;
const UpcomingItem({required this.manga, required this.expectedDate});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UpcomingItem && manga.id == other.manga.id;
@override
int get hashCode => manga.id.hashCode;
}

View file

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:mangayomi/modules/calendar/widgets/calendar_indicator.dart';
const _maxEvents = 3;
class CalendarDay extends StatelessWidget {
final DateTime date;
final int events;
final VoidCallback onDayClick;
final bool isToday;
final bool isPast;
const CalendarDay({
required this.date,
required this.events,
required this.onDayClick,
required this.isToday,
required this.isPast,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primaryColor = theme.colorScheme.primary;
final onBgColor = theme.colorScheme.onSurface;
return InkWell(
onTap: onDayClick,
customBorder: const CircleBorder(),
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: isToday
? BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: onBgColor, width: 1),
)
: null,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${date.day}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: isPast ? onBgColor.withValues(alpha: 0.38) : onBgColor,
),
),
if (events > 0) ...[
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: List.generate(
events.clamp(0, _maxEvents),
(index) => CalendarIndicator(
index: index,
size: 56,
color: primaryColor,
),
),
),
],
],
),
),
),
);
}
}

View file

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class CalendarHeader extends StatelessWidget {
final DateTime yearMonth;
final VoidCallback onPreviousClick;
final VoidCallback onNextClick;
const CalendarHeader({
required this.yearMonth,
required this.onPreviousClick,
required this.onNextClick,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final locale = Localizations.localeOf(context);
final formatter = DateFormat('MMMM yyyy', locale.toLanguageTag());
final title = formatter.format(yearMonth);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(animation),
child: child,
),
),
child: Text(
title,
key: ValueKey(title),
style: theme.textTheme.titleLarge,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.keyboard_arrow_left),
onPressed: onPreviousClick,
),
IconButton(
icon: const Icon(Icons.keyboard_arrow_right),
onPressed: onNextClick,
),
],
),
],
),
);
}
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
const _indicatorScale = 12;
const _indicatorAlphaMultiplier = 0.3;
class CalendarIndicator extends StatelessWidget {
final int index;
final double size;
final Color color;
const CalendarIndicator({
required this.index,
required this.size,
required this.color,
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
width: size / _indicatorScale,
height: size / _indicatorScale,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: (index + 1) * _indicatorAlphaMultiplier),
),
);
}
}

View file

@ -0,0 +1,153 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:mangayomi/modules/calendar/widgets/calendar_indicator.dart';
import 'package:table_calendar/table_calendar.dart';
const _maxEventDots = 3;
class UpcomingCalendar extends StatefulWidget {
final DateTime selectedYearMonth;
final Map<DateTime, int> events;
final ValueChanged<DateTime> setSelectedYearMonth;
final ValueChanged<DateTime> onClickDay;
const UpcomingCalendar({
required this.selectedYearMonth,
required this.events,
required this.setSelectedYearMonth,
required this.onClickDay,
super.key,
});
@override
State<UpcomingCalendar> createState() => _UpcomingCalendarState();
}
class _UpcomingCalendarState extends State<UpcomingCalendar> {
CalendarFormat _calendarFormat = CalendarFormat.month;
late DateTime _focusedDay;
@override
void initState() {
super.initState();
_focusedDay = widget.selectedYearMonth;
}
@override
void didUpdateWidget(covariant UpcomingCalendar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedYearMonth != widget.selectedYearMonth) {
_focusedDay = widget.selectedYearMonth;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final locale = Localizations.localeOf(context);
return TableCalendar(
firstDay: DateTime.now().subtract(const Duration(days: 365)),
lastDay: DateTime.now().add(const Duration(days: 1000)),
focusedDay: _focusedDay,
locale: locale.toLanguageTag(),
calendarFormat: _calendarFormat,
startingDayOfWeek: StartingDayOfWeek.monday,
headerStyle: HeaderStyle(
formatButtonVisible: true,
titleCentered: true,
formatButtonShowsNext: false,
titleTextStyle: theme.textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
),
leftChevronIcon: Icon(
Icons.keyboard_arrow_left,
color: theme.colorScheme.onSurface,
),
rightChevronIcon: Icon(
Icons.keyboard_arrow_right,
color: theme.colorScheme.onSurface,
),
),
daysOfWeekStyle: DaysOfWeekStyle(
weekdayStyle: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: theme.colorScheme.onSurface,
),
weekendStyle: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: theme.colorScheme.primary,
),
),
calendarStyle: CalendarStyle(
outsideDaysVisible: true,
todayDecoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: theme.colorScheme.primary, width: 1.5),
),
todayTextStyle: TextStyle(
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
defaultTextStyle: TextStyle(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
weekendTextStyle: TextStyle(
fontWeight: FontWeight.w500,
color: theme.colorScheme.primary,
),
outsideTextStyle: TextStyle(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withValues(alpha: 0.38),
),
// Disable default marker so we use our custom builder
markersMaxCount: 0,
),
eventLoader: (day) {
final key = DateTime(day.year, day.month, day.day);
final count = widget.events[key] ?? 0;
return List.generate(count, (_) => day);
},
calendarBuilders: CalendarBuilders(
markerBuilder: (context, date, events) {
final key = DateTime(date.year, date.month, date.day);
final count = widget.events[key] ?? 0;
if (count == 0) return null;
return Positioned(
bottom: 4,
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
min(count, _maxEventDots),
(index) => CalendarIndicator(
index: index,
size: 48,
color: theme.colorScheme.primary,
),
),
),
);
},
),
onDaySelected: (selectedDay, focusedDay) {
setState(() => _focusedDay = focusedDay);
widget.onClickDay(
DateTime(selectedDay.year, selectedDay.month, selectedDay.day),
);
},
onFormatChanged: (format) {
setState(() => _calendarFormat = format);
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
widget.setSelectedYearMonth(
DateTime(focusedDay.year, focusedDay.month),
);
},
);
}
}

View file

@ -0,0 +1,79 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart';
import 'package:mangayomi/utils/constant.dart';
import 'package:mangayomi/utils/headers.dart';
const _upcomingItemHeight = 96.0;
class UpcomingItem extends ConsumerWidget {
final Manga manga;
final VoidCallback onTap;
const UpcomingItem({required this.manga, required this.onTap, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
child: SizedBox(
height: _upcomingItemHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Cover image
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 56,
height: _upcomingItemHeight - 16,
child: Image(
image: _getImageProvider(ref),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
color: theme.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image, size: 24),
),
),
),
),
const SizedBox(width: 16),
// Title
Expanded(
child: Text(
manga.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
ImageProvider _getImageProvider(WidgetRef ref) {
if (manga.customCoverImage != null) {
return MemoryImage(manga.customCoverImage as Uint8List);
}
return CustomExtendedNetworkImageProvider(
toImgUrl(manga.customCoverFromTracker ?? manga.imageUrl ?? ''),
headers: ref.watch(
headersProvider(
source: manga.source ?? '',
lang: manga.lang ?? '',
sourceId: manga.sourceId,
),
),
);
}
}

View file

@ -1,5 +1,3 @@
import 'dart:math';
import 'package:mangayomi/eval/model/m_bridge.dart'; import 'package:mangayomi/eval/model/m_bridge.dart';
import 'package:mangayomi/eval/model/m_manga.dart'; import 'package:mangayomi/eval/model/m_manga.dart';
import 'package:mangayomi/main.dart'; import 'package:mangayomi/main.dart';
@ -7,8 +5,8 @@ import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/update.dart'; import 'package:mangayomi/models/update.dart';
import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/services/get_detail.dart'; import 'package:mangayomi/services/get_detail.dart';
import 'package:mangayomi/utils/extensions/others.dart';
import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart';
import 'package:mangayomi/utils/fetch_interval.dart';
import 'package:mangayomi/utils/utils.dart'; import 'package:mangayomi/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'update_manga_detail_providers.g.dart'; part 'update_manga_detail_providers.g.dart';
@ -140,27 +138,15 @@ Future<dynamic> updateMangaDetail(
oldChap.manga.saveSync(); oldChap.manga.saveSync();
} }
} }
final List<int> daysBetweenUploads = []; // Calculate fetch interval:
for (var i = 0; i + 1 < chaps.length; i++) { // median of gaps between recent distinct chapter dates, clamped [1, 28].
if (chaps[i].dateUpload != null && chaps[i + 1].dateUpload != null) { final allChapters = isar.mangas.getSync(mangaId)!.chapters.toList();
final date1 = DateTime.fromMillisecondsSinceEpoch( if (allChapters.isNotEmpty) {
int.parse(chaps[i].dateUpload!), final interval = FetchInterval.calculateInterval(allChapters);
);
final date2 = DateTime.fromMillisecondsSinceEpoch(
int.parse(chaps[i + 1].dateUpload!),
);
daysBetweenUploads.add(date1.difference(date2).abs().inDays);
}
}
if (daysBetweenUploads.isNotEmpty) {
final median = daysBetweenUploads.median();
isar.mangas.putSync( isar.mangas.putSync(
manga manga
..id = mangaId ..id = mangaId
..smartUpdateDays = max( ..smartUpdateDays = interval,
median,
daysBetweenUploads.arithmeticMean(),
),
); );
} }
}); });

View file

@ -401,11 +401,11 @@ class _MangaChapterPageGalleryState
chapterName: widget.chapter.name!, chapterName: widget.chapter.name!,
), ),
onFailedToLoadImage: (value) { onFailedToLoadImage: (value) {
// Handle failed image loading // // Handle failed image loading
if (_failedToLoadImage.value != value && // if (_failedToLoadImage.value != value &&
context.mounted) { // context.mounted) {
_failedToLoadImage.value = value; // _failedToLoadImage.value = value;
} // }
}, },
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
isDoublePageMode: isDoublePageMode:

View file

@ -38,11 +38,12 @@ extension LetExtension<T> on T {
extension MedianExtension on List<int> { extension MedianExtension on List<int> {
int median() { int median() {
var middle = length ~/ 2; final sorted = List<int>.from(this)..sort();
if (length % 2 == 1) { var middle = sorted.length ~/ 2;
return this[middle]; if (sorted.length % 2 == 1) {
return sorted[middle];
} else { } else {
return ((this[middle - 1] + this[middle]) / 2).round(); return ((sorted[middle - 1] + sorted[middle]) / 2).round();
} }
} }

View file

@ -0,0 +1,233 @@
import 'dart:math';
import 'package:mangayomi/models/chapter.dart';
/// Computes the fetch interval and next expected update date for a manga.
///
/// Uses the median of intervals between recent chapter upload dates
/// (falling back to fetch/updatedAt dates) to determine a reliable
/// release cadence. The interval is clamped to [1, maxInterval] days.
class FetchInterval {
FetchInterval._();
/// Maximum interval in days.
static const int maxInterval = 28;
/// Grace period in days for the fetch window.
static const int _gracePeriod = 1;
/// Calculates the fetch interval in days from a list of chapters.
///
/// - Takes the last N **distinct** dates (N = 3 if 8 chapters, else 10)
/// - Computes pairwise day-gaps, sorts them, picks the **median**
/// - Falls back to fetch dates (`updatedAt`) if upload dates are insufficient
/// - Defaults to 7 days if still insufficient
/// - Clamps to [1, [maxInterval]]
static int calculateInterval(List<Chapter> chapters) {
final chapterWindow = chapters.length <= 8 ? 3 : 10;
// Upload dates (primary signal)
final uploadDates =
chapters
.where((c) {
final ms = int.tryParse(c.dateUpload ?? '');
return ms != null && ms > 0;
})
.map((c) => int.parse(c.dateUpload!))
.toList()
..sort((a, b) => b.compareTo(a)); // descending
final distinctUploadDays = _distinctDays(
uploadDates,
).take(chapterWindow).toList();
if (distinctUploadDays.length >= 3) {
return _medianInterval(distinctUploadDays);
}
// Fetch / updatedAt dates (fallback signal)
final fetchDates =
chapters
.where((c) => c.updatedAt != null && c.updatedAt! > 0)
.map((c) => c.updatedAt!)
.toList()
..sort((a, b) => b.compareTo(a)); // descending
final distinctFetchDays = _distinctDays(
fetchDates,
).take(chapterWindow).toList();
if (distinctFetchDays.length >= 3) {
return _medianInterval(distinctFetchDays);
}
// Not enough data default 7 days
return 7;
}
/// Computes the next expected update timestamp (milliseconds since epoch)
/// for a manga.
///
/// [lastUpdate] epoch millis when chapters were last synced
/// [interval] fetch interval in days (from [calculateInterval])
/// [currentNextUpdate] previously stored next-update epoch millis (0 if none)
/// [now] current DateTime (injectable for testing)
static int calculateNextUpdate({
required int lastUpdate,
required int interval,
int currentNextUpdate = 0,
DateTime? now,
}) {
final dateTime = now ?? DateTime.now();
// If existing nextUpdate is within the grace window, keep it.
if (currentNextUpdate > 0) {
final window = getWindow(dateTime);
if (currentNextUpdate >= window.$1 &&
currentNextUpdate <= window.$2 + 1) {
return currentNextUpdate;
}
}
final latestDate = lastUpdate > 0
? DateTime.fromMillisecondsSinceEpoch(lastUpdate)
: dateTime;
final latestDay = DateTime(
latestDate.year,
latestDate.month,
latestDate.day,
);
final nowDay = DateTime(dateTime.year, dateTime.month, dateTime.day);
final timeSinceLatest = nowDay.difference(latestDay).inDays;
final effectiveInterval = interval < 0
? interval.abs()
: _increaseInterval(interval, timeSinceLatest, increaseWhenOver: 10);
final cycle = effectiveInterval > 0
? timeSinceLatest ~/ effectiveInterval
: 0;
return latestDay
.add(Duration(days: (cycle + 1) * interval.abs()))
.millisecondsSinceEpoch;
}
/// Computes the expected next update [DateTime] for a manga, suitable
/// for the calendar screen.
///
/// [lastChapterDateMs] epoch millis of last chapter upload date
/// [lastUpdateMs] epoch millis of when manga was last updated/synced
/// [interval] fetch interval in days
static DateTime? computeExpectedDate({
required int? lastChapterDateMs,
required int? lastUpdateMs,
required int? interval,
DateTime? now,
}) {
if (interval == null || interval <= 0) return null;
final dateTime = now ?? DateTime.now();
// Use the most recent of: last chapter upload date, last manga update
final referenceMs = [
if (lastChapterDateMs != null && lastChapterDateMs > 0) lastChapterDateMs,
if (lastUpdateMs != null && lastUpdateMs > 0) lastUpdateMs,
];
if (referenceMs.isEmpty) return null;
final latestMs = referenceMs.reduce(max);
final latestDate = DateTime.fromMillisecondsSinceEpoch(latestMs);
final latestDay = DateTime(
latestDate.year,
latestDate.month,
latestDate.day,
);
final nowDay = DateTime(dateTime.year, dateTime.month, dateTime.day);
final timeSinceLatest = nowDay.difference(latestDay).inDays;
if (timeSinceLatest < 0) {
// Latest date is in the future, next update = latestDay + interval
return latestDay.add(Duration(days: interval));
}
// Find which cycle we're in and return start of next cycle
final effectiveInterval = _increaseInterval(
interval,
timeSinceLatest,
increaseWhenOver: 10,
);
final cycle = effectiveInterval > 0
? timeSinceLatest ~/ effectiveInterval
: 0;
return latestDay.add(Duration(days: (cycle + 1) * interval));
}
/// Returns the fetch window as (lowerBound, upperBound) in epoch millis.
/// Today ± [_gracePeriod] day(s).
static (int, int) getWindow(DateTime dateTime) {
final today = DateTime(dateTime.year, dateTime.month, dateTime.day);
final lower = today.subtract(Duration(days: _gracePeriod));
final upper = today.add(Duration(days: _gracePeriod));
return (lower.millisecondsSinceEpoch, upper.millisecondsSinceEpoch - 1);
}
// Private helpers
/// Doubles the interval progressively if too many check cycles
/// have been missed (>10).
static int _increaseInterval(
int delta,
int timeSinceLatest, {
required int increaseWhenOver,
}) {
if (delta >= maxInterval) return maxInterval;
final cycle = (timeSinceLatest ~/ delta) + 1;
if (cycle > increaseWhenOver) {
return _increaseInterval(
min(delta * 2, maxInterval),
timeSinceLatest,
increaseWhenOver: increaseWhenOver,
);
}
return delta;
}
/// Deduplicates epoch-millis timestamps to distinct calendar days,
/// preserving order. Returns day-start epoch millis.
static Iterable<int> _distinctDays(List<int> epochMillis) sync* {
final seen = <int>{};
for (final ms in epochMillis) {
final day = DateTime.fromMillisecondsSinceEpoch(ms);
final dayKey = DateTime(
day.year,
day.month,
day.day,
).millisecondsSinceEpoch;
if (seen.add(dayKey)) {
yield dayKey;
}
}
}
/// Computes pairwise day-gaps from a list of distinct day-start
/// epoch-millis (descending order), sorts them, and returns the median.
/// Clamps to [1, [maxInterval]].
static int _medianInterval(List<int> distinctDays) {
final ranges = <int>[];
for (var i = 0; i + 1 < distinctDays.length; i++) {
final daysDiff =
(DateTime.fromMillisecondsSinceEpoch(distinctDays[i]).difference(
DateTime.fromMillisecondsSinceEpoch(distinctDays[i + 1]),
)).inDays.abs();
ranges.add(daysDiff);
}
ranges.sort();
// Median: middle element (upper-median for even length)
final median = ranges[(ranges.length - 1) ~/ 2];
return median.clamp(1, maxInterval);
}
}