Add cxyz Android project structure and initial configuration
- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration. - Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files. - Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown. - Configured analysis options and dependencies in pubspec.yaml to support Flutter development. - Established a pnpm workspace and lockfile for dependency management. - Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
/// MangaTown mobile (`m.mangatown.com`) constants.
|
||||
library;
|
||||
|
||||
const mangaTownOrigin = 'https://m.mangatown.com';
|
||||
|
||||
const mangaTownMobileUa =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 '
|
||||
'(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
const mangaTownHtmlHeaders = <String, String>{
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'User-Agent': mangaTownMobileUa,
|
||||
};
|
||||
|
||||
const mangaTownImageHeaders = <String, String>{
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': '$mangaTownOrigin/',
|
||||
'User-Agent': mangaTownMobileUa,
|
||||
};
|
||||
|
||||
/// Site catalog page size (Latest / Hot / Directory).
|
||||
const mangaTownCatalogPageSize = 20;
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
|
||||
import '../source.dart';
|
||||
import 'constants.dart';
|
||||
import 'urls.dart';
|
||||
|
||||
/// MangaTown mobile HTML adapter (`m.mangatown.com`).
|
||||
class MangaTownSource implements ComicSource {
|
||||
MangaTownSource({Dio? dio})
|
||||
: _dio = dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
headers: mangaTownHtmlHeaders,
|
||||
responseType: ResponseType.plain,
|
||||
validateStatus: (s) => s != null && s >= 200 && s < 300,
|
||||
),
|
||||
);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _pageFetchConcurrency = 4;
|
||||
|
||||
@override
|
||||
SourceId get id => SourceId.mangatown;
|
||||
|
||||
@override
|
||||
String get displayName => 'MangaTown';
|
||||
|
||||
@override
|
||||
String get blurb =>
|
||||
'Latest, hot rankings, directory filters, and search via m.mangatown.com';
|
||||
|
||||
@override
|
||||
List<CatalogMenu> get catalogMenus => const [
|
||||
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'),
|
||||
CatalogMenu(kind: CatalogMenuKind.popular, title: 'Hot'),
|
||||
CatalogMenu(kind: CatalogMenuKind.hotToday, title: 'Hot Today'),
|
||||
CatalogMenu(kind: CatalogMenuKind.hotWeek, title: 'Hot Week'),
|
||||
CatalogMenu(kind: CatalogMenuKind.hotMonth, title: 'Hot Month'),
|
||||
CatalogMenu(kind: CatalogMenuKind.directory, title: 'Directory'),
|
||||
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
|
||||
];
|
||||
|
||||
@override
|
||||
Map<String, String> get imageHeaders =>
|
||||
Map.unmodifiable(mangaTownImageHeaders);
|
||||
|
||||
Future<String> _getHtml(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
String label = 'request',
|
||||
}) async {
|
||||
try {
|
||||
final res = await _dio.get<String>(
|
||||
url,
|
||||
options: Options(headers: headers ?? mangaTownHtmlHeaders),
|
||||
);
|
||||
return res.data ?? '';
|
||||
} on DioException catch (e) {
|
||||
throw StateError('MangaTown $label failed: ${e.message ?? e}');
|
||||
}
|
||||
}
|
||||
|
||||
/// CatalogFeedPage uses caller `limit` as page stride (like MangaRead).
|
||||
int _sitePage(int offset, int limit) {
|
||||
final stride = limit > 0 ? limit : mangaTownCatalogPageSize;
|
||||
return (offset ~/ stride) + 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CatalogComic>> loadCatalog(
|
||||
CatalogMenuKind kind, {
|
||||
String query = '',
|
||||
int offset = 0,
|
||||
int limit = 30,
|
||||
CatalogBrowseParams? browse,
|
||||
}) async {
|
||||
final page = _sitePage(offset, limit);
|
||||
late final String url;
|
||||
switch (kind) {
|
||||
case CatalogMenuKind.official:
|
||||
case CatalogMenuKind.uncensored:
|
||||
case CatalogMenuKind.recommended:
|
||||
throw UnsupportedError('MangaTown has no ${kind.name} catalog feed');
|
||||
case CatalogMenuKind.latest:
|
||||
url = catalogLatestUrl(page: page);
|
||||
case CatalogMenuKind.popular:
|
||||
case CatalogMenuKind.hotToday:
|
||||
case CatalogMenuKind.hotWeek:
|
||||
case CatalogMenuKind.hotMonth:
|
||||
final period = hotPeriodForMenu(kind)!;
|
||||
url = catalogHotUrl(period: period, page: page);
|
||||
case CatalogMenuKind.directory:
|
||||
url = directoryUrl(browse ?? const CatalogBrowseParams(), page: page);
|
||||
case CatalogMenuKind.search:
|
||||
final q = query.trim();
|
||||
if (q.length < 2) return const [];
|
||||
url = catalogSearchUrl(query: q, page: page);
|
||||
}
|
||||
|
||||
final html = await _getHtml(url, label: kind.name);
|
||||
return _parseCatalogCards(html, baseHref: url);
|
||||
}
|
||||
|
||||
List<CatalogComic> _parseCatalogCards(String html, {required String baseHref}) {
|
||||
final doc = html_parser.parse(html);
|
||||
final posts = doc.querySelectorAll('ul.post-list li div.post');
|
||||
final seen = <String>{};
|
||||
final out = <CatalogComic>[];
|
||||
|
||||
for (final post in posts) {
|
||||
final coverA = post.querySelector('a.manga-cover');
|
||||
final href = coverA?.attributes['href']?.trim() ??
|
||||
post.querySelector('a[href*="/manga/"]')?.attributes['href']?.trim();
|
||||
if (href == null || href.isEmpty) continue;
|
||||
final listingUrl = absoluteUrl(href, baseHref);
|
||||
if (!listingUrl.contains('/manga/')) continue;
|
||||
final slug = mangaSlugFromUrl(listingUrl);
|
||||
if (slug == 'comic' || !seen.add(slug)) continue;
|
||||
|
||||
var title = coverA?.attributes['rel']?.trim() ?? '';
|
||||
if (title.isEmpty) {
|
||||
title = post
|
||||
.querySelector('.post-info .title')
|
||||
?.text
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'\s+'), ' ') ??
|
||||
'';
|
||||
}
|
||||
if (title.isEmpty) title = slug;
|
||||
|
||||
String? coverUrl;
|
||||
final img = post.querySelector('img');
|
||||
final rawSrc = img?.attributes['src']?.trim();
|
||||
if (rawSrc != null && rawSrc.isNotEmpty && !rawSrc.startsWith('data:')) {
|
||||
coverUrl = absoluteUrl(rawSrc, baseHref);
|
||||
}
|
||||
|
||||
out.add(
|
||||
CatalogComic(
|
||||
source: SourceId.mangatown,
|
||||
slug: slug,
|
||||
title: title,
|
||||
listingUrl: listingUrlForSlug(slug),
|
||||
coverUrl: coverUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
String? _extractTitle(String html) {
|
||||
final doc = html_parser.parse(html);
|
||||
final t = doc
|
||||
.querySelector('.manga-detail-top .title')
|
||||
?.text
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (t != null && t.isNotEmpty) return t;
|
||||
final titleEl = doc.querySelector('title')?.text.trim() ?? '';
|
||||
final m = RegExp(
|
||||
r'Read\s+(.+?)\s+Manga',
|
||||
caseSensitive: false,
|
||||
).firstMatch(titleEl);
|
||||
return m?.group(1)?.trim();
|
||||
}
|
||||
|
||||
String? _extractCover(String html, String baseHref) {
|
||||
final doc = html_parser.parse(html);
|
||||
final img = doc.querySelector('img.detail-cover');
|
||||
final raw = img?.attributes['src']?.trim();
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return absoluteUrl(raw, baseHref);
|
||||
}
|
||||
|
||||
String? _extractDescription(String html) {
|
||||
final doc = html_parser.parse(html);
|
||||
final show = doc.querySelector('#show');
|
||||
if (show != null) {
|
||||
final clone = show.clone(true);
|
||||
clone.querySelectorAll('a').forEach((a) => a.remove());
|
||||
final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
final hide = doc.querySelector('#hide');
|
||||
if (hide != null) {
|
||||
final clone = hide.clone(true);
|
||||
clone.querySelectorAll('a').forEach((a) => a.remove());
|
||||
final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<ChapterLink> _extractChapters(String html, String baseHref) {
|
||||
final doc = html_parser.parse(html);
|
||||
final items = doc.querySelectorAll('ul.detail-ch-list li a');
|
||||
final chapters = <ChapterLink>[];
|
||||
for (final a in items) {
|
||||
final href = a.attributes['href']?.trim();
|
||||
if (href == null || href.isEmpty) continue;
|
||||
final clone = a.clone(true);
|
||||
clone.querySelectorAll('span').forEach((s) => s.remove());
|
||||
final title = clone.text.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
final url = absoluteUrl(href, baseHref);
|
||||
final key = chapterKeyFromUrl(url);
|
||||
chapters.add(
|
||||
ChapterLink(
|
||||
url: chapterBaseUrl(url),
|
||||
title: title.isNotEmpty ? title : key,
|
||||
chapterKey: key,
|
||||
chapterNumber: chapterNumberFromTitle(title),
|
||||
),
|
||||
);
|
||||
}
|
||||
return chapters;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SeriesDetail> fetchSeries(String listingUrl) async {
|
||||
final base = mangaBaseUrlFromListingUrl(listingUrl);
|
||||
final html = await _getHtml(base, label: 'series');
|
||||
var chapters = _extractChapters(html, base);
|
||||
if (chapters.length > 1) {
|
||||
chapters = chapters.reversed.toList(growable: false);
|
||||
}
|
||||
final slug = mangaSlugFromUrl(base);
|
||||
return SeriesDetail(
|
||||
source: SourceId.mangatown,
|
||||
listingUrl: listingUrlForSlug(slug),
|
||||
slug: slug,
|
||||
title: _extractTitle(html) ?? slug,
|
||||
coverUrl: _extractCover(html, base),
|
||||
description: _extractDescription(html),
|
||||
chapters: chapters,
|
||||
);
|
||||
}
|
||||
|
||||
int _pageCountFromHtml(String html) {
|
||||
final doc = html_parser.parse(html);
|
||||
final options = doc.querySelectorAll('select.index-page option');
|
||||
if (options.isNotEmpty) return options.length;
|
||||
final selects = doc.querySelectorAll('.ch-select select option');
|
||||
if (selects.isNotEmpty) return selects.length;
|
||||
final pager = doc.querySelector('#pager')?.text ?? '';
|
||||
final m = RegExp(r'/\s*(\d+)').firstMatch(pager);
|
||||
if (m != null) {
|
||||
return int.tryParse(m.group(1)!) ?? 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
String? _imageFromReaderHtml(String html, String baseHref) {
|
||||
final doc = html_parser.parse(html);
|
||||
dom.Element? img =
|
||||
doc.querySelector('#viewer img#image') ?? doc.querySelector('img#image');
|
||||
final raw = img?.attributes['src']?.trim();
|
||||
if (raw == null || raw.isEmpty || raw.startsWith('data:')) return null;
|
||||
return absoluteUrl(raw, baseHref);
|
||||
}
|
||||
|
||||
Future<List<String>> _fetchPagesParallel(
|
||||
String chapterBase,
|
||||
int fromPage,
|
||||
int total,
|
||||
) async {
|
||||
if (fromPage > total) return const [];
|
||||
final count = total - fromPage + 1;
|
||||
final urls = List<String?>.filled(count, null);
|
||||
var next = fromPage;
|
||||
Future<void> worker() async {
|
||||
while (true) {
|
||||
final i = next;
|
||||
next++;
|
||||
if (i > total) return;
|
||||
final pageUrl = chapterPageUrl(chapterBase, i);
|
||||
final html = await _getHtml(pageUrl, label: 'chapter page $i');
|
||||
urls[i - fromPage] = _imageFromReaderHtml(html, pageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
final workers = List.generate(
|
||||
_pageFetchConcurrency.clamp(1, count),
|
||||
(_) => worker(),
|
||||
);
|
||||
await Future.wait(workers);
|
||||
return urls.whereType<String>().toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> fetchPageUrls(String chapterUrl) async {
|
||||
final base = chapterBaseUrl(chapterUrl);
|
||||
final firstHtml = await _getHtml(base, label: 'chapter');
|
||||
final total = _pageCountFromHtml(firstHtml);
|
||||
final first = _imageFromReaderHtml(firstHtml, base);
|
||||
if (total <= 1) {
|
||||
return first == null ? const [] : [first];
|
||||
}
|
||||
final rest = await _fetchPagesParallel(base, 2, total);
|
||||
if (first == null) return rest;
|
||||
return [first, ...rest];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import '../source.dart';
|
||||
import 'constants.dart';
|
||||
|
||||
/// Hot ranking period for `/hot` feeds.
|
||||
enum MangaTownHotPeriod {
|
||||
total,
|
||||
today,
|
||||
week,
|
||||
month,
|
||||
}
|
||||
|
||||
String? hotPeriodQuery(MangaTownHotPeriod period) {
|
||||
switch (period) {
|
||||
case MangaTownHotPeriod.total:
|
||||
return null;
|
||||
case MangaTownHotPeriod.today:
|
||||
return 'tviews.za';
|
||||
case MangaTownHotPeriod.week:
|
||||
return 'wviews.za';
|
||||
case MangaTownHotPeriod.month:
|
||||
return 'mviews.za';
|
||||
}
|
||||
}
|
||||
|
||||
MangaTownHotPeriod? hotPeriodForMenu(CatalogMenuKind kind) {
|
||||
switch (kind) {
|
||||
case CatalogMenuKind.popular:
|
||||
return MangaTownHotPeriod.total;
|
||||
case CatalogMenuKind.hotToday:
|
||||
return MangaTownHotPeriod.today;
|
||||
case CatalogMenuKind.hotWeek:
|
||||
return MangaTownHotPeriod.week;
|
||||
case CatalogMenuKind.hotMonth:
|
||||
return MangaTownHotPeriod.month;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Page 1 uses the bare path; page 2+ appends `/{n}.html`.
|
||||
String _pagedPath(String path, int page) {
|
||||
final base = path.replaceAll(RegExp(r'/+$'), '');
|
||||
if (page <= 1) return base;
|
||||
return '$base/$page.html';
|
||||
}
|
||||
|
||||
Uri _withOptionalQuery(String path, String? queryToken) {
|
||||
final uri = Uri.parse('$mangaTownOrigin$path');
|
||||
if (queryToken == null || queryToken.isEmpty) return uri;
|
||||
// Site uses bare tokens like `?wviews.za` (no `=` value).
|
||||
return Uri.parse('${uri.toString()}?$queryToken');
|
||||
}
|
||||
|
||||
String catalogLatestUrl({int page = 1}) {
|
||||
return _withOptionalQuery(_pagedPath('/latest', page), null).toString();
|
||||
}
|
||||
|
||||
String catalogHotUrl({
|
||||
MangaTownHotPeriod period = MangaTownHotPeriod.total,
|
||||
int page = 1,
|
||||
}) {
|
||||
return _withOptionalQuery(
|
||||
_pagedPath('/hot', page),
|
||||
hotPeriodQuery(period),
|
||||
).toString();
|
||||
}
|
||||
|
||||
/// `/directory/{demographic}-{genre}-0-{status}-{initial}-0`
|
||||
String directoryUrl(
|
||||
CatalogBrowseParams browse, {
|
||||
int page = 1,
|
||||
}) {
|
||||
final demo = _seg(browse.demographic);
|
||||
final genre = _seg(browse.genre);
|
||||
final status = _seg(browse.status);
|
||||
final initial = _seg(browse.initial);
|
||||
final path = _pagedPath(
|
||||
'/directory/$demo-$genre-0-$status-$initial-0',
|
||||
page,
|
||||
);
|
||||
return '$mangaTownOrigin$path';
|
||||
}
|
||||
|
||||
String catalogSearchUrl({required String query, int page = 1}) {
|
||||
final path = _pagedPath('/search', page);
|
||||
return Uri.parse('$mangaTownOrigin$path').replace(
|
||||
queryParameters: {'name': query},
|
||||
).toString();
|
||||
}
|
||||
|
||||
String _seg(String raw) {
|
||||
final t = raw.trim();
|
||||
if (t.isEmpty) return '0';
|
||||
return t;
|
||||
}
|
||||
|
||||
String mangaSlugFromUrl(String pageUrl) {
|
||||
try {
|
||||
final parts =
|
||||
Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final mi = parts.indexOf('manga');
|
||||
if (mi != -1 && mi + 1 < parts.length) {
|
||||
return parts[mi + 1];
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'comic';
|
||||
}
|
||||
|
||||
String listingUrlForSlug(String slug, {String origin = mangaTownOrigin}) {
|
||||
final base = origin.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$base/manga/$slug';
|
||||
}
|
||||
|
||||
String mangaBaseUrlFromListingUrl(String pageUrl) {
|
||||
final u = Uri.parse(pageUrl);
|
||||
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final mi = parts.indexOf('manga');
|
||||
if (mi == -1 || mi + 1 >= parts.length) {
|
||||
throw StateError(
|
||||
'Could not find /manga/{slug}/ in URL; use a manga or chapter link from MangaTown.',
|
||||
);
|
||||
}
|
||||
final slug = parts[mi + 1];
|
||||
return Uri(scheme: u.scheme, host: u.host, path: '/manga/$slug').toString();
|
||||
}
|
||||
|
||||
/// Strip reader page suffixes like `/2.html` so we keep `/manga/{slug}/c005/`.
|
||||
String chapterBaseUrl(String chapterUrl) {
|
||||
final u = Uri.parse(chapterUrl);
|
||||
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
if (parts.isEmpty) return chapterUrl;
|
||||
final last = parts.last;
|
||||
if (RegExp(r'^\d+\.html$', caseSensitive: false).hasMatch(last)) {
|
||||
parts.removeLast();
|
||||
}
|
||||
final path = '/${parts.join('/')}/';
|
||||
return Uri(scheme: u.scheme, host: u.host, path: path).toString();
|
||||
}
|
||||
|
||||
/// 1-based reader page URL under a chapter base.
|
||||
String chapterPageUrl(String chapterBase, int pageIndex) {
|
||||
final base = chapterBaseUrl(chapterBase);
|
||||
if (pageIndex <= 1) return base;
|
||||
final trimmed = base.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$trimmed/$pageIndex.html';
|
||||
}
|
||||
|
||||
String chapterKeyFromUrl(String chapterUrl) {
|
||||
try {
|
||||
final segs = Uri.parse(chapterBaseUrl(chapterUrl))
|
||||
.pathSegments
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
return segs.isEmpty ? 'chapter' : segs.last;
|
||||
} catch (_) {
|
||||
return 'chapter';
|
||||
}
|
||||
}
|
||||
|
||||
String? chapterNumberFromTitle(String title) {
|
||||
final cleaned = title.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
final m = RegExp(
|
||||
r'(?:Chapter|Ch\.?|C\.?)\s*([\d.]+)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(cleaned);
|
||||
return m?.group(1);
|
||||
}
|
||||
|
||||
String absoluteUrl(String href, String baseHref) {
|
||||
final resolved = Uri.parse(baseHref).resolve(href.trim()).toString();
|
||||
if (resolved.startsWith('//')) {
|
||||
return 'https:$resolved';
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// Demographics offered by MangaTown directory filters.
|
||||
const mangaTownDemographics = <MapEntry<String, String>>[
|
||||
MapEntry('0', 'All'),
|
||||
MapEntry('josei', 'Josei'),
|
||||
MapEntry('seinen', 'Seinen'),
|
||||
MapEntry('shoujo', 'Shoujo'),
|
||||
MapEntry('shoujo_ai', 'Shoujo Ai'),
|
||||
MapEntry('shounen', 'Shounen'),
|
||||
MapEntry('shounen_ai', 'Shounen Ai'),
|
||||
MapEntry('yaoi', 'Yaoi'),
|
||||
MapEntry('yuri', 'Yuri'),
|
||||
];
|
||||
|
||||
/// Genre slug → label for directory filters.
|
||||
const mangaTownGenres = <MapEntry<String, String>>[
|
||||
MapEntry('0', 'All'),
|
||||
MapEntry('4_koma', '4 Koma'),
|
||||
MapEntry('action', 'Action'),
|
||||
MapEntry('adventure', 'Adventure'),
|
||||
MapEntry('comedy', 'Comedy'),
|
||||
MapEntry('cooking', 'Cooking'),
|
||||
MapEntry('doujinshi', 'Doujinshi'),
|
||||
MapEntry('drama', 'Drama'),
|
||||
MapEntry('ecchi', 'Ecchi'),
|
||||
MapEntry('fantasy', 'Fantasy'),
|
||||
MapEntry('gender_bender', 'Gender Bender'),
|
||||
MapEntry('harem', 'Harem'),
|
||||
MapEntry('historical', 'Historical'),
|
||||
MapEntry('horror', 'Horror'),
|
||||
MapEntry('martial_arts', 'Martial Arts'),
|
||||
MapEntry('mature', 'Mature'),
|
||||
MapEntry('mecha', 'Mecha'),
|
||||
MapEntry('music', 'Music'),
|
||||
MapEntry('mystery', 'Mystery'),
|
||||
MapEntry('one_shot', 'One Shot'),
|
||||
MapEntry('psychological', 'Psychological'),
|
||||
MapEntry('reverse_harem', 'Reverse Harem'),
|
||||
MapEntry('romance', 'Romance'),
|
||||
MapEntry('school_life', 'School Life'),
|
||||
MapEntry('sci_fi', 'Sci Fi'),
|
||||
MapEntry('shotacon', 'Shotacon'),
|
||||
MapEntry('slice_of_life', 'Slice Of Life'),
|
||||
MapEntry('smut', 'Smut'),
|
||||
MapEntry('sports', 'Sports'),
|
||||
MapEntry('supernatural', 'Supernatural'),
|
||||
MapEntry('suspense', 'Suspense'),
|
||||
MapEntry('tragedy', 'Tragedy'),
|
||||
MapEntry('vampire', 'Vampire'),
|
||||
MapEntry('webtoons', 'Webtoons'),
|
||||
MapEntry('youkai', 'Youkai'),
|
||||
];
|
||||
|
||||
const mangaTownStatuses = <MapEntry<String, String>>[
|
||||
MapEntry('0', 'All'),
|
||||
MapEntry('ongoing', 'Ongoing'),
|
||||
MapEntry('new', 'New'),
|
||||
MapEntry('completed', 'Completed'),
|
||||
];
|
||||
|
||||
/// A–Z plus `0-9`.
|
||||
List<MapEntry<String, String>> mangaTownInitials() {
|
||||
final out = <MapEntry<String, String>>[const MapEntry('0', 'All')];
|
||||
for (var c = 0; c < 26; c++) {
|
||||
final letter = String.fromCharCode('a'.codeUnitAt(0) + c);
|
||||
out.add(MapEntry(letter, letter.toUpperCase()));
|
||||
}
|
||||
out.add(const MapEntry('0-9', '0-9'));
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user