mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-03-11 17:25:32 +00:00
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:
parent
7944c6f055
commit
95a55d5f81
11 changed files with 960 additions and 376 deletions
|
|
@ -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<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 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
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -51,122 +45,54 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
|
|||
} 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<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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
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<CalendarScreen> {
|
|||
),
|
||||
);
|
||||
},
|
||||
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<UpcomingUIModel> items, Map<DateTime, int> 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<Manga> 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<Manga> 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<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 = {};
|
||||
|
||||
List<Manga> _getEntriesForDay(DateTime day, List<Manga> 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<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);
|
||||
/// 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<Manga> 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<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),
|
||||
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<UpcomingUIModel> items;
|
||||
final Map<DateTime, int> events;
|
||||
final Map<DateTime, int> 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
41
lib/modules/calendar/models/upcoming_ui_model.dart
Normal file
41
lib/modules/calendar/models/upcoming_ui_model.dart
Normal 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;
|
||||
}
|
||||
73
lib/modules/calendar/widgets/calendar_day.dart
Normal file
73
lib/modules/calendar/widgets/calendar_day.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
63
lib/modules/calendar/widgets/calendar_header.dart
Normal file
63
lib/modules/calendar/widgets/calendar_header.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/modules/calendar/widgets/calendar_indicator.dart
Normal file
30
lib/modules/calendar/widgets/calendar_indicator.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/modules/calendar/widgets/upcoming_calendar.dart
Normal file
153
lib/modules/calendar/widgets/upcoming_calendar.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
79
lib/modules/calendar/widgets/upcoming_item.dart
Normal file
79
lib/modules/calendar/widgets/upcoming_item.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<dynamic> updateMangaDetail(
|
|||
oldChap.manga.saveSync();
|
||||
}
|
||||
}
|
||||
final List<int> 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -38,11 +38,12 @@ extension LetExtension<T> on T {
|
|||
|
||||
extension MedianExtension on List<int> {
|
||||
int median() {
|
||||
var middle = length ~/ 2;
|
||||
if (length % 2 == 1) {
|
||||
return this[middle];
|
||||
final sorted = List<int>.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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
233
lib/utils/fetch_interval.dart
Normal file
233
lib/utils/fetch_interval.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue