file_details_widget.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import "package:exif/exif.dart";
  2. import "package:flutter/material.dart";
  3. import "package:logging/logging.dart";
  4. import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
  5. import "package:photos/core/configuration.dart";
  6. import "package:photos/generated/l10n.dart";
  7. import 'package:photos/models/file/file.dart';
  8. import 'package:photos/models/file/file_type.dart';
  9. import "package:photos/models/metadata/file_magic.dart";
  10. import "package:photos/services/file_magic_service.dart";
  11. import "package:photos/services/update_service.dart";
  12. import "package:photos/theme/colors.dart";
  13. import 'package:photos/theme/ente_theme.dart';
  14. import 'package:photos/ui/components/buttons/icon_button_widget.dart';
  15. import "package:photos/ui/components/divider_widget.dart";
  16. import "package:photos/ui/components/info_item_widget.dart";
  17. import 'package:photos/ui/components/title_bar_widget.dart';
  18. import 'package:photos/ui/viewer/file/file_caption_widget.dart';
  19. import "package:photos/ui/viewer/file_details/added_by_widget.dart";
  20. import "package:photos/ui/viewer/file_details/albums_item_widget.dart";
  21. import 'package:photos/ui/viewer/file_details/backed_up_time_item_widget.dart';
  22. import "package:photos/ui/viewer/file_details/creation_time_item_widget.dart";
  23. import 'package:photos/ui/viewer/file_details/exif_item_widgets.dart';
  24. import "package:photos/ui/viewer/file_details/file_properties_item_widget.dart";
  25. import "package:photos/ui/viewer/file_details/location_tags_widget.dart";
  26. import "package:photos/ui/viewer/file_details/objects_item_widget.dart";
  27. import 'package:photos/ui/viewer/location/update_location_data_widget.dart';
  28. import "package:photos/utils/exif_util.dart";
  29. class FileDetailsWidget extends StatefulWidget {
  30. final EnteFile file;
  31. const FileDetailsWidget(
  32. this.file, {
  33. Key? key,
  34. }) : super(key: key);
  35. @override
  36. State<FileDetailsWidget> createState() => _FileDetailsWidgetState();
  37. }
  38. class _FileDetailsWidgetState extends State<FileDetailsWidget> {
  39. final ValueNotifier<Map<String, IfdTag>?> _exifNotifier = ValueNotifier(null);
  40. final Map<String, dynamic> _exifData = {
  41. "focalLength": null,
  42. "fNumber": null,
  43. "resolution": null,
  44. "takenOnDevice": null,
  45. "exposureTime": null,
  46. "ISO": null,
  47. "megaPixels": null,
  48. "lat": null,
  49. "long": null,
  50. "latRef": null,
  51. "longRef": null,
  52. };
  53. bool _isImage = false;
  54. late int _currentUserID;
  55. bool showExifListTile = false;
  56. final ValueNotifier<bool> hasLocationData = ValueNotifier(false);
  57. final Logger _logger = Logger("_FileDetailsWidgetState");
  58. @override
  59. void initState() {
  60. debugPrint('file_details_sheet initState');
  61. _currentUserID = Configuration.instance.getUserID()!;
  62. hasLocationData.value = widget.file.hasLocation;
  63. _isImage = widget.file.fileType == FileType.image ||
  64. widget.file.fileType == FileType.livePhoto;
  65. _exifNotifier.addListener(() {
  66. if (_exifNotifier.value != null && !widget.file.hasLocation) {
  67. _updateLocationFromExif(_exifNotifier.value!).ignore();
  68. }
  69. });
  70. if (_isImage) {
  71. _exifNotifier.addListener(() {
  72. if (_exifNotifier.value != null) {
  73. _generateExifForDetails(_exifNotifier.value!);
  74. }
  75. showExifListTile = _exifData["focalLength"] != null ||
  76. _exifData["fNumber"] != null ||
  77. _exifData["takenOnDevice"] != null ||
  78. _exifData["exposureTime"] != null ||
  79. _exifData["ISO"] != null;
  80. });
  81. }
  82. getExif(widget.file).then((exif) {
  83. _exifNotifier.value = exif;
  84. });
  85. super.initState();
  86. }
  87. @override
  88. void dispose() {
  89. _exifNotifier.dispose();
  90. super.dispose();
  91. }
  92. @override
  93. Widget build(BuildContext context) {
  94. final file = widget.file;
  95. final bool isFileOwner =
  96. file.ownerID == null || file.ownerID == _currentUserID;
  97. //Make sure the bottom most tile is always the same one, that is it should
  98. //not be rendered only if a condition is met.
  99. final fileDetailsTiles = <Widget>[];
  100. fileDetailsTiles.add(
  101. !widget.file.isUploaded ||
  102. (!isFileOwner && (widget.file.caption?.isEmpty ?? true))
  103. ? const SizedBox(height: 16)
  104. : Padding(
  105. padding: const EdgeInsets.only(top: 8, bottom: 24),
  106. child: isFileOwner
  107. ? FileCaptionWidget(file: widget.file)
  108. : FileCaptionReadyOnly(caption: widget.file.caption!),
  109. ),
  110. );
  111. fileDetailsTiles.addAll([
  112. CreationTimeItem(file, _currentUserID),
  113. const FileDetailsDivider(),
  114. ValueListenableBuilder(
  115. valueListenable: _exifNotifier,
  116. builder: (context, _, __) => FilePropertiesItemWidget(
  117. file,
  118. _isImage,
  119. _exifData,
  120. _currentUserID,
  121. ),
  122. ),
  123. const FileDetailsDivider(),
  124. ]);
  125. fileDetailsTiles.add(
  126. ValueListenableBuilder(
  127. valueListenable: _exifNotifier,
  128. builder: (context, value, _) {
  129. return showExifListTile
  130. ? Column(
  131. children: [
  132. BasicExifItemWidget(_exifData),
  133. const FileDetailsDivider(),
  134. ],
  135. )
  136. : const SizedBox.shrink();
  137. },
  138. ),
  139. );
  140. fileDetailsTiles.addAll([
  141. ValueListenableBuilder(
  142. valueListenable: hasLocationData,
  143. builder: (context, bool value, __) {
  144. return value
  145. ? Column(
  146. children: [
  147. LocationTagsWidget(
  148. widget.file.location!,
  149. ),
  150. const FileDetailsDivider(),
  151. ],
  152. )
  153. : Column(
  154. children: [
  155. InfoItemWidget(
  156. leadingIcon: Icons.pin_drop_outlined,
  157. title: "No location data",
  158. subtitleSection: Future.value(
  159. [
  160. Text(
  161. "Add location data",
  162. style: getEnteTextTheme(context).miniBoldMuted,
  163. ),
  164. ],
  165. ),
  166. hasChipButtons: false,
  167. onTap: () {
  168. showBarModalBottomSheet(
  169. shape: const RoundedRectangleBorder(
  170. borderRadius: BorderRadius.vertical(
  171. top: Radius.circular(5),
  172. ),
  173. ),
  174. backgroundColor:
  175. getEnteColorScheme(context).backgroundElevated,
  176. barrierColor: backdropFaintDark,
  177. context: context,
  178. builder: (context) {
  179. return UpdateLocationDataWidget([file]);
  180. },
  181. );
  182. },
  183. ),
  184. const FileDetailsDivider(),
  185. ],
  186. );
  187. },
  188. ),
  189. ]);
  190. if (_isImage) {
  191. fileDetailsTiles.addAll([
  192. ValueListenableBuilder(
  193. valueListenable: _exifNotifier,
  194. builder: (context, value, _) {
  195. return Column(
  196. children: [
  197. AllExifItemWidget(file, _exifNotifier.value),
  198. const FileDetailsDivider(),
  199. ],
  200. );
  201. },
  202. ),
  203. ]);
  204. }
  205. if (!UpdateService.instance.isFdroidFlavor()) {
  206. fileDetailsTiles.addAll([
  207. ObjectsItemWidget(file),
  208. const FileDetailsDivider(),
  209. ]);
  210. }
  211. if (file.uploadedFileID != null && file.updationTime != null) {
  212. fileDetailsTiles.addAll(
  213. [
  214. BackedUpTimeItemWidget(file),
  215. const FileDetailsDivider(),
  216. ],
  217. );
  218. }
  219. fileDetailsTiles.add(AlbumsItemWidget(file, _currentUserID));
  220. return SafeArea(
  221. top: false,
  222. child: Scrollbar(
  223. thickness: 4,
  224. radius: const Radius.circular(2),
  225. thumbVisibility: true,
  226. child: Padding(
  227. padding: const EdgeInsets.all(8.0),
  228. child: CustomScrollView(
  229. physics: const ClampingScrollPhysics(),
  230. shrinkWrap: true,
  231. slivers: <Widget>[
  232. TitleBarWidget(
  233. isFlexibleSpaceDisabled: true,
  234. title: S.of(context).details,
  235. isOnTopOfScreen: false,
  236. backgroundColor: getEnteColorScheme(context).backgroundElevated,
  237. leading: IconButtonWidget(
  238. icon: Icons.expand_more_outlined,
  239. iconButtonType: IconButtonType.primary,
  240. onTap: () => Navigator.pop(context),
  241. ),
  242. ),
  243. SliverToBoxAdapter(child: AddedByWidget(widget.file)),
  244. SliverList(
  245. delegate: SliverChildBuilderDelegate(
  246. (context, index) {
  247. return fileDetailsTiles[index];
  248. },
  249. childCount: fileDetailsTiles.length,
  250. ),
  251. ),
  252. ],
  253. ),
  254. ),
  255. ),
  256. );
  257. }
  258. //This code is for updating the location of files in which location data is
  259. //missing and the EXIF has location data. This is only happens for a
  260. //certain specific minority of devices.
  261. Future<void> _updateLocationFromExif(Map<String, IfdTag> exif) async {
  262. // If the file is not uploaded or the file is not owned by the current user
  263. // then we don't need to update the location.
  264. if (!widget.file.isUploaded || widget.file.ownerID! != _currentUserID) {
  265. return;
  266. }
  267. try {
  268. final locationDataFromExif = locationFromExif(exif);
  269. if (locationDataFromExif?.latitude != null &&
  270. locationDataFromExif?.longitude != null) {
  271. widget.file.location = locationDataFromExif;
  272. await FileMagicService.instance.updatePublicMagicMetadata([
  273. widget.file,
  274. ], {
  275. latKey: locationDataFromExif!.latitude,
  276. longKey: locationDataFromExif.longitude,
  277. });
  278. hasLocationData.value = true;
  279. }
  280. } catch (e, s) {
  281. _logger.severe("Error while updating location from EXIF", e, s);
  282. }
  283. }
  284. _generateExifForDetails(Map<String, IfdTag> exif) {
  285. if (exif["EXIF FocalLength"] != null) {
  286. _exifData["focalLength"] =
  287. (exif["EXIF FocalLength"]!.values.toList()[0] as Ratio).numerator /
  288. (exif["EXIF FocalLength"]!.values.toList()[0] as Ratio)
  289. .denominator;
  290. }
  291. if (exif["EXIF FNumber"] != null) {
  292. _exifData["fNumber"] =
  293. (exif["EXIF FNumber"]!.values.toList()[0] as Ratio).numerator /
  294. (exif["EXIF FNumber"]!.values.toList()[0] as Ratio).denominator;
  295. }
  296. final imageWidth = exif["EXIF ExifImageWidth"] ?? exif["Image ImageWidth"];
  297. final imageLength = exif["EXIF ExifImageLength"] ??
  298. exif["Image "
  299. "ImageLength"];
  300. if (imageWidth != null && imageLength != null) {
  301. _exifData["resolution"] = '$imageWidth x $imageLength';
  302. final double megaPixels =
  303. (imageWidth.values.firstAsInt() * imageLength.values.firstAsInt()) /
  304. 1000000;
  305. final double roundedMegaPixels = (megaPixels * 10).round() / 10.0;
  306. _exifData['megaPixels'] = roundedMegaPixels..toStringAsFixed(1);
  307. } else {
  308. debugPrint("No image width/height");
  309. }
  310. if (exif["Image Make"] != null && exif["Image Model"] != null) {
  311. _exifData["takenOnDevice"] =
  312. exif["Image Make"].toString() + " " + exif["Image Model"].toString();
  313. }
  314. if (exif["EXIF ExposureTime"] != null) {
  315. _exifData["exposureTime"] = exif["EXIF ExposureTime"].toString();
  316. }
  317. if (exif["EXIF ISOSpeedRatings"] != null) {
  318. _exifData['ISO'] = exif["EXIF ISOSpeedRatings"].toString();
  319. }
  320. }
  321. }
  322. class FileDetailsDivider extends StatelessWidget {
  323. const FileDetailsDivider({super.key});
  324. @override
  325. Widget build(BuildContext context) {
  326. const dividerPadding = EdgeInsets.symmetric(vertical: 9.5);
  327. return const DividerWidget(
  328. dividerType: DividerType.menu,
  329. divColorHasBlur: false,
  330. padding: dividerPadding,
  331. );
  332. }
  333. }