file_info_widget.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import "package:exif/exif.dart";
  2. import "package:flutter/cupertino.dart";
  3. import "package:flutter/material.dart";
  4. import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';
  5. import 'package:path/path.dart' as path;
  6. import 'package:photo_manager/photo_manager.dart';
  7. import "package:photos/core/configuration.dart";
  8. import 'package:photos/db/files_db.dart';
  9. import "package:photos/ente_theme_data.dart";
  10. import "package:photos/models/file.dart";
  11. import "package:photos/models/file_type.dart";
  12. import 'package:photos/services/collections_service.dart';
  13. import "package:photos/services/feature_flag_service.dart";
  14. import 'package:photos/theme/ente_theme.dart';
  15. import 'package:photos/ui/components/divider_widget.dart';
  16. import 'package:photos/ui/components/icon_button_widget.dart';
  17. import 'package:photos/ui/components/title_bar_widget.dart';
  18. import 'package:photos/ui/viewer/file/collections_list_of_file_widget.dart';
  19. import 'package:photos/ui/viewer/file/device_folders_list_of_file_widget.dart';
  20. import 'package:photos/ui/viewer/file/file_caption_widget.dart';
  21. import "package:photos/ui/viewer/file/object_tags_widget.dart";
  22. import 'package:photos/ui/viewer/file/raw_exif_list_tile_widget.dart';
  23. import "package:photos/utils/date_time_util.dart";
  24. import "package:photos/utils/exif_util.dart";
  25. import "package:photos/utils/file_util.dart";
  26. import "package:photos/utils/magic_util.dart";
  27. class FileInfoWidget extends StatefulWidget {
  28. final File file;
  29. const FileInfoWidget(
  30. this.file, {
  31. Key? key,
  32. }) : super(key: key);
  33. @override
  34. State<FileInfoWidget> createState() => _FileInfoWidgetState();
  35. }
  36. class _FileInfoWidgetState extends State<FileInfoWidget> {
  37. Map<String, IfdTag>? _exif;
  38. final Map<String, dynamic> _exifData = {
  39. "focalLength": null,
  40. "fNumber": null,
  41. "resolution": null,
  42. "takenOnDevice": null,
  43. "exposureTime": null,
  44. "ISO": null,
  45. "megaPixels": null
  46. };
  47. bool _isImage = false;
  48. int? _currentUserID;
  49. @override
  50. void initState() {
  51. debugPrint('file_info_dialog initState');
  52. _currentUserID = Configuration.instance.getUserID();
  53. _isImage = widget.file.fileType == FileType.image ||
  54. widget.file.fileType == FileType.livePhoto;
  55. if (_isImage) {
  56. getExif(widget.file).then((exif) {
  57. if (mounted) {
  58. setState(() {
  59. _exif = exif;
  60. });
  61. }
  62. });
  63. }
  64. super.initState();
  65. }
  66. @override
  67. Widget build(BuildContext context) {
  68. final file = widget.file;
  69. final fileIsBackedup = file.uploadedFileID == null ? false : true;
  70. final bool isFileOwner =
  71. file.ownerID == null || file.ownerID == _currentUserID;
  72. late Future<Set<int>> allCollectionIDsOfFile;
  73. //Typing this as Future<Set<T>> as it would be easier to implement showing multiple device folders for a file in the future
  74. final Future<Set<String>> allDeviceFoldersOfFile =
  75. Future.sync(() => {file.deviceFolder ?? ''});
  76. if (fileIsBackedup) {
  77. allCollectionIDsOfFile = FilesDB.instance.getAllCollectionIDsOfFile(
  78. file.uploadedFileID!,
  79. );
  80. }
  81. final dateTime = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
  82. final dateTimeForUpdationTime =
  83. DateTime.fromMicrosecondsSinceEpoch(file.updationTime!);
  84. if (_isImage && _exif != null) {
  85. _generateExifForDetails(_exif!);
  86. }
  87. final bool showExifListTile = _exifData["focalLength"] != null ||
  88. _exifData["fNumber"] != null ||
  89. _exifData["takenOnDevice"] != null ||
  90. _exifData["exposureTime"] != null ||
  91. _exifData["ISO"] != null;
  92. final bool showDimension =
  93. _exifData["resolution"] != null && _exifData["megaPixels"] != null;
  94. final listTiles = <Widget?>[
  95. !widget.file.isUploaded ||
  96. (!isFileOwner && (widget.file.caption?.isEmpty ?? true))
  97. ? const SizedBox.shrink()
  98. : Padding(
  99. padding: const EdgeInsets.only(top: 8, bottom: 4),
  100. child: isFileOwner
  101. ? FileCaptionWidget(file: widget.file)
  102. : FileCaptionReadyOnly(caption: widget.file.caption!),
  103. ),
  104. ListTile(
  105. horizontalTitleGap: 2,
  106. leading: const Padding(
  107. padding: EdgeInsets.only(top: 8),
  108. child: Icon(Icons.calendar_today_rounded),
  109. ),
  110. title: Text(
  111. getFullDate(
  112. DateTime.fromMicrosecondsSinceEpoch(file.creationTime!),
  113. ),
  114. ),
  115. subtitle: Text(
  116. getTimeIn12hrFormat(dateTime) + " " + dateTime.timeZoneName,
  117. style: Theme.of(context).textTheme.bodyText2!.copyWith(
  118. color: Theme.of(context)
  119. .colorScheme
  120. .defaultTextColor
  121. .withOpacity(0.5),
  122. ),
  123. ),
  124. trailing: (widget.file.ownerID == null ||
  125. widget.file.ownerID == _currentUserID) &&
  126. widget.file.uploadedFileID != null
  127. ? IconButton(
  128. onPressed: () {
  129. _showDateTimePicker(widget.file);
  130. },
  131. icon: const Icon(Icons.edit),
  132. )
  133. : const SizedBox.shrink(),
  134. ),
  135. ListTile(
  136. horizontalTitleGap: 2,
  137. leading: _isImage
  138. ? const Padding(
  139. padding: EdgeInsets.only(top: 8),
  140. child: Icon(
  141. Icons.image,
  142. ),
  143. )
  144. : const Padding(
  145. padding: EdgeInsets.only(top: 8),
  146. child: Icon(
  147. Icons.video_camera_back,
  148. size: 27,
  149. ),
  150. ),
  151. title: Text(
  152. path.basenameWithoutExtension(file.displayName) +
  153. path.extension(file.displayName).toUpperCase(),
  154. ),
  155. subtitle: Row(
  156. children: [
  157. showDimension
  158. ? Text(
  159. "${_exifData["megaPixels"]}MP "
  160. "${_exifData["resolution"]} ",
  161. )
  162. : const SizedBox.shrink(),
  163. _getFileSize(),
  164. (file.fileType == FileType.video) &&
  165. (file.localID != null || file.duration != 0)
  166. ? Padding(
  167. padding: const EdgeInsets.only(left: 8.0),
  168. child: _getVideoDuration(),
  169. )
  170. : const SizedBox.shrink(),
  171. ],
  172. ),
  173. trailing: file.uploadedFileID == null || file.ownerID != _currentUserID
  174. ? const SizedBox.shrink()
  175. : IconButton(
  176. onPressed: () async {
  177. await editFilename(context, file);
  178. setState(() {});
  179. },
  180. icon: const Icon(Icons.edit),
  181. ),
  182. ),
  183. showExifListTile
  184. ? ListTile(
  185. horizontalTitleGap: 2,
  186. leading: const Icon(Icons.camera_rounded),
  187. title: Text(_exifData["takenOnDevice"] ?? "--"),
  188. subtitle: Row(
  189. children: [
  190. _exifData["fNumber"] != null
  191. ? Padding(
  192. padding: const EdgeInsets.only(right: 10),
  193. child: Text('ƒ/' + _exifData["fNumber"].toString()),
  194. )
  195. : const SizedBox.shrink(),
  196. _exifData["exposureTime"] != null
  197. ? Padding(
  198. padding: const EdgeInsets.only(right: 10),
  199. child: Text(_exifData["exposureTime"]),
  200. )
  201. : const SizedBox.shrink(),
  202. _exifData["focalLength"] != null
  203. ? Padding(
  204. padding: const EdgeInsets.only(right: 10),
  205. child:
  206. Text(_exifData["focalLength"].toString() + "mm"),
  207. )
  208. : const SizedBox.shrink(),
  209. _exifData["ISO"] != null
  210. ? Padding(
  211. padding: const EdgeInsets.only(right: 10),
  212. child: Text("ISO" + _exifData["ISO"].toString()),
  213. )
  214. : const SizedBox.shrink(),
  215. ],
  216. ),
  217. )
  218. : null,
  219. SizedBox(
  220. height: 62,
  221. child: ListTile(
  222. horizontalTitleGap: 0,
  223. leading: const Icon(Icons.folder_outlined),
  224. title: fileIsBackedup
  225. ? CollectionsListOfFileWidget(
  226. allCollectionIDsOfFile,
  227. _currentUserID!,
  228. )
  229. : DeviceFoldersListOfFileWidget(allDeviceFoldersOfFile),
  230. ),
  231. ),
  232. FeatureFlagService.instance.isInternalUserOrDebugBuild()
  233. ? SizedBox(
  234. height: 62,
  235. child: ListTile(
  236. horizontalTitleGap: 0,
  237. leading: const Icon(Icons.image_search),
  238. title: ObjectTagsWidget(file),
  239. ),
  240. )
  241. : null,
  242. (file.uploadedFileID != null && file.updationTime != null)
  243. ? ListTile(
  244. horizontalTitleGap: 2,
  245. leading: const Padding(
  246. padding: EdgeInsets.only(top: 8),
  247. child: Icon(Icons.cloud_upload_outlined),
  248. ),
  249. title: Text(
  250. getFullDate(
  251. DateTime.fromMicrosecondsSinceEpoch(file.updationTime!),
  252. ),
  253. ),
  254. subtitle: Text(
  255. getTimeIn12hrFormat(dateTimeForUpdationTime) +
  256. " " +
  257. dateTimeForUpdationTime.timeZoneName,
  258. style: Theme.of(context).textTheme.bodyText2!.copyWith(
  259. color: Theme.of(context)
  260. .colorScheme
  261. .defaultTextColor
  262. .withOpacity(0.5),
  263. ),
  264. ),
  265. )
  266. : null,
  267. _isImage ? RawExifListTileWidget(_exif, widget.file) : null,
  268. ];
  269. listTiles.removeWhere(
  270. (element) => element == null,
  271. );
  272. return SafeArea(
  273. top: false,
  274. child: Scrollbar(
  275. thickness: 4,
  276. radius: const Radius.circular(2),
  277. thumbVisibility: true,
  278. child: Padding(
  279. padding: const EdgeInsets.all(8.0),
  280. child: CustomScrollView(
  281. physics: const ClampingScrollPhysics(),
  282. shrinkWrap: true,
  283. slivers: <Widget>[
  284. TitleBarWidget(
  285. isFlexibleSpaceDisabled: true,
  286. title: "Details",
  287. isOnTopOfScreen: false,
  288. backgroundColor: getEnteColorScheme(context).backgroundElevated,
  289. leading: IconButtonWidget(
  290. icon: Icons.close_outlined,
  291. iconButtonType: IconButtonType.primary,
  292. onTap: () => Navigator.pop(context),
  293. ),
  294. ),
  295. SliverToBoxAdapter(child: addedBy(widget.file)),
  296. SliverList(
  297. delegate: SliverChildBuilderDelegate(
  298. (context, index) {
  299. if (index.isOdd) {
  300. return index == 1
  301. ? const SizedBox.shrink()
  302. : const DividerWidget(
  303. dividerType: DividerType.menu,
  304. );
  305. } else {
  306. return listTiles[index ~/ 2];
  307. }
  308. },
  309. childCount: (listTiles.length * 2) - 1,
  310. ),
  311. )
  312. ],
  313. ),
  314. ),
  315. ),
  316. );
  317. }
  318. Widget addedBy(File file) {
  319. if (file.uploadedFileID == null) {
  320. return const SizedBox.shrink();
  321. }
  322. String? addedBy;
  323. if (file.ownerID == _currentUserID) {
  324. if (file.pubMagicMetadata!.uploaderName != null) {
  325. addedBy = file.pubMagicMetadata!.uploaderName;
  326. }
  327. } else {
  328. final fileOwner = CollectionsService.instance
  329. .getFileOwner(file.ownerID!, file.collectionID);
  330. addedBy = fileOwner.email;
  331. }
  332. if (addedBy == null || addedBy.isEmpty) {
  333. return const SizedBox.shrink();
  334. }
  335. final enteTheme = Theme.of(context).colorScheme.enteTheme;
  336. return Padding(
  337. padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 16),
  338. child: Text(
  339. "Added by $addedBy",
  340. style: enteTheme.textTheme.mini
  341. .copyWith(color: enteTheme.colorScheme.textMuted),
  342. ),
  343. );
  344. }
  345. _generateExifForDetails(Map<String, IfdTag> exif) {
  346. if (exif["EXIF FocalLength"] != null) {
  347. _exifData["focalLength"] =
  348. (exif["EXIF FocalLength"]!.values.toList()[0] as Ratio).numerator /
  349. (exif["EXIF FocalLength"]!.values.toList()[0] as Ratio)
  350. .denominator;
  351. }
  352. if (exif["EXIF FNumber"] != null) {
  353. _exifData["fNumber"] =
  354. (exif["EXIF FNumber"]!.values.toList()[0] as Ratio).numerator /
  355. (exif["EXIF FNumber"]!.values.toList()[0] as Ratio).denominator;
  356. }
  357. final imageWidth = exif["EXIF ExifImageWidth"] ?? exif["Image ImageWidth"];
  358. final imageLength = exif["EXIF ExifImageLength"] ??
  359. exif["Image "
  360. "ImageLength"];
  361. if (imageWidth != null && imageLength != null) {
  362. _exifData["resolution"] = '$imageWidth x $imageLength';
  363. _exifData['megaPixels'] =
  364. ((imageWidth.values.firstAsInt() * imageLength.values.firstAsInt()) /
  365. 1000000)
  366. .toStringAsFixed(1);
  367. } else {
  368. debugPrint("No image width/height");
  369. }
  370. if (exif["Image Make"] != null && exif["Image Model"] != null) {
  371. _exifData["takenOnDevice"] =
  372. exif["Image Make"].toString() + " " + exif["Image Model"].toString();
  373. }
  374. if (exif["EXIF ExposureTime"] != null) {
  375. _exifData["exposureTime"] = exif["EXIF ExposureTime"].toString();
  376. }
  377. if (exif["EXIF ISOSpeedRatings"] != null) {
  378. _exifData['ISO'] = exif["EXIF ISOSpeedRatings"].toString();
  379. }
  380. }
  381. Widget _getFileSize() {
  382. Future<int> fileSizeFuture;
  383. if (widget.file.fileSize != null) {
  384. fileSizeFuture = Future.value(widget.file.fileSize);
  385. } else {
  386. fileSizeFuture = getFile(widget.file).then((f) => f!.length());
  387. }
  388. return FutureBuilder<int>(
  389. future: fileSizeFuture,
  390. builder: (context, snapshot) {
  391. if (snapshot.hasData) {
  392. return Text(
  393. (snapshot.data! / (1024 * 1024)).toStringAsFixed(2) + " MB",
  394. );
  395. } else {
  396. return Center(
  397. child: SizedBox.fromSize(
  398. size: const Size.square(24),
  399. child: const CupertinoActivityIndicator(
  400. radius: 8,
  401. ),
  402. ),
  403. );
  404. }
  405. },
  406. );
  407. }
  408. Widget _getVideoDuration() {
  409. if (widget.file.duration != 0) {
  410. return Text(
  411. secondsToHHMMSS(widget.file.duration!),
  412. );
  413. }
  414. return FutureBuilder<AssetEntity?>(
  415. future: widget.file.getAsset,
  416. builder: (context, snapshot) {
  417. if (snapshot.hasData) {
  418. return Text(
  419. snapshot.data!.videoDuration.toString().split(".")[0],
  420. );
  421. } else {
  422. return Center(
  423. child: SizedBox.fromSize(
  424. size: const Size.square(24),
  425. child: const CupertinoActivityIndicator(
  426. radius: 8,
  427. ),
  428. ),
  429. );
  430. }
  431. },
  432. );
  433. }
  434. void _showDateTimePicker(File file) async {
  435. final dateResult = await DatePicker.showDatePicker(
  436. context,
  437. minTime: DateTime(1800, 1, 1),
  438. maxTime: DateTime.now(),
  439. currentTime: DateTime.fromMicrosecondsSinceEpoch(file.creationTime!),
  440. locale: LocaleType.en,
  441. theme: Theme.of(context).colorScheme.dateTimePickertheme,
  442. );
  443. if (dateResult == null) {
  444. return;
  445. }
  446. final dateWithTimeResult = await DatePicker.showTime12hPicker(
  447. context,
  448. showTitleActions: true,
  449. currentTime: dateResult,
  450. locale: LocaleType.en,
  451. theme: Theme.of(context).colorScheme.dateTimePickertheme,
  452. );
  453. if (dateWithTimeResult != null) {
  454. if (await editTime(
  455. context,
  456. List.of([widget.file]),
  457. dateWithTimeResult.microsecondsSinceEpoch,
  458. )) {
  459. widget.file.creationTime = dateWithTimeResult.microsecondsSinceEpoch;
  460. setState(() {});
  461. }
  462. }
  463. }
  464. }