Browse Source

Build UI for image viewer to show exif

Tran, Alex 3 years ago
parent
commit
c7daba2cef

+ 45 - 0
mobile/lib/modules/asset_viewer/models/image_viewer_page_state.model.dart

@@ -0,0 +1,45 @@
+import 'dart:convert';
+
+class ImageViewerPageState {
+  final bool isBottomSheetEnable;
+  ImageViewerPageState({
+    required this.isBottomSheetEnable,
+  });
+
+  ImageViewerPageState copyWith({
+    bool? isBottomSheetEnable,
+  }) {
+    return ImageViewerPageState(
+      isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'isBottomSheetEnable': isBottomSheetEnable,
+    };
+  }
+
+  factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
+    return ImageViewerPageState(
+      isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
+
+  @override
+  String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
+  }
+
+  @override
+  int get hashCode => isBottomSheetEnable.hashCode;
+}

+ 0 - 0
mobile/lib/modules/asset_viewer/models/store_model_here.txt


+ 21 - 0
mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart

@@ -0,0 +1,21 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
+import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+
+class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
+  ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
+
+  void toggleBottomSheet() {
+    bool isBottomSheetEnable = state.isBottomSheetEnable;
+
+    if (isBottomSheetEnable) {
+      state.copyWith(isBottomSheetEnable: false);
+    } else {
+      state.copyWith(isBottomSheetEnable: true);
+    }
+  }
+}
+
+final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
+    ((ref) => ImageViewerPageStateNotifier()));

+ 0 - 0
mobile/lib/modules/asset_viewer/services/store_services_here.txt


+ 0 - 0
mobile/lib/modules/asset_viewer/ui/bottom_control_app_bar.dart


+ 57 - 0
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -0,0 +1,57 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+
+class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
+  const TopControlAppBar({Key? key, required this.asset}) : super(key: key);
+
+  final ImmichAsset asset;
+
+  @override
+  Widget build(BuildContext context) {
+    double iconSize = 18.0;
+
+    return AppBar(
+      foregroundColor: Colors.grey[100],
+      toolbarHeight: 60,
+      backgroundColor: Colors.black,
+      leading: IconButton(
+        onPressed: () {
+          AutoRouter.of(context).pop();
+        },
+        icon: const Icon(
+          Icons.arrow_back_ios_new_rounded,
+          size: 20.0,
+        ),
+      ),
+      actions: [
+        IconButton(
+          iconSize: iconSize,
+          splashRadius: iconSize,
+          onPressed: () {
+            print("backup");
+          },
+          icon: const Icon(Icons.backup_outlined),
+        ),
+        IconButton(
+          iconSize: iconSize,
+          splashRadius: iconSize,
+          onPressed: () {
+            print("favorite");
+          },
+          icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
+        ),
+        IconButton(
+            iconSize: iconSize,
+            splashRadius: iconSize,
+            onPressed: () {
+              print("show modal");
+            },
+            icon: const Icon(Icons.more_horiz_rounded))
+      ],
+    );
+  }
+
+  @override
+  Size get preferredSize => const Size.fromHeight(kToolbarHeight);
+}

+ 161 - 0
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart

@@ -0,0 +1,161 @@
+import 'dart:math';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hive/hive.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
+import 'package:immich_mobile/modules/home/services/asset.service.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
+
+class ImageViewerPage extends HookConsumerWidget {
+  final String imageUrl;
+  final String heroTag;
+  final String thumbnailUrl;
+  final ImmichAsset asset;
+
+  ImageViewerPage(
+      {Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
+      : super(key: key);
+
+  final AssetService _assetService = AssetService();
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    var box = Hive.box(userInfoBox);
+
+    getDetail() async {
+      var detail = await _assetService.getAssetById(asset.id);
+    }
+
+    useEffect(() {
+      getDetail();
+      return null;
+    }, []);
+
+    return Scaffold(
+        backgroundColor: Colors.black,
+        appBar: TopControlAppBar(
+          asset: asset,
+        ),
+        body: Builder(
+          builder: (context) {
+            return ListView(
+              children: [
+                Padding(
+                  padding: const EdgeInsets.only(top: 60),
+                  child: Dismissible(
+                    direction: DismissDirection.down,
+                    onDismissed: (_) {
+                      AutoRouter.of(context).pop();
+                    },
+                    movementDuration: const Duration(milliseconds: 5),
+                    resizeDuration: const Duration(milliseconds: 5),
+                    key: Key(heroTag),
+                    child: GestureDetector(
+                      child: Center(
+                        child: Hero(
+                          tag: heroTag,
+                          child: CachedNetworkImage(
+                            fit: BoxFit.fill,
+                            imageUrl: imageUrl,
+                            httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+                            fadeInDuration: const Duration(milliseconds: 250),
+                            errorWidget: (context, url, error) => const Icon(Icons.error),
+                            placeholder: (context, url) {
+                              return CachedNetworkImage(
+                                fit: BoxFit.cover,
+                                imageUrl: thumbnailUrl,
+                                httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+                                placeholderFadeInDuration: const Duration(milliseconds: 0),
+                                progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
+                                  scale: 0.2,
+                                  child: CircularProgressIndicator(value: downloadProgress.progress),
+                                ),
+                                errorWidget: (context, url, error) => const Icon(Icons.error),
+                              );
+                            },
+                          ),
+                        ),
+                      ),
+                    ),
+                  ),
+                ),
+                Container(
+                  decoration: const BoxDecoration(color: Colors.black),
+                  width: MediaQuery.of(context).size.width,
+                  height: MediaQuery.of(context).size.height / 2,
+                  child: Column(
+                    children: [
+                      Icon(
+                        Icons.horizontal_rule_rounded,
+                        color: Colors.grey[50],
+                        size: 40,
+                      ),
+                      Row(
+                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                        children: [
+                          ControlBoxButton(
+                            iconData: Icons.delete_forever_rounded,
+                            label: "Delete",
+                            onPressed: () {},
+                          ),
+                        ],
+                      ),
+                      Padding(
+                        padding: const EdgeInsets.symmetric(vertical: 8.0),
+                        child: Divider(
+                          endIndent: 10,
+                          indent: 10,
+                          thickness: 1,
+                          color: Colors.grey[800],
+                        ),
+                      )
+                    ],
+                  ),
+                ),
+              ],
+            );
+          },
+        ));
+  }
+}
+
+class ControlBoxButton extends StatelessWidget {
+  const ControlBoxButton({Key? key, required this.label, required this.iconData, required this.onPressed})
+      : super(key: key);
+
+  final String label;
+  final IconData iconData;
+  final Function onPressed;
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      width: 60,
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.start,
+        crossAxisAlignment: CrossAxisAlignment.center,
+        children: [
+          IconButton(
+            onPressed: () {
+              onPressed();
+            },
+            icon: Icon(
+              iconData,
+              size: 25,
+              color: Colors.grey[50],
+            ),
+          ),
+          Text(
+            label,
+            style: TextStyle(fontSize: 10, color: Colors.grey[50]),
+          )
+        ],
+      ),
+    );
+  }
+}

+ 18 - 0
mobile/lib/modules/home/services/asset.service.dart

@@ -3,6 +3,7 @@ import 'dart:convert';
 import 'package:flutter/material.dart';
 import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
 import 'package:immich_mobile/shared/services/network.service.dart';
 
 class AssetService {
@@ -58,4 +59,21 @@ class AssetService {
       return [];
     }
   }
+
+  Future<ImmichAssetWithExif?> getAssetById(String assetId) async {
+    try {
+      var res = await _networkService.getRequest(
+        url: "asset/assetById/$assetId",
+      );
+
+      Map<String, dynamic> decodedData = jsonDecode(res.toString());
+
+      ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
+      print("result $result");
+      return result;
+    } catch (e) {
+      debugPrint("Error getAllAsset  ${e.toString()}");
+      return null;
+    }
+  }
 }

+ 82 - 69
mobile/lib/modules/home/ui/immich_sliver_appbar.dart

@@ -1,7 +1,10 @@
 import 'package:auto_route/auto_route.dart';
+import 'package:badges/badges.dart';
 import 'package:flutter/material.dart';
 import 'package:google_fonts/google_fonts.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
+import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/backup_state.model.dart';
@@ -20,79 +23,89 @@ class ImmichSliverAppBar extends ConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final BackUpState _backupState = ref.watch(backupProvider);
-
-    return SliverPadding(
-      padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
-      sliver: SliverAppBar(
-        centerTitle: true,
-        floating: true,
-        pinned: false,
-        snap: false,
-        backgroundColor: Colors.grey[200],
-        shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
-        leading: Builder(
-          builder: (BuildContext context) {
-            return IconButton(
-              icon: const Icon(Icons.account_circle_rounded),
-              onPressed: () {
-                Scaffold.of(context).openDrawer();
-              },
-              tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
-            );
-          },
-        ),
-        title: Text(
-          'IMMICH',
-          style: GoogleFonts.snowburstOne(
-            textStyle: TextStyle(
-              fontWeight: FontWeight.bold,
-              fontSize: 18,
-              color: Theme.of(context).primaryColor,
-            ),
+    bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
+    return SliverAppBar(
+      centerTitle: true,
+      floating: true,
+      pinned: false,
+      snap: false,
+      backgroundColor: Colors.grey[200],
+      shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
+      leading: Builder(
+        builder: (BuildContext context) {
+          return IconButton(
+            icon: const Icon(Icons.account_circle_rounded),
+            onPressed: () {
+              Scaffold.of(context).openDrawer();
+            },
+            tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
+          );
+        },
+      ),
+      title: Text(
+        'IMMICH',
+        style: GoogleFonts.snowburstOne(
+          textStyle: TextStyle(
+            fontWeight: FontWeight.bold,
+            fontSize: 18,
+            color: Theme.of(context).primaryColor,
           ),
         ),
-        actions: [
-          Stack(
-            alignment: AlignmentDirectional.center,
-            children: [
-              _backupState.backupProgress == BackUpProgressEnum.inProgress
-                  ? Positioned(
-                      top: 10,
-                      right: 12,
-                      child: SizedBox(
-                        height: 8,
-                        width: 8,
-                        child: CircularProgressIndicator(
-                          strokeWidth: 1,
-                          valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
-                        ),
+      ),
+      actions: [
+        Stack(
+          alignment: AlignmentDirectional.center,
+          children: [
+            _backupState.backupProgress == BackUpProgressEnum.inProgress
+                ? Positioned(
+                    top: 10,
+                    right: 12,
+                    child: SizedBox(
+                      height: 8,
+                      width: 8,
+                      child: CircularProgressIndicator(
+                        strokeWidth: 1,
+                        valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
                       ),
-                    )
-                  : Container(),
-              IconButton(
-                icon: const Icon(Icons.backup_rounded),
-                tooltip: 'Backup Controller',
-                onPressed: () async {
-                  var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
-
-                  if (onPop == true) {
-                    onPopBack!();
-                  }
-                },
-              ),
-              _backupState.backupProgress == BackUpProgressEnum.inProgress
-                  ? Positioned(
-                      bottom: 5,
-                      child: Text(
-                        _backupState.backingUpAssetCount.toString(),
-                        style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
+                    ),
+                  )
+                : Container(),
+            IconButton(
+              splashRadius: 25,
+              iconSize: 30,
+              icon: _isEnableAutoBackup
+                  ? const Icon(Icons.backup_rounded)
+                  : Badge(
+                      padding: const EdgeInsets.all(4),
+                      elevation: 1,
+                      position: BadgePosition.bottomEnd(bottom: -4, end: -4),
+                      badgeColor: Colors.white,
+                      badgeContent: const Icon(
+                        Icons.cloud_off_rounded,
+                        size: 8,
                       ),
-                    )
-                  : Container()
-            ],
-          ),
-        ],
-      ),
+                      child: const Icon(Icons.backup_rounded)),
+              tooltip: 'Backup Controller',
+              onPressed: () async {
+                var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
+
+                if (onPop == true) {
+                  onPopBack!();
+                }
+              },
+            ),
+            _backupState.backupProgress == BackUpProgressEnum.inProgress
+                ? Positioned(
+                    bottom: 5,
+                    child: Text(
+                      _backupState.backingUpAssetCount.toString(),
+                      style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
+                    ),
+                  )
+                : Container()
+          ],
+        ),
+      ],
     );
   }
 }

+ 1 - 0
mobile/lib/modules/home/ui/thumbnail_image.dart

@@ -56,6 +56,7 @@ class ThumbnailImage extends HookConsumerWidget {
                     '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
                 heroTag: asset.id,
                 thumbnailUrl: thumbnailRequestUrl,
+                asset: asset,
               ),
             );
           } else {

+ 1 - 1
mobile/lib/modules/login/ui/login_form.dart

@@ -15,7 +15,7 @@ class LoginForm extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
     final usernameController = useTextEditingController(text: 'testuser@email.com');
     final passwordController = useTextEditingController(text: 'password');
-    final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283');
+    final serverEndpointController = useTextEditingController(text: 'http://192.168.1.204:2283');
 
     return Center(
       child: ConstrainedBox(

+ 0 - 2
mobile/lib/modules/login/views/login_page.dart

@@ -1,7 +1,5 @@
-import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/login/ui/login_form.dart';
 
 class LoginPage extends HookConsumerWidget {

+ 2 - 1
mobile/lib/routing/router.dart

@@ -3,8 +3,9 @@ import 'package:flutter/widgets.dart';
 import 'package:immich_mobile/modules/login/views/login_page.dart';
 import 'package:immich_mobile/modules/home/views/home_page.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/shared/views/backup_controller_page.dart';
-import 'package:immich_mobile/shared/views/image_viewer_page.dart';
+import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
 import 'package:immich_mobile/shared/views/video_viewer_page.dart';
 
 part 'router.gr.dart';

+ 11 - 5
mobile/lib/routing/router.gr.dart

@@ -41,7 +41,8 @@ class _$AppRouter extends RootStackRouter {
               key: args.key,
               imageUrl: args.imageUrl,
               heroTag: args.heroTag,
-              thumbnailUrl: args.thumbnailUrl));
+              thumbnailUrl: args.thumbnailUrl,
+              asset: args.asset));
     },
     VideoViewerRoute.name: (routeData) {
       final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -96,14 +97,16 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
       {Key? key,
       required String imageUrl,
       required String heroTag,
-      required String thumbnailUrl})
+      required String thumbnailUrl,
+      required ImmichAsset asset})
       : super(ImageViewerRoute.name,
             path: '/image-viewer-page',
             args: ImageViewerRouteArgs(
                 key: key,
                 imageUrl: imageUrl,
                 heroTag: heroTag,
-                thumbnailUrl: thumbnailUrl));
+                thumbnailUrl: thumbnailUrl,
+                asset: asset));
 
   static const String name = 'ImageViewerRoute';
 }
