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/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,41 +45,76 @@ 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(
|
},
|
||||||
|
error: (error, stackTrace) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(l10n.calendar_no_data, textAlign: TextAlign.center),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const ProgressCenter(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(List<UpcomingUIModel> items, Map<DateTime, int> events) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
|
// Item type selector
|
||||||
|
if (_visibleTypes.length > 1)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Column(
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
_buildWarningTile(context),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: SegmentedButton(
|
child: SegmentedButton(
|
||||||
emptySelectionAllowed: true,
|
emptySelectionAllowed: true,
|
||||||
showSelectedIcon: false,
|
showSelectedIcon: false,
|
||||||
|
|
@ -105,324 +134,220 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
|
||||||
}).toList(),
|
}).toList(),
|
||||||
selected: {itemType?.index},
|
selected: {itemType?.index},
|
||||||
onSelectionChanged: (newSelection) {
|
onSelectionChanged: (newSelection) {
|
||||||
if (newSelection.isNotEmpty &&
|
if (newSelection.isNotEmpty && newSelection.first != null) {
|
||||||
newSelection.first != null) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
itemType =
|
itemType = ItemType.values[newSelection.first!];
|
||||||
ItemType.values[newSelection.first!];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
_buildCalendar(data, locale),
|
SliverToBoxAdapter(
|
||||||
const SizedBox(height: 15),
|
child: UpcomingCalendar(
|
||||||
],
|
selectedYearMonth: _selectedYearMonth,
|
||||||
),
|
events: events,
|
||||||
),
|
setSelectedYearMonth: (yearMonth) {
|
||||||
ValueListenableBuilder<List<Manga>>(
|
setState(() {
|
||||||
valueListenable: _selectedEntries,
|
_selectedYearMonth = yearMonth;
|
||||||
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(
|
onClickDay: (date) => _scrollToDate(date),
|
||||||
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(
|
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||||
manga: element,
|
|
||||||
selectedDay: _selectedDay,
|
// All upcoming items (date headers + manga tiles)
|
||||||
);
|
if (items.isEmpty)
|
||||||
},
|
SliverToBoxAdapter(
|
||||||
order: GroupedListOrder.ASC,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(child: const SizedBox(height: 15)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
error: (Object error, StackTrace stackTrace) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Text(l10n.calendar_no_data, textAlign: TextAlign.center),
|
child: Center(
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
loading: () {
|
|
||||||
return 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(
|
child: Text(
|
||||||
context.l10n.calendar_info,
|
l10n.calendar_no_data,
|
||||||
softWrap: true,
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
overflow: TextOverflow.clip,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
style: TextStyle(fontSize: 13, color: context.secondaryColor),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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,
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onPageChanged: (focusedDay) => _focusedDay = focusedDay,
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = manga.chapters
|
||||||
final lastChapter = e.chapters
|
|
||||||
.filter()
|
.filter()
|
||||||
.sortByDateUploadDesc()
|
.sortByDateUploadDesc()
|
||||||
.findFirstSync();
|
.findFirstSync();
|
||||||
final lastDate = int.tryParse(lastChapter?.dateUpload ?? "");
|
final lastChapterMs = int.tryParse(lastChapter?.dateUpload ?? '');
|
||||||
final start = lastDate != null
|
return FetchInterval.computeExpectedDate(
|
||||||
? DateTime.fromMillisecondsSinceEpoch(lastDate)
|
lastChapterDateMs: lastChapterMs,
|
||||||
: DateTime.now();
|
lastUpdateMs: manga.lastUpdate,
|
||||||
final temp = start.add(Duration(days: e.smartUpdateDays!));
|
interval: manga.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
|
||||||
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(
|
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: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
manga.name!,
|
dateFormat(
|
||||||
overflow: TextOverflow.ellipsis,
|
date.millisecondsSinceEpoch.toString(),
|
||||||
style: TextStyle(
|
context: context,
|
||||||
fontSize: 14,
|
ref: ref,
|
||||||
color: Theme.of(
|
useRelativeTimesTamps: true,
|
||||||
context,
|
showInDaysFuture: true,
|
||||||
).textTheme.bodyLarge!.color,
|
),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
const SizedBox(width: 8),
|
||||||
context.l10n.n_chapters(
|
Badge(
|
||||||
manga.chapters.countSync(),
|
backgroundColor: theme.colorScheme.primary,
|
||||||
),
|
textColor: theme.colorScheme.onPrimary,
|
||||||
overflow: TextOverflow.ellipsis,
|
label: Text('$mangaCount'),
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
color: context.secondaryColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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_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(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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