From 95a55d5f81c5e42075048ec53540b06f1952c496 Mon Sep 17 00:00:00 2001 From: Moustapha Kodjo Amadou <107993382+kodjodevf@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:05:29 +0100 Subject: [PATCH] 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. --- lib/modules/calendar/calendar_screen.dart | 617 ++++++++---------- .../calendar/models/upcoming_ui_model.dart | 41 ++ .../calendar/widgets/calendar_day.dart | 73 +++ .../calendar/widgets/calendar_header.dart | 63 ++ .../calendar/widgets/calendar_indicator.dart | 30 + .../calendar/widgets/upcoming_calendar.dart | 153 +++++ .../calendar/widgets/upcoming_item.dart | 79 +++ .../update_manga_detail_providers.dart | 28 +- lib/modules/manga/reader/reader_view.dart | 10 +- lib/utils/extensions/others.dart | 9 +- lib/utils/fetch_interval.dart | 233 +++++++ 11 files changed, 960 insertions(+), 376 deletions(-) create mode 100644 lib/modules/calendar/models/upcoming_ui_model.dart create mode 100644 lib/modules/calendar/widgets/calendar_day.dart create mode 100644 lib/modules/calendar/widgets/calendar_header.dart create mode 100644 lib/modules/calendar/widgets/calendar_indicator.dart create mode 100644 lib/modules/calendar/widgets/upcoming_calendar.dart create mode 100644 lib/modules/calendar/widgets/upcoming_item.dart create mode 100644 lib/utils/fetch_interval.dart diff --git a/lib/modules/calendar/calendar_screen.dart b/lib/modules/calendar/calendar_screen.dart index b6347864..5cf9bec5 100644 --- a/lib/modules/calendar/calendar_screen.dart +++ b/lib/modules/calendar/calendar_screen.dart @@ -1,24 +1,21 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:isar_community/isar.dart'; import 'package:mangayomi/models/chapter.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/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/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/providers/l10n_providers.dart'; -import 'package:mangayomi/utils/constant.dart'; import 'package:mangayomi/utils/date.dart'; -import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; -import 'package:mangayomi/utils/headers.dart'; +import 'package:mangayomi/utils/fetch_interval.dart'; import 'package:mangayomi/utils/item_type_filters.dart'; import 'package:mangayomi/utils/item_type_localization.dart'; -import 'package:table_calendar/table_calendar.dart'; class CalendarScreen extends ConsumerStatefulWidget { final ItemType? itemType; @@ -29,18 +26,15 @@ class CalendarScreen extends ConsumerStatefulWidget { } class _CalendarScreenState extends ConsumerState { - late final ValueNotifier> _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 List _visibleTypes; + /// Currently displayed year/month on the calendar. + late DateTime _selectedYearMonth; + + /// Header GlobalKeys so clicking a calendar day scrolls to that date. + final Map _headerKeys = {}; + @override void initState() { super.initState(); @@ -51,122 +45,54 @@ class _CalendarScreenState extends ConsumerState { } else { itemType = _visibleTypes.isNotEmpty ? _visibleTypes.first : null; } - _selectedDay = _focusedDay; - _selectedEntries = ValueNotifier([]); - } - - @override - void dispose() { - _selectedEntries.dispose(); - super.dispose(); + final now = DateTime.now(); + _selectedYearMonth = DateTime(now.year, now.month); } @override Widget build(BuildContext context) { final l10n = context.l10n; - final locale = ref.watch(l10nLocaleStateProvider); final data = ref.watch(getCalendarStreamProvider(itemType: itemType)); + 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( - data: (data) { - if (_selectedDay != null) { - _selectedEntries.value = _getEntriesForDay(_selectedDay!, data); + data: (mangaList) { + final upcomingData = _buildUpcomingData(mangaList); + 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), - 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>( - valueListenable: _selectedEntries, - builder: (context, value, _) { - return CustomSliverGroupedListView( - 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)), - ], - ), - ); + + return _buildContent(items, events); }, - error: (Object error, StackTrace stackTrace) { + error: (error, stackTrace) { return Center( child: Padding( padding: const EdgeInsets.all(8.0), @@ -174,254 +100,253 @@ class _CalendarScreenState extends ConsumerState { ), ); }, - loading: () { - return const ProgressCenter(); - }, + loading: () => const ProgressCenter(), ), ); } - Widget _buildWarningTile(BuildContext context) { - return ListTile( - title: Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - children: [ - Icon(Icons.warning_amber_outlined, color: context.secondaryColor), - const SizedBox(width: 10), - Flexible( - child: Text( - context.l10n.calendar_info, - softWrap: true, - overflow: TextOverflow.clip, - style: TextStyle(fontSize: 13, color: context.secondaryColor), + Widget _buildContent(List items, Map events) { + final l10n = context.l10n; + + return CustomScrollView( + slivers: [ + // Item type selector + if (_visibleTypes.length > 1) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + 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!]; + }); + } + }, ), ), - ], + ), + + 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 data, Locale locale) { - return TableCalendar( - firstDay: firstDay, - lastDay: lastDay, - focusedDay: _focusedDay, - locale: locale.toLanguageTag(), - selectedDayPredicate: (day) => isSameDay(_selectedDay, day), - rangeStartDay: _rangeStart, - rangeEndDay: _rangeEnd, - calendarFormat: _calendarFormat, - rangeSelectionMode: _rangeSelectionMode, - eventLoader: (day) => _getEntriesForDay(day, data), - startingDayOfWeek: StartingDayOfWeek.monday, - calendarStyle: CalendarStyle( - outsideDaysVisible: true, - weekendTextStyle: TextStyle(color: context.primaryColor), - ), - onDaySelected: (selectedDay, focusedDay) => - _onDaySelected(selectedDay, focusedDay, data), - onRangeSelected: (start, end, focusedDay) => - _onRangeSelected(start, end, focusedDay, data), - onFormatChanged: (format) { - if (_calendarFormat != format) { - setState(() => _calendarFormat = format); + /// Scrolls to the header for [date] using Scrollable.ensureVisible. + void _scrollToDate(DateTime date) { + final key = _headerKeys[date]; + if (key?.currentContext != null) { + Scrollable.ensureVisible( + key!.currentContext!, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + /// Builds the upcoming UI model list, events map, and header indexes + /// from the raw manga list. + _UpcomingData _buildUpcomingData(List mangaList) { + // 1. Compute expected next update date for each manga + final List<({Manga manga, DateTime expectedDate})> mangaWithDates = []; + for (final manga in mangaList) { + final expectedDate = _computeExpectedDate(manga); + if (expectedDate != null) { + mangaWithDates.add((manga: manga, expectedDate: expectedDate)); + } + } + + // 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 items = []; + final Map events = {}; + final Map 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> _dayCache = {}; - - List _getEntriesForDay(DateTime day, List data) { - final key = "${day.year}-${day.month}-${day.day}"; - 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 _getEntriesForRange( - DateTime start, - DateTime end, - List data, - ) { - final days = _daysInRange(start, end); - - return [for (final d in days) ..._getEntriesForDay(d, data)]; - } - - void _onDaySelected( - DateTime selectedDay, - DateTime focusedDay, - List data, - ) { - if (!isSameDay(_selectedDay, selectedDay)) { - setState(() { - _selectedDay = selectedDay; - _focusedDay = focusedDay; - _rangeStart = null; - _rangeEnd = null; - _rangeSelectionMode = RangeSelectionMode.toggledOff; - }); - - _selectedEntries.value = _getEntriesForDay(selectedDay, data); + /// Computes the expected next update date for a mang + DateTime? _computeExpectedDate(Manga manga) { + if (manga.smartUpdateDays == null || manga.smartUpdateDays! <= 0) { + return null; } - } - - void _onRangeSelected( - DateTime? start, - DateTime? end, - DateTime focusedDay, - List data, - ) { - setState(() { - _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 _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), + final lastChapter = manga.chapters + .filter() + .sortByDateUploadDesc() + .findFirstSync(); + final lastChapterMs = int.tryParse(lastChapter?.dateUpload ?? ''); + return FetchInterval.computeExpectedDate( + lastChapterDateMs: lastChapterMs, + lastUpdateMs: manga.lastUpdate, + interval: manga.smartUpdateDays, ); } } -class CalendarListTileWidget extends ConsumerWidget { - final Manga manga; - final DateTime? selectedDay; - const CalendarListTileWidget({ - required this.manga, - required this.selectedDay, - super.key, +/// Internal data class for computed upcoming state. +class _UpcomingData { + final List items; + final Map events; + final Map headerIndexes; + + 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 Widget build(BuildContext context, WidgetRef ref) { - return Material( - borderRadius: BorderRadius.circular(5), - color: Colors.transparent, - clipBehavior: Clip.antiAliasWithSaveLayer, - child: InkWell( - onTap: () => context.push('/manga-reader/detail', extra: manga.id), - onLongPress: () {}, - onSecondaryTap: () {}, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 5), - child: Container( - height: 45, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(5)), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - 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, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ], + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + Text( + dateFormat( + date.millisecondsSinceEpoch.toString(), + context: context, + ref: ref, + useRelativeTimesTamps: true, + showInDaysFuture: true, + ), + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurfaceVariant, ), ), - ), + const SizedBox(width: 8), + Badge( + backgroundColor: theme.colorScheme.primary, + textColor: theme.colorScheme.onPrimary, + label: Text('$mangaCount'), + ), + ], ), ); } diff --git a/lib/modules/calendar/models/upcoming_ui_model.dart b/lib/modules/calendar/models/upcoming_ui_model.dart new file mode 100644 index 00000000..135f88be --- /dev/null +++ b/lib/modules/calendar/models/upcoming_ui_model.dart @@ -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; +} diff --git a/lib/modules/calendar/widgets/calendar_day.dart b/lib/modules/calendar/widgets/calendar_day.dart new file mode 100644 index 00000000..e1be996d --- /dev/null +++ b/lib/modules/calendar/widgets/calendar_day.dart @@ -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, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/calendar/widgets/calendar_header.dart b/lib/modules/calendar/widgets/calendar_header.dart new file mode 100644 index 00000000..842ae3de --- /dev/null +++ b/lib/modules/calendar/widgets/calendar_header.dart @@ -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( + 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, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/modules/calendar/widgets/calendar_indicator.dart b/lib/modules/calendar/widgets/calendar_indicator.dart new file mode 100644 index 00000000..a965b51e --- /dev/null +++ b/lib/modules/calendar/widgets/calendar_indicator.dart @@ -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), + ), + ); + } +} diff --git a/lib/modules/calendar/widgets/upcoming_calendar.dart b/lib/modules/calendar/widgets/upcoming_calendar.dart new file mode 100644 index 00000000..09631870 --- /dev/null +++ b/lib/modules/calendar/widgets/upcoming_calendar.dart @@ -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 events; + final ValueChanged setSelectedYearMonth; + final ValueChanged onClickDay; + + const UpcomingCalendar({ + required this.selectedYearMonth, + required this.events, + required this.setSelectedYearMonth, + required this.onClickDay, + super.key, + }); + + @override + State createState() => _UpcomingCalendarState(); +} + +class _UpcomingCalendarState extends State { + 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), + ); + }, + ); + } +} diff --git a/lib/modules/calendar/widgets/upcoming_item.dart b/lib/modules/calendar/widgets/upcoming_item.dart new file mode 100644 index 00000000..eec3fbfb --- /dev/null +++ b/lib/modules/calendar/widgets/upcoming_item.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart index 4590d046..5c07e139 100644 --- a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart +++ b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:mangayomi/eval/model/m_bridge.dart'; import 'package:mangayomi/eval/model/m_manga.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/manga.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/fetch_interval.dart'; import 'package:mangayomi/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'update_manga_detail_providers.g.dart'; @@ -140,27 +138,15 @@ Future updateMangaDetail( oldChap.manga.saveSync(); } } - final List daysBetweenUploads = []; - for (var i = 0; i + 1 < chaps.length; i++) { - if (chaps[i].dateUpload != null && chaps[i + 1].dateUpload != null) { - final date1 = DateTime.fromMillisecondsSinceEpoch( - int.parse(chaps[i].dateUpload!), - ); - final date2 = DateTime.fromMillisecondsSinceEpoch( - int.parse(chaps[i + 1].dateUpload!), - ); - daysBetweenUploads.add(date1.difference(date2).abs().inDays); - } - } - if (daysBetweenUploads.isNotEmpty) { - final median = daysBetweenUploads.median(); + // Calculate fetch interval: + // median of gaps between recent distinct chapter dates, clamped [1, 28]. + final allChapters = isar.mangas.getSync(mangaId)!.chapters.toList(); + if (allChapters.isNotEmpty) { + final interval = FetchInterval.calculateInterval(allChapters); isar.mangas.putSync( manga ..id = mangaId - ..smartUpdateDays = max( - median, - daysBetweenUploads.arithmeticMean(), - ), + ..smartUpdateDays = interval, ); } }); diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index ef3208e9..ceb8581e 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -401,11 +401,11 @@ class _MangaChapterPageGalleryState chapterName: widget.chapter.name!, ), onFailedToLoadImage: (value) { - // Handle failed image loading - if (_failedToLoadImage.value != value && - context.mounted) { - _failedToLoadImage.value = value; - } + // // Handle failed image loading + // if (_failedToLoadImage.value != value && + // context.mounted) { + // _failedToLoadImage.value = value; + // } }, backgroundColor: backgroundColor, isDoublePageMode: diff --git a/lib/utils/extensions/others.dart b/lib/utils/extensions/others.dart index b10123fe..5dbda711 100644 --- a/lib/utils/extensions/others.dart +++ b/lib/utils/extensions/others.dart @@ -38,11 +38,12 @@ extension LetExtension on T { extension MedianExtension on List { int median() { - var middle = length ~/ 2; - if (length % 2 == 1) { - return this[middle]; + final sorted = List.from(this)..sort(); + var middle = sorted.length ~/ 2; + if (sorted.length % 2 == 1) { + return sorted[middle]; } else { - return ((this[middle - 1] + this[middle]) / 2).round(); + return ((sorted[middle - 1] + sorted[middle]) / 2).round(); } } diff --git a/lib/utils/fetch_interval.dart b/lib/utils/fetch_interval.dart new file mode 100644 index 00000000..e5feed22 --- /dev/null +++ b/lib/utils/fetch_interval.dart @@ -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 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 _distinctDays(List epochMillis) sync* { + final seen = {}; + 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 distinctDays) { + final ranges = []; + 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); + } +}