1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-30 11:28:22 +02:00
immich/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

420 lines
14 KiB
Dart
Raw Normal View History

import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:timezone/timezone.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:url_launcher/url_launcher.dart';
class ExifBottomSheet extends HookConsumerWidget {
final Asset asset;
const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
bool hasCoordinates(ExifInfo? exifInfo) =>
exifInfo != null &&
exifInfo.latitude != null &&
exifInfo.longitude != null &&
exifInfo.latitude != 0 &&
exifInfo.longitude != 0;
String formatTimeZone(Duration d) =>
"GMT${d.isNegative ? '-' : '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
String get formattedDateTime {
DateTime dt = asset.fileCreatedAt.toLocal();
String? timeZone;
if (asset.exifInfo?.dateTimeOriginal != null) {
dt = asset.exifInfo!.dateTimeOriginal!;
if (asset.exifInfo?.timeZone != null) {
dt = dt.toUtc();
try {
final location = getLocation(asset.exifInfo!.timeZone!);
dt = TZDateTime.from(dt, location);
} on LocationNotFoundException {
RegExp re = RegExp(
r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
caseSensitive: false,
);
final m = re.firstMatch(asset.exifInfo!.timeZone!);
if (m != null) {
final duration = Duration(
hours: int.parse(m.group(1) ?? '0'),
minutes: int.parse(m.group(2) ?? '0'),
);
dt = dt.add(duration);
timeZone = formatTimeZone(duration);
}
}
}
}
final date = DateFormat.yMMMEd().format(dt);
final time = DateFormat.jm().format(dt);
timeZone ??= formatTimeZone(dt.timeZoneOffset);
return '$date$time $timeZone';
}
Future<Uri?> _createCoordinatesUri(ExifInfo? exifInfo) async {
if (!hasCoordinates(exifInfo)) {
return null;
}
final double latitude = exifInfo!.latitude!;
final double longitude = exifInfo.longitude!;
const zoomLevel = 16;
if (Platform.isAndroid) {
Uri uri = Uri(
scheme: 'geo',
host: '$latitude,$longitude',
queryParameters: {
'z': '$zoomLevel',
'q': '$latitude,$longitude($formattedDateTime)',
},
);
if (await canLaunchUrl(uri)) {
return uri;
}
} else if (Platform.isIOS) {
var params = {
'll': '$latitude,$longitude',
'q': formattedDateTime,
'z': '$zoomLevel',
};
Uri uri = Uri.https('maps.apple.com', '/', params);
if (await canLaunchUrl(uri)) {
return uri;
}
}
return Uri(
scheme: 'https',
host: 'openstreetmap.org',
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
fragment: 'map=$zoomLevel/$latitude/$longitude',
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetWithExif = ref.watch(assetDetailProvider(asset));
final exifInfo = (assetWithExif.value ?? asset).exifInfo;
var textColor = context.isDarkTheme ? Colors.white : Colors.black;
buildMap() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: LayoutBuilder(
builder: (context, constraints) {
return MapThumbnail(
coords: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
height: 150,
zoom: 16.0,
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
),
],
onTap: (tapPosition, latLong) async {
Uri? uri = await _createCoordinatesUri(exifInfo);
if (uri == null) {
return;
}
debugPrint('Opening Map Uri: $uri');
launchUrl(uri);
},
);
},
),
);
}
buildSizeText(Asset a) {
String resolution = a.width != null && a.height != null
? "${a.height} x ${a.width} "
: "";
String fileSize = a.exifInfo?.fileSize != null
? formatBytes(a.exifInfo!.fileSize!)
: "";
String text = resolution + fileSize;
return text.isNotEmpty ? text : null;
}
buildDragHeader() {
return const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 12),
Align(
alignment: Alignment.center,
child: CustomDraggingHandle(),
),
SizedBox(height: 12),
],
);
}
buildLocation() {
// Guard no lat/lng
if (!hasCoordinates(exifInfo)) {
return Container();
}
return Column(
children: [
// Location
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"exif_bottom_sheet_location",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
buildMap(),
RichText(
text: TextSpan(
style: context.textTheme.labelLarge,
children: [
if (exifInfo != null && exifInfo.city != null)
TextSpan(
text: exifInfo.city,
),
if (exifInfo != null &&
exifInfo.city != null &&
exifInfo.state != null)
const TextSpan(
text: ", ",
),
if (exifInfo != null && exifInfo.state != null)
TextSpan(
text: "${exifInfo.state}",
),
],
),
),
Text(
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(150),
),
),
],
),
],
);
}
buildDate() {
return Text(
formattedDateTime,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
);
}
buildImageProperties() {
// Helper to create the ListTile and avoid repeating code
createImagePropertiesListStyle(title, subtitle) => ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: Icon(
Icons.image,
color: textColor.withAlpha(200),
),
titleAlignment: ListTileTitleAlignment.center,
title: Text(
title,
style: context.textTheme.labelLarge,
),
subtitle: subtitle,
);
final imgSizeString = buildSizeText(asset);
if (imgSizeString == null && asset.fileName.isNotEmpty) {
// There is only filename
return createImagePropertiesListStyle(
asset.fileName,
null,
);
} else if (imgSizeString != null && asset.fileName.isNotEmpty) {
// There is both filename and size information
return createImagePropertiesListStyle(
asset.fileName,
Text(imgSizeString, style: context.textTheme.bodySmall),
);
} else if (imgSizeString != null && asset.fileName.isEmpty) {
// There is only size information
return createImagePropertiesListStyle(
imgSizeString,
null,
);
}
}
buildDetail() {
final imgProperties = buildImageProperties();
// There are no details
if (imgProperties == null &&
(exifInfo == null || exifInfo.make == null)) {
return Container();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"exif_bottom_sheet_details",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
),
if (imgProperties != null) imgProperties,
if (exifInfo?.make != null)
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: Icon(
Icons.camera,
color: textColor.withAlpha(200),
),
title: Text(
"${exifInfo!.make} ${exifInfo.model}",
style: context.textTheme.labelLarge,
),
subtitle: exifInfo.f != null ||
exifInfo.exposureSeconds != null ||
exifInfo.mm != null ||
exifInfo.iso != null
? Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ",
style: context.textTheme.bodySmall,
)
: null,
),
],
);
}
return GestureDetector(
onTap: () {
// FocusScope.of(context).unfocus();
},
child: SingleChildScrollView(
child: Card(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(15),
topRight: Radius.circular(15),
),
),
margin: const EdgeInsets.all(0),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16.0),
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
// Two column
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
buildDragHeader(),
buildDate(),
if (asset.isRemote) DescriptionInput(asset: asset),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: hasCoordinates(exifInfo) ? 5 : 0,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: buildLocation(),
),
),
Flexible(
flex: 5,
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: buildDetail(),
),
),
],
),
const SizedBox(height: 50),
],
),
);
}
// One column
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
buildDragHeader(),
buildDate(),
assetWithExif.when(
data: (data) => DescriptionInput(asset: data),
error: (error, stackTrace) => Icon(
Icons.image_not_supported_outlined,
color: context.primaryColor,
),
loading: () => const SizedBox(
width: 75,
height: 75,
child: CircularProgressIndicator.adaptive(),
),
),
const SizedBox(height: 8.0),
buildLocation(),
SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),
buildDetail(),
const SizedBox(height: 50),
],
);
},
),
),
),
),
);
}
}