file_info_widget.dart 15 KB

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