TLA Line data Source code
1 : // Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
2 : //
3 : // This file is part of Flipdare's proprietary software and contains
4 : // confidential and copyrighted material. Unauthorised copying,
5 : // modification, distribution, or use of this file is strictly
6 : // prohibited without prior written permission from Flipdare Pty Ltd.
7 : //
8 : // This software includes third-party components licensed under MIT,
9 : // BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
10 : //
11 :
12 : import 'dart:io' show File;
13 : import 'dart:io';
14 : import 'dart:ui' show Size;
15 :
16 : import 'package:core/admin/logging.dart';
17 : import 'package:core/helper/should_be_overridden.dart';
18 : import 'package:fpdart/fpdart.dart' show Either;
19 : import 'package:image_size_getter/file_input.dart';
20 : import 'package:image_size_getter/image_size_getter.dart' as size_getter;
21 : import 'package:core/admin/logging.dart' show LOG;
22 : import 'package:core/app_constants.dart';
23 : import 'package:core/constants/app_mime_type.dart';
24 : import 'package:core/extension/xfile_ext.dart';
25 : import 'package:core/file/file_helper.dart';
26 : import 'package:cross_file/cross_file.dart' show XFile;
27 : import 'package:flutter/material.dart' show Image;
28 : import 'package:video_player/video_player.dart';
29 :
30 : abstract class AppFile {
31 : const factory AppFile(XFile file) = AppXFile;
32 : const factory AppFile.path(String path) = AppPathFile;
33 :
34 HIT 1 : const AppFile._();
35 :
36 : @shouldBeOverridden
37 : XFile get xfile;
38 :
39 3 : String get path => xfile.path;
40 MIS 0 : String get name => FileHelper(xfile).name;
41 0 : File get asDartFile => xfile.asDartFile;
42 0 : Image get asImage => Image.file(xfile.asDartFile);
43 :
44 0 : int? get maxUploadSizeBytes {
45 0 : if (isSupportedVideo) {
46 : return kMaxVideoSizeBytes;
47 0 : } else if (isSupportedImage) {
48 : return kMaxImageSizeBytes;
49 : } else {
50 : return null;
51 : }
52 : }
53 :
54 : // Note: this checks the string path, not file properties.
55 0 : bool get isValidPath => FileHelper(xfile).isValidPath;
56 HIT 3 : bool get isPathEmpty => path.isEmpty;
57 MIS 0 : bool get hasSuffix => (suffix != null);
58 :
59 HIT 4 : String get nameWithoutExtension => FileHelper(xfile).nameWithoutExtension;
60 4 : String get fileName => FileHelper(xfile).name;
61 4 : String? get suffix => FileHelper(xfile).suffix;
62 :
63 1 : AppMimeType? get mimeType {
64 2 : if (fileName.isEmpty) return null;
65 2 : final helper = FileHelper(xfile);
66 :
67 1 : if (!helper.isValidPath) {
68 MIS 0 : LOG.e('File is not valid, cannot determine mimeType');
69 : return null;
70 : }
71 :
72 HIT 2 : final fileExt = helper.suffix?.toLowerCase();
73 : if (fileExt == null) {
74 2 : LOG.e('File has no suffix, cannot determine if it is supported');
75 : return null;
76 : }
77 :
78 1 : return AppMimeType.fromString(fileExt);
79 : }
80 :
81 MIS 0 : Future<int> fileSize() async {
82 0 : if (isPathEmpty) {
83 0 : LOG.e('File path is empty, cannot determine size');
84 0 : return -1;
85 : }
86 0 : return await xfile.length();
87 : }
88 :
89 0 : Future<bool> get isWithinUploadLimit async {
90 0 : final size = await fileSize();
91 0 : final maxSize = maxUploadSizeBytes;
92 : if (maxSize == null) {
93 0 : LOG.e('File $fileName has unsupported type, cannot determine upload limit');
94 : return false;
95 : }
96 0 : if (size < 0) {
97 0 : LOG.e('File $fileName has invalid size $size, cannot determine upload limit');
98 : return false;
99 : }
100 0 : if (size > maxSize) {
101 0 : LOG.w('File $fileName size $size exceeds upload limit of $maxSize bytes');
102 : return false;
103 : }
104 : return true;
105 : }
106 :
107 HIT 1 : bool get isSupported {
108 2 : final helper = FileHelper(xfile);
109 :
110 1 : if (!helper.isValidPath) {
111 MIS 0 : LOG.e('File is not valid, cannot determine if it is supported: $fileName');
112 : return false;
113 : }
114 :
115 HIT 1 : if (mimeType == null) {
116 4 : LOG.w('File mimeType is null, cannot be supported: $fileName');
117 : return false;
118 : }
119 :
120 2 : if (isSupportedImage || isSupportedVideo) {
121 4 : LOG.d('File is supported: $fileName');
122 : return true;
123 : } else {
124 MIS 0 : LOG.w('File is not supported: $fileName');
125 : return false;
126 : }
127 : }
128 :
129 HIT 1 : bool get isSupportedImage {
130 2 : final helper = FileHelper(xfile);
131 :
132 1 : if (!helper.isValidPath) {
133 MIS 0 : LOG.e('File is not valid, cannot determine if it is supported');
134 : return false;
135 : }
136 :
137 HIT 2 : final fileExt = helper.suffix?.toLowerCase();
138 : if (fileExt == null) {
139 MIS 0 : LOG.e('File has no suffix, cannot determine if it is supported');
140 : return false;
141 : }
142 :
143 HIT 1 : final result = AppMimeType.fromString(fileExt);
144 : if (result == null) {
145 MIS 0 : LOG.w('File extension is not a supported image format: $fileExt');
146 : return false;
147 : }
148 HIT 1 : return result.isSupportedImage;
149 : }
150 :
151 1 : bool get isSupportedVideo {
152 2 : final helper = FileHelper(xfile);
153 :
154 1 : if (!helper.isValidPath) {
155 MIS 0 : LOG.e('File is not valid, cannot determine if it is supported');
156 : return false;
157 : }
158 :
159 HIT 2 : final fileExt = helper.suffix?.toLowerCase();
160 : if (fileExt == null) {
161 MIS 0 : LOG.e('File has no suffix, cannot determine if it is supported');
162 : return false;
163 : }
164 :
165 HIT 1 : final result = AppMimeType.fromString(fileExt);
166 : if (result == null) {
167 MIS 0 : LOG.w('File extension is not a supported video format: $fileExt');
168 : return false;
169 : }
170 HIT 1 : return result.isSupportedVideo;
171 : }
172 :
173 : // coverage:ignore-start
174 : String get debugStr {
175 : return '[name=$nameWithoutExtension, '
176 : 'ext=$suffix, path=$path, mimeType=$mimeType]';
177 : }
178 : }
179 :
180 : // coverage:ignore-end
181 :
182 : abstract class AppContentFile extends AppFile {
183 : final Either<XFile, String> _file;
184 :
185 MIS 0 : static AppContentFile content(Either<XFile, String> file) {
186 0 : final fileCheck = file.fold((xfile) => AppXFile(xfile), (path) => AppPathFile(path));
187 0 : if (fileCheck.isSupportedVideo) {
188 0 : return AppContentFile._video(file);
189 : } else {
190 0 : return AppContentFile._image(file);
191 : }
192 : }
193 :
194 0 : static AppContentFile? image(Either<XFile, String> file) {
195 0 : final contentFile = AppContentFile._image(file);
196 0 : if (!contentFile.isSupportedImage) {
197 0 : LOG.w('File is not a supported image: ${contentFile.debugStr}');
198 : return null;
199 : }
200 : return contentFile;
201 : }
202 :
203 0 : static AppContentFile? video(Either<XFile, String> file) {
204 0 : final contentFile = AppContentFile._video(file);
205 0 : if (!contentFile.isSupportedVideo) {
206 0 : LOG.w('File is not a supported video: ${contentFile.debugStr}');
207 : return null;
208 : }
209 : return contentFile;
210 : }
211 :
212 HIT 2 : const AppContentFile._(this._file) : super._();
213 : const factory AppContentFile._image(Either<XFile, String> file) = AppImageFile;
214 : const factory AppContentFile._video(Either<XFile, String> file) = AppVideoFile;
215 :
216 1 : @override
217 2 : XFile get xfile => _file.fold(
218 MIS 0 : (file) => file,
219 HIT 2 : (path) => XFile(path),
220 : );
221 :
222 : @shouldBeOverridden
223 : bool get isImage;
224 :
225 : @shouldBeOverridden
226 : bool get isVideo;
227 :
228 : @shouldBeOverridden
229 : Size? get dimension;
230 : }
231 :
232 : class AppXFile extends AppFile {
233 : final XFile _file;
234 :
235 2 : const AppXFile(this._file) : super._();
236 :
237 1 : @override
238 1 : XFile get xfile => _file;
239 : }
240 :
241 : class AppPathFile extends AppFile {
242 : final String _path;
243 :
244 2 : const AppPathFile(this._path) : super._();
245 :
246 1 : @override
247 2 : XFile get xfile => XFile(_path);
248 : }
249 :
250 : class AppImageFile extends AppContentFile {
251 : final Size? _size;
252 :
253 MIS 0 : const AppImageFile(super.file) : _size = null, super._();
254 :
255 0 : const AppImageFile.withInfo(super.file, this._size) : super._();
256 :
257 0 : @override
258 0 : bool get isImage => (mimeType != null) ? mimeType!.isSupportedImage : false;
259 :
260 0 : @override
261 : bool get isVideo => false;
262 :
263 0 : @override
264 : Size? get dimension {
265 0 : if (_size != null) {
266 0 : return _size;
267 : }
268 :
269 : try {
270 0 : final result = size_getter.ImageSizeGetter.getSizeResult(
271 0 : FileInput(asDartFile),
272 : );
273 0 : final size = result.size;
274 0 : final actualSize = Size(size.width.roundToDouble(), size.height.roundToDouble());
275 0 : LOG.d('Image size from metadata for $path is $actualSize');
276 : return actualSize;
277 : } catch (e) {
278 0 : LOG.e('Error getting image size from metadata for $path: $e');
279 : return null;
280 : }
281 : }
282 : }
283 :
284 : class AppVideoFile extends AppContentFile {
285 : final Size? _size;
286 : final Duration? _duration;
287 :
288 0 : const AppVideoFile(super.file) : _size = null, _duration = null, super._();
289 :
290 HIT 2 : const AppVideoFile.withInfo(super.file, this._size, this._duration) : super._();
291 :
292 1 : @override
293 3 : bool get isVideo => (mimeType != null) ? mimeType!.isSupportedVideo : false;
294 MIS 0 : @override
295 : bool get isImage => false;
296 :
297 0 : @override
298 : Size? get dimension {
299 0 : if (_size != null) {
300 0 : return _size;
301 : }
302 :
303 : try {
304 0 : final controller = VideoPlayerController.file(xfile.asDartFile)..initialize();
305 0 : final videoInfo = controller.value;
306 0 : if (!videoInfo.isInitialized) {
307 : return null;
308 : }
309 :
310 0 : final size = videoInfo.size;
311 0 : LOG.d('Image size from metadata for $path is $size');
312 : return size;
313 : } catch (e) {
314 0 : LOG.e('Error getting image size from metadata for $path: $e');
315 : return null;
316 : }
317 : }
318 :
319 HIT 1 : List<int> get timePositions {
320 1 : if (!isVideo) {
321 MIS 0 : return [];
322 : }
323 :
324 : int videoDurationSec;
325 :
326 HIT 1 : if (_duration != null) {
327 2 : videoDurationSec = _duration.inSeconds;
328 : } else {
329 MIS 0 : videoDurationSec = VideoPlayerController.file(xfile.asDartFile).value.duration.inSeconds;
330 : }
331 :
332 HIT 1 : if (videoDurationSec <= 0) {
333 MIS 0 : LOG.e('Video duration is invalid for $path');
334 0 : return [];
335 : }
336 :
337 : // Generate 10 evenly spaced time positions
338 : final frameCount = 10;
339 HIT 1 : if (videoDurationSec <= frameCount) {
340 : // If video is shorter than frame count, return each second
341 2 : return List<int>.generate(videoDurationSec, (index) => index);
342 : }
343 :
344 2 : final interval = videoDurationSec ~/ (frameCount + 1);
345 4 : final positions = List<int>.generate(frameCount, (index) => interval * (index + 1));
346 :
347 : return positions;
348 : }
349 : }
|