chore: initialize Guangya Flutter project
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Cloud file model representing a file or folder in the cloud drive.
|
||||
class CloudFile {
|
||||
static const supportedVideoExtensions = {
|
||||
'mp4',
|
||||
'm4v',
|
||||
'mkv',
|
||||
'mov',
|
||||
'avi',
|
||||
'ts',
|
||||
'm2ts',
|
||||
'mts',
|
||||
'webm',
|
||||
'flv',
|
||||
'f4v',
|
||||
'wmv',
|
||||
'asf',
|
||||
'mpg',
|
||||
'mpeg',
|
||||
'vob',
|
||||
'iso',
|
||||
'rm',
|
||||
'rmvb',
|
||||
'3gp',
|
||||
'ogv',
|
||||
};
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final bool isDirectory;
|
||||
final int? size;
|
||||
final String? gcid;
|
||||
final int? subDirectoryCount;
|
||||
final int? subFileCount;
|
||||
final String modifiedAt;
|
||||
final String cloudPath;
|
||||
final int fileType;
|
||||
|
||||
const CloudFile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.isDirectory,
|
||||
this.size,
|
||||
this.gcid,
|
||||
this.subDirectoryCount,
|
||||
this.subFileCount,
|
||||
this.modifiedAt = '',
|
||||
this.cloudPath = '',
|
||||
this.fileType = 0,
|
||||
});
|
||||
|
||||
bool get isVideo {
|
||||
if (isDirectory) return false;
|
||||
if (fileType == 2) return true;
|
||||
final ext = name.split('.').last.toLowerCase();
|
||||
return supportedVideoExtensions.contains(ext);
|
||||
}
|
||||
|
||||
bool get isImage => fileType == 1;
|
||||
bool get isAudio => fileType == 3;
|
||||
bool get isDocument => fileType == 4;
|
||||
|
||||
String get icon {
|
||||
if (isDirectory) return 'folder';
|
||||
if (isVideo) return 'movie';
|
||||
switch (fileType) {
|
||||
case 1:
|
||||
return 'image';
|
||||
case 2:
|
||||
return 'movie';
|
||||
case 3:
|
||||
return 'music_note';
|
||||
case 4:
|
||||
return 'description';
|
||||
case 5:
|
||||
case 9:
|
||||
return 'archive';
|
||||
default:
|
||||
return 'insert_drive_file';
|
||||
}
|
||||
}
|
||||
|
||||
String get typeName {
|
||||
if (isDirectory) return '文件夹';
|
||||
if (isVideo) return '视频';
|
||||
const names = {1: '图片', 2: '视频', 3: '音频', 4: '文档', 5: '压缩包', 9: 'BT种子'};
|
||||
return names[fileType] ?? '文件';
|
||||
}
|
||||
|
||||
String get formattedSize {
|
||||
if (size == null) return '--';
|
||||
return _formatBytes(size!);
|
||||
}
|
||||
|
||||
static String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
|
||||
factory CloudFile.fromJson(Map<String, dynamic> json) {
|
||||
final name =
|
||||
json['name'] ??
|
||||
json['fileName'] ??
|
||||
json['file_name'] ??
|
||||
json['resName'] ??
|
||||
json['res_name'] ??
|
||||
json['dirName'] ??
|
||||
json['dir_name'] ??
|
||||
'';
|
||||
final id = _extractId(json);
|
||||
if (id.isEmpty) throw FormatException('Missing file ID');
|
||||
|
||||
final resourceType = _extractInt(json, ['resType']);
|
||||
final type = _extractInt(json, ['fileType', 'type']) ?? 0;
|
||||
|
||||
bool isDir;
|
||||
final explicitDir = json['isDir'] ?? json['dir'] ?? json['directoryType'];
|
||||
if (explicitDir != null) {
|
||||
isDir = _truthyDirectoryFlag(explicitDir);
|
||||
} else if (resourceType != null) {
|
||||
isDir = resourceType == 2;
|
||||
} else {
|
||||
isDir =
|
||||
type == 0 && (json['dirName'] != null || json['children'] != null);
|
||||
}
|
||||
|
||||
int? fileSize = _extractIntDeep(json, [
|
||||
'size',
|
||||
'fileSize',
|
||||
'resSize',
|
||||
'totalSize',
|
||||
'dirSize',
|
||||
'folderSize',
|
||||
]);
|
||||
if (isDir && fileSize == null) fileSize = 0;
|
||||
final epoch = _extractIntDeep(json, ['utime', 'ctime']);
|
||||
|
||||
return CloudFile(
|
||||
id: id,
|
||||
name: name.toString(),
|
||||
isDirectory: isDir,
|
||||
size: fileSize,
|
||||
gcid: _extractStringDeep(json, ['gcid', 'gcId', 'gcidValue', 'hash']),
|
||||
subDirectoryCount: _extractIntDeep(json, ['subDirCount']),
|
||||
subFileCount: _extractIntDeep(json, ['subFileCount']),
|
||||
modifiedAt:
|
||||
json['updateTime']?.toString() ??
|
||||
json['updatedAt']?.toString() ??
|
||||
json['modifyTime']?.toString() ??
|
||||
json['createTime']?.toString() ??
|
||||
(epoch == null ? null : _formatEpoch(epoch)) ??
|
||||
'',
|
||||
cloudPath:
|
||||
_extractString(json, ['location', 'path', 'fullPath']) ??
|
||||
name.toString(),
|
||||
fileType: type,
|
||||
);
|
||||
}
|
||||
|
||||
static String _extractId(Map<String, dynamic> json) {
|
||||
for (final key in ['fileId', 'file_id', 'resId', 'res_id', 'fid', 'id']) {
|
||||
final v = json[key];
|
||||
if (v != null && v.toString().isNotEmpty) return v.toString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static String? _extractString(Map<String, dynamic> json, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final v = json[key];
|
||||
if (v != null) return v.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int? _extractInt(Map<String, dynamic> json, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final v = json[key];
|
||||
final value = _toInt(v);
|
||||
if (value != null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _extractStringDeep(
|
||||
Map<String, dynamic> json,
|
||||
List<String> keys,
|
||||
) {
|
||||
final direct = _extractString(json, keys);
|
||||
if (direct != null && direct.isNotEmpty) return direct;
|
||||
for (final entry in json.entries) {
|
||||
final value = entry.value;
|
||||
if (value is Map<String, dynamic>) {
|
||||
final found = _extractStringDeep(value, keys);
|
||||
if (found != null && found.isNotEmpty) return found;
|
||||
} else if (value is List) {
|
||||
for (final item in value) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final found = _extractStringDeep(item, keys);
|
||||
if (found != null && found.isNotEmpty) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int? _extractIntDeep(Map<String, dynamic> json, List<String> keys) {
|
||||
final direct = _extractInt(json, keys);
|
||||
if (direct != null) return direct;
|
||||
for (final entry in json.entries) {
|
||||
final value = entry.value;
|
||||
if (value is Map<String, dynamic>) {
|
||||
final found = _extractIntDeep(value, keys);
|
||||
if (found != null) return found;
|
||||
} else if (value is List) {
|
||||
for (final item in value) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final found = _extractIntDeep(item, keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int? _toInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is double) return value.toInt();
|
||||
return int.tryParse(value.toString());
|
||||
}
|
||||
|
||||
static bool _truthyDirectoryFlag(dynamic value) {
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value == 1;
|
||||
final text = value.toString().toLowerCase();
|
||||
return text == '1' || text == 'true' || text == 'folder' || text == 'dir';
|
||||
}
|
||||
|
||||
static String _formatEpoch(int epoch) {
|
||||
final seconds = epoch > 9999999999 ? epoch ~/ 1000 : epoch;
|
||||
return DateFormat(
|
||||
'yyyy-MM-dd HH:mm',
|
||||
).format(DateTime.fromMillisecondsSinceEpoch(seconds * 1000));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || other is CloudFile && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import 'cloud_file.dart';
|
||||
|
||||
enum TMDBMediaKind { automatic, movie, tv }
|
||||
|
||||
extension TMDBMediaKindX on TMDBMediaKind {
|
||||
String get title {
|
||||
switch (this) {
|
||||
case TMDBMediaKind.automatic:
|
||||
return '自动识别';
|
||||
case TMDBMediaKind.movie:
|
||||
return '电影';
|
||||
case TMDBMediaKind.tv:
|
||||
return '剧集';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaLibraryKind { movies, series, mixed }
|
||||
|
||||
extension MediaLibraryKindX on MediaLibraryKind {
|
||||
String get title {
|
||||
switch (this) {
|
||||
case MediaLibraryKind.movies:
|
||||
return '电影';
|
||||
case MediaLibraryKind.series:
|
||||
return '电视剧';
|
||||
case MediaLibraryKind.mixed:
|
||||
return '混合内容';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MediaLibrarySource {
|
||||
final String id;
|
||||
final String? rootID;
|
||||
final String path;
|
||||
|
||||
const MediaLibrarySource({
|
||||
required this.id,
|
||||
required this.rootID,
|
||||
required this.path,
|
||||
});
|
||||
|
||||
factory MediaLibrarySource.fromJson(Map<String, dynamic> json) {
|
||||
return MediaLibrarySource(
|
||||
id:
|
||||
json['id']?.toString() ??
|
||||
DateTime.now().microsecondsSinceEpoch.toString(),
|
||||
rootID: json['rootID']?.toString(),
|
||||
path: json['path']?.toString() ?? '未配置目录',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {'id': id, 'rootID': rootID, 'path': path};
|
||||
}
|
||||
|
||||
class MediaLibraryDefinition {
|
||||
final String id;
|
||||
final String name;
|
||||
final List<MediaLibrarySource> sources;
|
||||
final MediaLibraryKind kind;
|
||||
final bool recursive;
|
||||
final int minimumSizeMB;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const MediaLibraryDefinition({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.sources,
|
||||
this.kind = MediaLibraryKind.mixed,
|
||||
this.recursive = true,
|
||||
this.minimumSizeMB = 50,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
String? get rootID => sources.isEmpty ? null : sources.first.rootID;
|
||||
|
||||
String get rootPath {
|
||||
if (sources.length == 1) return sources.first.path;
|
||||
if (sources.isEmpty) return '未配置目录';
|
||||
return '${sources.length} 个媒体目录';
|
||||
}
|
||||
|
||||
MediaLibraryDefinition copyWith({
|
||||
String? name,
|
||||
List<MediaLibrarySource>? sources,
|
||||
MediaLibraryKind? kind,
|
||||
bool? recursive,
|
||||
int? minimumSizeMB,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return MediaLibraryDefinition(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
sources: sources ?? this.sources,
|
||||
kind: kind ?? this.kind,
|
||||
recursive: recursive ?? this.recursive,
|
||||
minimumSizeMB: minimumSizeMB ?? this.minimumSizeMB,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
factory MediaLibraryDefinition.fromJson(Map<String, dynamic> json) {
|
||||
final rawSources = json['sources'];
|
||||
final sources = rawSources is List
|
||||
? rawSources
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => MediaLibrarySource.fromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
: [
|
||||
MediaLibrarySource(
|
||||
id: '${json['id'] ?? DateTime.now().microsecondsSinceEpoch}-legacy',
|
||||
rootID: json['rootID']?.toString(),
|
||||
path: json['rootPath']?.toString() ?? '未配置目录',
|
||||
),
|
||||
];
|
||||
return MediaLibraryDefinition(
|
||||
id:
|
||||
json['id']?.toString() ??
|
||||
DateTime.now().microsecondsSinceEpoch.toString(),
|
||||
name: json['name']?.toString() ?? '未命名媒体库',
|
||||
sources: sources,
|
||||
kind: MediaLibraryKind.values.firstWhere(
|
||||
(kind) => kind.name == json['kind']?.toString(),
|
||||
orElse: () => MediaLibraryKind.mixed,
|
||||
),
|
||||
recursive: json['recursive'] != false,
|
||||
minimumSizeMB: _toInt(json['minimumSizeMB']) ?? 50,
|
||||
updatedAt: _parseDate(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'sources': sources.map((source) => source.toJson()).toList(),
|
||||
'kind': kind.name,
|
||||
'recursive': recursive,
|
||||
'minimumSizeMB': minimumSizeMB,
|
||||
'updatedAt': updatedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
class MediaLibraryItem {
|
||||
final String libraryID;
|
||||
final CloudFile file;
|
||||
final int? tmdbID;
|
||||
final String title;
|
||||
final String originalTitle;
|
||||
final TMDBMediaKind? mediaKind;
|
||||
final String releaseDate;
|
||||
final String overview;
|
||||
final String? posterPath;
|
||||
final String? backdropPath;
|
||||
final bool hasChineseAudio;
|
||||
final bool hasChineseSubtitle;
|
||||
final int? collectionID;
|
||||
final String? collectionName;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const MediaLibraryItem({
|
||||
required this.libraryID,
|
||||
required this.file,
|
||||
this.tmdbID,
|
||||
required this.title,
|
||||
required this.originalTitle,
|
||||
this.mediaKind,
|
||||
this.releaseDate = '',
|
||||
this.overview = '',
|
||||
this.posterPath,
|
||||
this.backdropPath,
|
||||
this.hasChineseAudio = false,
|
||||
this.hasChineseSubtitle = false,
|
||||
this.collectionID,
|
||||
this.collectionName,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
String get id => file.id;
|
||||
String get year => releaseDate.length >= 4 ? releaseDate.substring(0, 4) : '';
|
||||
bool get isMatched => mediaKind != null && tmdbID != null;
|
||||
|
||||
factory MediaLibraryItem.fromFile(String libraryID, CloudFile file) {
|
||||
final parsed = ParsedMediaName.parse(file.name);
|
||||
final kind = parsed.isEpisode ? TMDBMediaKind.tv : TMDBMediaKind.movie;
|
||||
return MediaLibraryItem(
|
||||
libraryID: libraryID,
|
||||
file: file,
|
||||
title: parsed.title,
|
||||
originalTitle: parsed.title,
|
||||
mediaKind: kind,
|
||||
releaseDate: parsed.year == null ? '' : '${parsed.year}-01-01',
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
factory MediaLibraryItem.fromJson(Map<String, dynamic> json) {
|
||||
return MediaLibraryItem(
|
||||
libraryID: json['libraryID']?.toString() ?? '',
|
||||
file: CloudFile(
|
||||
id: json['fileID']?.toString() ?? '',
|
||||
name: json['cloudName']?.toString() ?? '',
|
||||
isDirectory: false,
|
||||
size: _toInt(json['fileSize']),
|
||||
gcid: json['gcid']?.toString(),
|
||||
modifiedAt: json['modifiedAt']?.toString() ?? '',
|
||||
cloudPath: json['resourcePath']?.toString() ?? '',
|
||||
fileType: _toInt(json['fileType']) ?? 2,
|
||||
),
|
||||
tmdbID: _toInt(json['tmdbID']),
|
||||
title: json['title']?.toString() ?? '',
|
||||
originalTitle: json['originalTitle']?.toString() ?? '',
|
||||
mediaKind: TMDBMediaKind.values
|
||||
.where((kind) => kind.name == json['mediaKind'])
|
||||
.firstOrNull,
|
||||
releaseDate: json['releaseDate']?.toString() ?? '',
|
||||
overview: json['overview']?.toString() ?? '',
|
||||
posterPath: json['posterPath']?.toString(),
|
||||
backdropPath: json['backdropPath']?.toString(),
|
||||
hasChineseAudio: json['hasChineseAudio'] == true,
|
||||
hasChineseSubtitle: json['hasChineseSubtitle'] == true,
|
||||
collectionID: _toInt(json['collectionID']),
|
||||
collectionName: json['collectionName']?.toString(),
|
||||
updatedAt: _parseDate(json['updatedAt']) ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'libraryID': libraryID,
|
||||
'fileID': file.id,
|
||||
'resourcePath': file.cloudPath,
|
||||
'cloudName': file.name,
|
||||
'fileSize': file.size,
|
||||
'gcid': file.gcid,
|
||||
'fileType': file.fileType,
|
||||
'modifiedAt': file.modifiedAt,
|
||||
'tmdbID': tmdbID,
|
||||
'mediaKind': mediaKind?.name,
|
||||
'title': title,
|
||||
'originalTitle': originalTitle,
|
||||
'releaseDate': releaseDate,
|
||||
'overview': overview,
|
||||
'posterPath': posterPath,
|
||||
'backdropPath': backdropPath,
|
||||
'hasChineseAudio': hasChineseAudio,
|
||||
'hasChineseSubtitle': hasChineseSubtitle,
|
||||
'collectionID': collectionID,
|
||||
'collectionName': collectionName,
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
class MediaLibraryStatistics {
|
||||
final int total;
|
||||
final int movies;
|
||||
final int series;
|
||||
final int unmatched;
|
||||
final int collections;
|
||||
|
||||
const MediaLibraryStatistics({
|
||||
this.total = 0,
|
||||
this.movies = 0,
|
||||
this.series = 0,
|
||||
this.unmatched = 0,
|
||||
this.collections = 0,
|
||||
});
|
||||
|
||||
factory MediaLibraryStatistics.fromItems(Iterable<MediaLibraryItem> items) {
|
||||
var total = 0;
|
||||
var movies = 0;
|
||||
var series = 0;
|
||||
var unmatched = 0;
|
||||
final collections = <String>{};
|
||||
for (final item in items) {
|
||||
total++;
|
||||
if (item.mediaKind == TMDBMediaKind.movie) movies++;
|
||||
if (item.mediaKind == TMDBMediaKind.tv) series++;
|
||||
if (!item.isMatched) unmatched++;
|
||||
final collectionKey =
|
||||
item.collectionID?.toString() ?? item.collectionName;
|
||||
if (collectionKey != null && collectionKey.isNotEmpty) {
|
||||
collections.add(collectionKey);
|
||||
}
|
||||
}
|
||||
return MediaLibraryStatistics(
|
||||
total: total,
|
||||
movies: movies,
|
||||
series: series,
|
||||
unmatched: unmatched,
|
||||
collections: collections.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaLibraryScanProgress {
|
||||
final String phase;
|
||||
final int completed;
|
||||
final int total;
|
||||
|
||||
const MediaLibraryScanProgress({
|
||||
this.phase = '',
|
||||
this.completed = 0,
|
||||
this.total = 0,
|
||||
});
|
||||
}
|
||||
|
||||
class ParsedMediaName {
|
||||
final String title;
|
||||
final int? year;
|
||||
final int? season;
|
||||
final int? episode;
|
||||
final bool isEpisode;
|
||||
|
||||
const ParsedMediaName({
|
||||
required this.title,
|
||||
this.year,
|
||||
this.season,
|
||||
this.episode,
|
||||
this.isEpisode = false,
|
||||
});
|
||||
|
||||
factory ParsedMediaName.parse(String name) {
|
||||
var stem = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
||||
stem = stem.replaceAll(RegExp(r'[\._]+'), ' ');
|
||||
|
||||
final episodeMatch =
|
||||
RegExp(
|
||||
r'(?:S|第)\s*(\d{1,2})\s*(?:E|季\s*第?)\s*(\d{1,3})',
|
||||
caseSensitive: false,
|
||||
).firstMatch(stem) ??
|
||||
RegExp(r'(\d{1,2})x(\d{1,3})', caseSensitive: false).firstMatch(stem);
|
||||
final yearMatch = RegExp(r'(19\d{2}|20\d{2})').firstMatch(stem);
|
||||
|
||||
final cutIndex =
|
||||
[
|
||||
episodeMatch?.start,
|
||||
yearMatch?.start,
|
||||
RegExp(
|
||||
r'\b(2160p|1080p|720p|bluray|web[- ]?dl|hdtv|x264|x265|hevc)\b',
|
||||
caseSensitive: false,
|
||||
).firstMatch(stem)?.start,
|
||||
].whereType<int>().fold<int?>(
|
||||
null,
|
||||
(min, value) => min == null || value < min ? value : min,
|
||||
);
|
||||
|
||||
var title = cutIndex == null ? stem : stem.substring(0, cutIndex);
|
||||
title = title
|
||||
.replaceAll(RegExp(r'[\[\]\(\)]'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
if (title.isEmpty) title = stem.trim();
|
||||
|
||||
return ParsedMediaName(
|
||||
title: title,
|
||||
year: yearMatch == null ? null : int.tryParse(yearMatch.group(1)!),
|
||||
season: episodeMatch == null
|
||||
? null
|
||||
: int.tryParse(episodeMatch.group(1)!),
|
||||
episode: episodeMatch == null
|
||||
? null
|
||||
: int.tryParse(episodeMatch.group(2)!),
|
||||
isEpisode: episodeMatch != null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int? _toInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is double) return value.toInt();
|
||||
return int.tryParse(value.toString());
|
||||
}
|
||||
|
||||
DateTime? _parseDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is DateTime) return value;
|
||||
return DateTime.tryParse(value.toString());
|
||||
}
|
||||
|
||||
extension _FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
Reference in New Issue
Block a user