@@ -113,7 +116,8 @@ class ImageViewerRouteArgs {
       {this.key,
       required this.imageUrl,
       required this.heroTag,
-      required this.thumbnailUrl});
+      required this.thumbnailUrl,
+      required this.asset});
 
   final Key? key;
 
@@ -123,9 +127,11 @@ class ImageViewerRouteArgs {
 
   final String thumbnailUrl;
 
+  final ImmichAsset asset;
+
   @override
   String toString() {
-    return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl}';
+    return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset}';
   }
 }
 

+ 187 - 0
mobile/lib/shared/models/exif.model.dart

@@ -0,0 +1,187 @@
+import 'dart:convert';
+
+class ImmichExif {
+  final int? id;
+  final String? assetId;
+  final String? make;
+  final String? model;
+  final String? imageName;
+  final int? exifImageWidth;
+  final int? exifImageHeight;
+  final int? fileSizeInByte;
+  final String? orientation;
+  final String? dateTimeOriginal;
+  final String? modifyDate;
+  final String? lensModel;
+  final double? fNumber;
+  final double? focalLength;
+  final int? iso;
+  final double? exposureTime;
+  final double? latitude;
+  final double? longitude;
+
+  ImmichExif({
+    this.id,
+    this.assetId,
+    this.make,
+    this.model,
+    this.imageName,
+    this.exifImageWidth,
+    this.exifImageHeight,
+    this.fileSizeInByte,
+    this.orientation,
+    this.dateTimeOriginal,
+    this.modifyDate,
+    this.lensModel,
+    this.fNumber,
+    this.focalLength,
+    this.iso,
+    this.exposureTime,
+    this.latitude,
+    this.longitude,
+  });
+
+  ImmichExif copyWith({
+    int? id,
+    String? assetId,
+    String? make,
+    String? model,
+    String? imageName,
+    int? exifImageWidth,
+    int? exifImageHeight,
+    int? fileSizeInByte,
+    String? orientation,
+    String? dateTimeOriginal,
+    String? modifyDate,
+    String? lensModel,
+    double? fNumber,
+    double? focalLength,
+    int? iso,
+    double? exposureTime,
+    double? latitude,
+    double? longitude,
+  }) {
+    return ImmichExif(
+      id: id ?? this.id,
+      assetId: assetId ?? this.assetId,
+      make: make ?? this.make,
+      model: model ?? this.model,
+      imageName: imageName ?? this.imageName,
+      exifImageWidth: exifImageWidth ?? this.exifImageWidth,
+      exifImageHeight: exifImageHeight ?? this.exifImageHeight,
+      fileSizeInByte: fileSizeInByte ?? this.fileSizeInByte,
+      orientation: orientation ?? this.orientation,
+      dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
+      modifyDate: modifyDate ?? this.modifyDate,
+      lensModel: lensModel ?? this.lensModel,
+      fNumber: fNumber ?? this.fNumber,
+      focalLength: focalLength ?? this.focalLength,
+      iso: iso ?? this.iso,
+      exposureTime: exposureTime ?? this.exposureTime,
+      latitude: latitude ?? this.latitude,
+      longitude: longitude ?? this.longitude,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'id': id,
+      'assetId': assetId,
+      'make': make,
+      'model': model,
+      'imageName': imageName,
+      'exifImageWidth': exifImageWidth,
+      'exifImageHeight': exifImageHeight,
+      'fileSizeInByte': fileSizeInByte,
+      'orientation': orientation,
+      'dateTimeOriginal': dateTimeOriginal,
+      'modifyDate': modifyDate,
+      'lensModel': lensModel,
+      'fNumber': fNumber,
+      'focalLength': focalLength,
+      'iso': iso,
+      'exposureTime': exposureTime,
+      'latitude': latitude,
+      'longitude': longitude,
+    };
+  }
+
+  factory ImmichExif.fromMap(Map<String, dynamic> map) {
+    return ImmichExif(
+      id: map['id']?.toInt(),
+      assetId: map['assetId'],
+      make: map['make'],
+      model: map['model'],
+      imageName: map['imageName'],
+      exifImageWidth: map['exifImageWidth']?.toInt(),
+      exifImageHeight: map['exifImageHeight']?.toInt(),
+      fileSizeInByte: map['fileSizeInByte']?.toInt(),
+      orientation: map['orientation'],
+      dateTimeOriginal: map['dateTimeOriginal'],
+      modifyDate: map['modifyDate'],
+      lensModel: map['lensModel'],
+      fNumber: map['fNumber']?.toDouble(),
+      focalLength: map['focalLength']?.toDouble(),
+      iso: map['iso']?.toInt(),
+      exposureTime: map['exposureTime']?.toDouble(),
+      latitude: map['latitude']?.toDouble(),
+      longitude: map['longitude']?.toDouble(),
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory ImmichExif.fromJson(String source) => ImmichExif.fromMap(json.decode(source));
+
+  @override
+  String toString() {
+    return 'ImmichExif(id: $id, assetId: $assetId, make: $make, model: $model, imageName: $imageName, exifImageWidth: $exifImageWidth, exifImageHeight: $exifImageHeight, fileSizeInByte: $fileSizeInByte, orientation: $orientation, dateTimeOriginal: $dateTimeOriginal, modifyDate: $modifyDate, lensModel: $lensModel, fNumber: $fNumber, focalLength: $focalLength, iso: $iso, exposureTime: $exposureTime, latitude: $latitude, longitude: $longitude)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is ImmichExif &&
+        other.id == id &&
+        other.assetId == assetId &&
+        other.make == make &&
+        other.model == model &&
+        other.imageName == imageName &&
+        other.exifImageWidth == exifImageWidth &&
+        other.exifImageHeight == exifImageHeight &&
+        other.fileSizeInByte == fileSizeInByte &&
+        other.orientation == orientation &&
+        other.dateTimeOriginal == dateTimeOriginal &&
+        other.modifyDate == modifyDate &&
+        other.lensModel == lensModel &&
+        other.fNumber == fNumber &&
+        other.focalLength == focalLength &&
+        other.iso == iso &&
+        other.exposureTime == exposureTime &&
+        other.latitude == latitude &&
+        other.longitude == longitude;
+  }
+
+  @override
+  int get hashCode {
+    return id.hashCode ^
+        assetId.hashCode ^
+        make.hashCode ^
+        model.hashCode ^
+        imageName.hashCode ^
+        exifImageWidth.hashCode ^
+        exifImageHeight.hashCode ^
+        fileSizeInByte.hashCode ^
+        orientation.hashCode ^
+        dateTimeOriginal.hashCode ^
+        modifyDate.hashCode ^
+        lensModel.hashCode ^
+        fNumber.hashCode ^
+        focalLength.hashCode ^
+        iso.hashCode ^
+        exposureTime.hashCode ^
+        latitude.hashCode ^
+        longitude.hashCode;
+  }
+}

+ 125 - 0
mobile/lib/shared/models/immich_asset_with_exif.model.dart

@@ -0,0 +1,125 @@
+import 'dart:convert';
+
+import 'package:immich_mobile/shared/models/exif.model.dart';
+
+class ImmichAssetWithExif {
+  final String id;
+  final String deviceAssetId;
+  final String userId;
+  final String deviceId;
+  final String type;
+  final String createdAt;
+  final String modifiedAt;
+  final bool isFavorite;
+  final String? duration;
+  final ImmichExif? exifInfo;
+
+  ImmichAssetWithExif({
+    required this.id,
+    required this.deviceAssetId,
+    required this.userId,
+    required this.deviceId,
+    required this.type,
+    required this.createdAt,
+    required this.modifiedAt,
+    required this.isFavorite,
+    this.duration,
+    this.exifInfo,
+  });
+
+  ImmichAssetWithExif copyWith({
+    String? id,
+    String? deviceAssetId,
+    String? userId,
+    String? deviceId,
+    String? type,
+    String? createdAt,
+    String? modifiedAt,
+    bool? isFavorite,
+    String? duration,
+    ImmichExif? exifInfo,
+  }) {
+    return ImmichAssetWithExif(
+      id: id ?? this.id,
+      deviceAssetId: deviceAssetId ?? this.deviceAssetId,
+      userId: userId ?? this.userId,
+      deviceId: deviceId ?? this.deviceId,
+      type: type ?? this.type,
+      createdAt: createdAt ?? this.createdAt,
+      modifiedAt: modifiedAt ?? this.modifiedAt,
+      isFavorite: isFavorite ?? this.isFavorite,
+      duration: duration ?? this.duration,
+      exifInfo: exifInfo ?? this.exifInfo,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'id': id,
+      'deviceAssetId': deviceAssetId,
+      'userId': userId,
+      'deviceId': deviceId,
+      'type': type,
+      'createdAt': createdAt,
+      'modifiedAt': modifiedAt,
+      'isFavorite': isFavorite,
+      'duration': duration,
+      'exifInfo': exifInfo?.toMap(),
+    };
+  }
+
+  factory ImmichAssetWithExif.fromMap(Map<String, dynamic> map) {
+    return ImmichAssetWithExif(
+      id: map['id'] ?? '',
+      deviceAssetId: map['deviceAssetId'] ?? '',
+      userId: map['userId'] ?? '',
+      deviceId: map['deviceId'] ?? '',
+      type: map['type'] ?? '',
+      createdAt: map['createdAt'] ?? '',
+      modifiedAt: map['modifiedAt'] ?? '',
+      isFavorite: map['isFavorite'] ?? false,
+      duration: map['duration'],
+      exifInfo: map['exifInfo'] != null ? ImmichExif.fromMap(map['exifInfo']) : null,
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory ImmichAssetWithExif.fromJson(String source) => ImmichAssetWithExif.fromMap(json.decode(source));
+
+  @override
+  String toString() {
+    return 'ImmichAssetWithExif(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration, exifInfo: $exifInfo)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is ImmichAssetWithExif &&
+        other.id == id &&
+        other.deviceAssetId == deviceAssetId &&
+        other.userId == userId &&
+        other.deviceId == deviceId &&
+        other.type == type &&
+        other.createdAt == createdAt &&
+        other.modifiedAt == modifiedAt &&
+        other.isFavorite == isFavorite &&
+        other.duration == duration &&
+        other.exifInfo == exifInfo;
+  }
+
+  @override
+  int get hashCode {
+    return id.hashCode ^
+        deviceAssetId.hashCode ^
+        userId.hashCode ^
+        deviceId.hashCode ^
+        type.hashCode ^
+        createdAt.hashCode ^
+        modifiedAt.hashCode ^
+        isFavorite.hashCode ^
+        duration.hashCode ^
+        exifInfo.hashCode;
+  }
+}

+ 0 - 64
mobile/lib/shared/views/image_viewer_page.dart

@@ -1,64 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:cached_network_image/cached_network_image.dart';
-import 'package:flutter/material.dart';
-import 'package:hive/hive.dart';
-import 'package:immich_mobile/constants/hive_box.dart';
-
-class ImageViewerPage extends StatelessWidget {
-  final String imageUrl;
-  final String heroTag;
-  final String thumbnailUrl;
-
-  const ImageViewerPage({Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl})
-      : super(key: key);
-
-  @override
-  Widget build(BuildContext context) {
-    var box = Hive.box(userInfoBox);
-
-    return Scaffold(
-      backgroundColor: Colors.black,
-      appBar: AppBar(
-        toolbarHeight: 60,
-        backgroundColor: Colors.black,
-        leading: IconButton(
-            onPressed: () {
-              AutoRouter.of(context).pop();
-            },
-            icon: const Icon(Icons.arrow_back_ios)),
-      ),
-      body: Dismissible(
-        direction: DismissDirection.vertical,
-        onDismissed: (_) {
-          AutoRouter.of(context).pop();
-        },
-        key: Key(heroTag),
-        child: Center(
-          child: Hero(
-            tag: heroTag,
-            child: CachedNetworkImage(
-              fit: BoxFit.cover,
-              imageUrl: imageUrl,
-              httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-              fadeInDuration: const Duration(milliseconds: 250),
-              errorWidget: (context, url, error) => const Icon(Icons.error),
-              placeholder: (context, url) {
-                return CachedNetworkImage(
-                  fit: BoxFit.cover,
-                  imageUrl: thumbnailUrl,
-                  httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-                  placeholderFadeInDuration: const Duration(milliseconds: 0),
-                  progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
-                    scale: 0.2,
-                    child: CircularProgressIndicator(value: downloadProgress.progress),
-                  ),
-                  errorWidget: (context, url, error) => const Icon(Icons.error),
-                );
-              },
-            ),
-          ),
-        ),
-      ),
-    );
-  }
-}

+ 1 - 1
mobile/makefile

@@ -2,7 +2,7 @@ build:
 	flutter packages pub run build_runner build
 
 watch:
-	flutter packages pub run build_runner watch
+	flutter packages pub run build_runner watch --delete-conflicting-outputs
 
 create_app_icon:
 	flutter pub run flutter_launcher_icons:main

+ 8 - 8
mobile/pubspec.lock

@@ -50,6 +50,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "3.2.1"
+  badges:
+    dependency: "direct main"
+    description:
+      name: badges
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.2"
   boolean_selector:
     dependency: transitive
     description:
@@ -513,13 +520,6 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "0.12.11"
-  material_color_utilities:
-    dependency: transitive
-    description:
-      name: material_color_utilities
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "0.1.3"
   meta:
     dependency: transitive
     description:
@@ -825,7 +825,7 @@ packages:
       name: test_api
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.4.8"
+    version: "0.4.3"
   timing:
     dependency: transitive
     description:

+ 1 - 0
mobile/pubspec.yaml

@@ -31,6 +31,7 @@ dependencies:
   video_player: ^2.2.18
   chewie: ^1.2.2
   sliver_tools: ^0.2.5
+  badges: ^2.0.2
 
 dev_dependencies:
   flutter_test: