Skip to content

Commit de1dbce

Browse files
authored
Implemented EXIF store and display (#19)
* Added EXIF extracting in the backend * Added EXIF displaying on `image_viewer_page.dart` * Added Icon for backup option not enable
1 parent d149850 commit de1dbce

35 files changed

+1089
-844
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import 'dart:convert';
2+
3+
class ImageViewerPageState {
4+
final bool isBottomSheetEnable;
5+
ImageViewerPageState({
6+
required this.isBottomSheetEnable,
7+
});
8+
9+
ImageViewerPageState copyWith({
10+
bool? isBottomSheetEnable,
11+
}) {
12+
return ImageViewerPageState(
13+
isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
14+
);
15+
}
16+
17+
Map<String, dynamic> toMap() {
18+
return {
19+
'isBottomSheetEnable': isBottomSheetEnable,
20+
};
21+
}
22+
23+
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
24+
return ImageViewerPageState(
25+
isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
26+
);
27+
}
28+
29+
String toJson() => json.encode(toMap());
30+
31+
factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
32+
33+
@override
34+
String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
35+
36+
@override
37+
bool operator ==(Object other) {
38+
if (identical(this, other)) return true;
39+
40+
return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
41+
}
42+
43+
@override
44+
int get hashCode => isBottomSheetEnable.hashCode;
45+
}

mobile/lib/modules/asset_viewer/models/store_model_here.txt

Whitespace-only changes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'package:hooks_riverpod/hooks_riverpod.dart';
2+
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
3+
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
4+
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
5+
6+
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
7+
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
8+
9+
void toggleBottomSheet() {
10+
bool isBottomSheetEnable = state.isBottomSheetEnable;
11+
12+
if (isBottomSheetEnable) {
13+
state.copyWith(isBottomSheetEnable: false);
14+
} else {
15+
state.copyWith(isBottomSheetEnable: true);
16+
}
17+
}
18+
}
19+
20+
final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
21+
((ref) => ImageViewerPageStateNotifier()));

mobile/lib/modules/asset_viewer/services/store_services_here.txt

Whitespace-only changes.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:hooks_riverpod/hooks_riverpod.dart';
3+
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
4+
import 'package:intl/intl.dart';
5+
import 'package:path/path.dart' as p;
6+
7+
class ExifBottomSheet extends ConsumerWidget {
8+
final ImmichAssetWithExif assetDetail;
9+
10+
const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key);
11+
12+
@override
13+
Widget build(BuildContext context, WidgetRef ref) {
14+
return Padding(
15+
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
16+
child: ListView(
17+
children: [
18+
assetDetail.exifInfo?.dateTimeOriginal != null
19+
? Text(
20+
DateFormat('E, LLL d, y • h:mm a').format(
21+
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
22+
),
23+
style: TextStyle(
24+
color: Colors.grey[400],
25+
fontWeight: FontWeight.bold,
26+
fontSize: 14,
27+
),
28+
)
29+
: Container(),
30+
Padding(
31+
padding: const EdgeInsets.only(top: 16.0),
32+
child: Text(
33+
"Add Description...",
34+
style: TextStyle(
35+
color: Colors.grey[500],
36+
fontSize: 11,
37+
),
38+
),
39+
),
40+
41+
// Location
42+
assetDetail.exifInfo?.latitude != null
43+
? Padding(
44+
padding: const EdgeInsets.only(top: 32.0),
45+
child: Column(
46+
crossAxisAlignment: CrossAxisAlignment.start,
47+
children: [
48+
Divider(
49+
thickness: 1,
50+
color: Colors.grey[600],
51+
),
52+
Text(
53+
"LOCATION",
54+
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
55+
),
56+
Text(
57+
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
58+
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
59+
)
60+
],
61+
),
62+
)
63+
: Container(),
64+
// Detail
65+
assetDetail.exifInfo != null
66+
? Padding(
67+
padding: const EdgeInsets.only(top: 32.0),
68+
child: Column(
69+
crossAxisAlignment: CrossAxisAlignment.start,
70+
children: [
71+
Divider(
72+
thickness: 1,
73+
color: Colors.grey[600],
74+
),
75+
Padding(
76+
padding: const EdgeInsets.only(bottom: 8.0),
77+
child: Text(
78+
"DETAILS",
79+
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
80+
),
81+
),
82+
ListTile(
83+
contentPadding: const EdgeInsets.all(0),
84+
dense: true,
85+
textColor: Colors.grey[300],
86+
iconColor: Colors.grey[300],
87+
leading: const Icon(Icons.image),
88+
title: Text(
89+
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
90+
style: const TextStyle(fontWeight: FontWeight.bold),
91+
),
92+
subtitle: Text(
93+
"${assetDetail.exifInfo?.exifImageHeight!} x ${assetDetail.exifInfo?.exifImageWidth!} ${assetDetail.exifInfo?.fileSizeInByte!}B "),
94+
),
95+
assetDetail.exifInfo?.make != null
96+
? ListTile(
97+
contentPadding: const EdgeInsets.all(0),
98+
dense: true,
99+
textColor: Colors.grey[300],
100+
iconColor: Colors.grey[300],
101+
leading: const Icon(Icons.camera),
102+
title: Text(
103+
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
104+
style: const TextStyle(fontWeight: FontWeight.bold),
105+
),
106+
subtitle: Text(
107+
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
108+
)
109+
: Container()
110+
],
111+
),
112+
)
113+
: Container()
114+
],
115+
),
116+
);
117+
}
118+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'package:auto_route/auto_route.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
4+
5+
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
6+
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
7+
8+
final ImmichAsset asset;
9+
final Function onMoreInfoPressed;
10+
@override
11+
Widget build(BuildContext context) {
12+
double iconSize = 18.0;
13+
14+
return AppBar(
15+
foregroundColor: Colors.grey[100],
16+
toolbarHeight: 60,
17+
backgroundColor: Colors.black,
18+
leading: IconButton(
19+
onPressed: () {
20+
AutoRouter.of(context).pop();
21+
},
22+
icon: const Icon(
23+
Icons.arrow_back_ios_new_rounded,
24+
size: 20.0,
25+
),
26+
),
27+
actions: [
28+
IconButton(
29+
iconSize: iconSize,
30+
splashRadius: iconSize,
31+
onPressed: () {
32+
print("backup");
33+
},
34+
icon: const Icon(Icons.backup_outlined),
35+
),
36+
IconButton(
37+
iconSize: iconSize,
38+
splashRadius: iconSize,
39+
onPressed: () {
40+
print("favorite");
41+
},
42+
icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
43+
),
44+
IconButton(
45+
iconSize: iconSize,
46+
splashRadius: iconSize,
47+
onPressed: () {
48+
onMoreInfoPressed();
49+
},
50+
icon: const Icon(Icons.more_horiz_rounded))
51+
],
52+
);
53+
}
54+
55+
@override
56+
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
57+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import 'package:auto_route/auto_route.dart';
2+
import 'package:cached_network_image/cached_network_image.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_hooks/flutter_hooks.dart';
5+
import 'package:hive/hive.dart';
6+
import 'package:hooks_riverpod/hooks_riverpod.dart';
7+
import 'package:immich_mobile/constants/hive_box.dart';
8+
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
9+
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
10+
import 'package:immich_mobile/modules/home/services/asset.service.dart';
11+
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
12+
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
13+
import 'package:photo_view/photo_view.dart';
14+
15+
// ignore: must_be_immutable
16+
class ImageViewerPage extends HookConsumerWidget {
17+
final String imageUrl;
18+
final String heroTag;
19+
final String thumbnailUrl;
20+
final ImmichAsset asset;
21+
final AssetService _assetService = AssetService();
22+
ImmichAssetWithExif? assetDetail;
23+
24+
ImageViewerPage(
25+
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
26+
: super(key: key);
27+
28+
@override
29+
Widget build(BuildContext context, WidgetRef ref) {
30+
var box = Hive.box(userInfoBox);
31+
32+
getAssetExif() async {
33+
assetDetail = await _assetService.getAssetById(asset.id);
34+
}
35+
36+
useEffect(() {
37+
getAssetExif();
38+
}, []);
39+
40+
return Scaffold(
41+
backgroundColor: Colors.black,
42+
appBar: TopControlAppBar(
43+
asset: asset,
44+
onMoreInfoPressed: () {
45+
showModalBottomSheet(
46+
backgroundColor: Colors.black,
47+
barrierColor: Colors.transparent,
48+
isScrollControlled: false,
49+
context: context,
50+
builder: (context) {
51+
return ExifBottomSheet(assetDetail: assetDetail!);
52+
});
53+
},
54+
),
55+
body: Center(
56+
child: Hero(
57+
tag: heroTag,
58+
child: CachedNetworkImage(
59+
fit: BoxFit.cover,
60+
imageUrl: imageUrl,
61+
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
62+
fadeInDuration: const Duration(milliseconds: 250),
63+
errorWidget: (context, url, error) => const Icon(Icons.error),
64+
imageBuilder: (context, imageProvider) {
65+
return PhotoView(imageProvider: imageProvider);
66+
},
67+
placeholder: (context, url) {
68+
return CachedNetworkImage(
69+
fit: BoxFit.cover,
70+
imageUrl: thumbnailUrl,
71+
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
72+
placeholderFadeInDuration: const Duration(milliseconds: 0),
73+
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
74+
scale: 0.2,
75+
child: CircularProgressIndicator(value: downloadProgress.progress),
76+
),
77+
errorWidget: (context, url, error) => const Icon(Icons.error),
78+
);
79+
},
80+
),
81+
),
82+
),
83+
);
84+
}
85+
}

mobile/lib/modules/home/services/asset.service.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'package:flutter/material.dart';
44
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
55
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
6+
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
67
import 'package:immich_mobile/shared/services/network.service.dart';
78

89
class AssetService {
@@ -58,4 +59,21 @@ class AssetService {
5859
return [];
5960
}
6061
}
62+
63+
Future<ImmichAssetWithExif?> getAssetById(String assetId) async {
64+
try {
65+
var res = await _networkService.getRequest(
66+
url: "asset/assetById/$assetId",
67+
);
68+
69+
Map<String, dynamic> decodedData = jsonDecode(res.toString());
70+
71+
ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
72+
print("result $result");
73+
return result;
74+
} catch (e) {
75+
debugPrint("Error getAllAsset ${e.toString()}");
76+
return null;
77+
}
78+
}
6179
}

0 commit comments

Comments
 (0)