diff --git a/README.md b/README.md index 873beb4bab..51378c86d8 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,11 @@ This project is under heavy development, there will be continous functions, feat # Features [x] Upload assets(videos/images) + [x] View assets + [x] Quick navigation with drag scroll bar + [x] Auto Backup # Development diff --git a/mobile/lib/modules/home/models/home_page_state.model.dart b/mobile/lib/modules/home/models/home_page_state.model.dart new file mode 100644 index 0000000000..6a094930c3 --- /dev/null +++ b/mobile/lib/modules/home/models/home_page_state.model.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; + +import 'package:immich_mobile/shared/models/immich_asset.model.dart'; + +class HomePageState { + final bool isMultiSelectEnable; + final Set selectedItems; + final Set selectedDateGroup; + HomePageState({ + required this.isMultiSelectEnable, + required this.selectedItems, + required this.selectedDateGroup, + }); + + HomePageState copyWith({ + bool? isMultiSelectEnable, + Set? selectedItems, + Set? selectedDateGroup, + }) { + return HomePageState( + isMultiSelectEnable: isMultiSelectEnable ?? this.isMultiSelectEnable, + selectedItems: selectedItems ?? this.selectedItems, + selectedDateGroup: selectedDateGroup ?? this.selectedDateGroup, + ); + } + + Map toMap() { + return { + 'isMultiSelectEnable': isMultiSelectEnable, + 'selectedItems': selectedItems.map((x) => x.toMap()).toList(), + 'selectedDateGroup': selectedDateGroup.toList(), + }; + } + + factory HomePageState.fromMap(Map map) { + return HomePageState( + isMultiSelectEnable: map['isMultiSelectEnable'] ?? false, + selectedItems: Set.from(map['selectedItems']?.map((x) => ImmichAsset.fromMap(x))), + selectedDateGroup: Set.from(map['selectedDateGroup']), + ); + } + + String toJson() => json.encode(toMap()); + + factory HomePageState.fromJson(String source) => HomePageState.fromMap(json.decode(source)); + + @override + String toString() => + 'HomePageState(isMultiSelectEnable: $isMultiSelectEnable, selectedItems: $selectedItems, selectedDateGroup: $selectedDateGroup)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + final setEquals = const DeepCollectionEquality().equals; + + return other is HomePageState && + other.isMultiSelectEnable == isMultiSelectEnable && + setEquals(other.selectedItems, selectedItems) && + setEquals(other.selectedDateGroup, selectedDateGroup); + } + + @override + int get hashCode => isMultiSelectEnable.hashCode ^ selectedItems.hashCode ^ selectedDateGroup.hashCode; +} diff --git a/mobile/lib/modules/home/providers/home_page_state.provider.dart b/mobile/lib/modules/home/providers/home_page_state.provider.dart new file mode 100644 index 0000000000..940737e9cd --- /dev/null +++ b/mobile/lib/modules/home/providers/home_page_state.provider.dart @@ -0,0 +1,63 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/models/home_page_state.model.dart'; +import 'package:immich_mobile/shared/models/immich_asset.model.dart'; + +class HomePageStateNotifier extends StateNotifier { + HomePageStateNotifier() + : super( + HomePageState( + isMultiSelectEnable: false, + selectedItems: {}, + selectedDateGroup: {}, + ), + ); + + void addSelectedDateGroup(String dateGroupTitle) { + state = state.copyWith(selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle}); + } + + void removeSelectedDateGroup(String dateGroupTitle) { + var currentDateGroup = state.selectedDateGroup; + + currentDateGroup.removeWhere((e) => e == dateGroupTitle); + + state = state.copyWith(selectedDateGroup: currentDateGroup); + } + + void enableMultiSelect(Set selectedItems) { + state = state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems); + } + + void disableMultiSelect() { + state = state.copyWith(isMultiSelectEnable: false, selectedItems: {}, selectedDateGroup: {}); + } + + void addSingleSelectedItem(ImmichAsset asset) { + state = state.copyWith(selectedItems: {...state.selectedItems, asset}); + } + + void addMultipleSelectedItems(List assets) { + state = state.copyWith(selectedItems: {...state.selectedItems, ...assets}); + } + + void removeSingleSelectedItem(ImmichAsset asset) { + Set currentList = state.selectedItems; + + currentList.removeWhere((e) => e.id == asset.id); + + state = state.copyWith(selectedItems: currentList); + } + + void removeMultipleSelectedItem(List assets) { + Set currentList = state.selectedItems; + + for (ImmichAsset asset in assets) { + currentList.removeWhere((e) => e.id == asset.id); + } + + state = state.copyWith(selectedItems: currentList); + } +} + +final homePageStateProvider = + StateNotifierProvider(((ref) => HomePageStateNotifier())); diff --git a/mobile/lib/modules/home/ui/daily_title_text.dart b/mobile/lib/modules/home/ui/daily_title_text.dart new file mode 100644 index 0000000000..add168c70a --- /dev/null +++ b/mobile/lib/modules/home/ui/daily_title_text.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart'; +import 'package:immich_mobile/shared/models/immich_asset.model.dart'; +import 'package:intl/intl.dart'; + +class DailyTitleText extends ConsumerWidget { + const DailyTitleText({ + Key? key, + required this.isoDate, + required this.assetGroup, + }) : super(key: key); + + final String isoDate; + final List assetGroup; + + @override + Widget build(BuildContext context, WidgetRef ref) { + var currentYear = DateTime.now().year; + var groupYear = DateTime.parse(isoDate).year; + var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy'; + var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate)); + var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable; + var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup; + var selectedItems = ref.watch(homePageStateProvider).selectedItems; + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 12.0, right: 12.0), + child: Row( + children: [ + Text( + dateText, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const Spacer(), + GestureDetector( + onTap: () { + if (isMultiSelectEnable && + selectedDateGroup.contains(dateText) && + selectedDateGroup.length == 1 && + selectedItems.length == assetGroup.length) { + ref.watch(homePageStateProvider.notifier).disableMultiSelect(); + } else if (isMultiSelectEnable && + selectedDateGroup.contains(dateText) && + selectedItems.length != assetGroup.length) { + ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText); + ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup); + } else if (isMultiSelectEnable && + selectedDateGroup.contains(dateText) && + selectedDateGroup.length > 1) { + ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText); + ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup); + } else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) { + ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText); + ref.watch(homePageStateProvider.notifier).addMultipleSelectedItems(assetGroup); + } else { + ref.watch(homePageStateProvider.notifier).enableMultiSelect(assetGroup.toSet()); + ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText); + } + }, + child: isMultiSelectEnable && selectedDateGroup.contains(dateText) + ? const Icon(Icons.check_circle_rounded) + : const Icon(Icons.check_circle_outline_rounded), + ) + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/home/ui/monthly_title_text.dart b/mobile/lib/modules/home/ui/monthly_title_text.dart new file mode 100644 index 0000000000..8df0334317 --- /dev/null +++ b/mobile/lib/modules/home/ui/monthly_title_text.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class MonthlyTitleText extends StatelessWidget { + const MonthlyTitleText({ + Key? key, + required this.isoDate, + }) : super(key: key); + + final String isoDate; + + @override + Widget build(BuildContext context) { + var monthTitleText = DateFormat('MMMM y').format(DateTime.parse(isoDate)); + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 32), + child: Text( + monthTitleText, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart index 64857cc81e..6d35749933 100644 --- a/mobile/lib/modules/home/ui/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/thumbnail_image.dart @@ -1,66 +1,121 @@ import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hive_flutter/hive_flutter.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/routing/router.dart'; -class ThumbnailImage extends HookWidget { +class ThumbnailImage extends HookConsumerWidget { final ImmichAsset asset; const ThumbnailImage({Key? key, required this.asset}) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final cacheKey = useState(1); var box = Hive.box(userInfoBox); var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; + + var selectedAsset = ref.watch(homePageStateProvider).selectedItems; + var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable; + + Widget _buildSelectionIcon(ImmichAsset asset) { + if (selectedAsset.contains(asset)) { + return Icon( + Icons.check_circle, + color: Theme.of(context).primaryColor, + ); + } else { + return const Icon( + Icons.circle_outlined, + color: Colors.white, + ); + } + } + return GestureDetector( onTap: () { - if (asset.type == 'IMAGE') { - AutoRouter.of(context).push( - ImageViewerRoute( - imageUrl: - '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', - heroTag: asset.id, - thumbnailUrl: thumbnailRequestUrl, - ), - ); + if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) { + ref.watch(homePageStateProvider.notifier).disableMultiSelect(); + } else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) { + ref.watch(homePageStateProvider.notifier).removeSingleSelectedItem(asset); + } else if (isMultiSelectEnable && !selectedAsset.contains(asset)) { + ref.watch(homePageStateProvider.notifier).addSingleSelectedItem(asset); } else { - debugPrint("Navigate to video player"); + if (asset.type == 'IMAGE') { + AutoRouter.of(context).push( + ImageViewerRoute( + imageUrl: + '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', + heroTag: asset.id, + thumbnailUrl: thumbnailRequestUrl, + ), + ); + } else { + debugPrint("Navigate to video player"); - AutoRouter.of(context).push( - VideoViewerRoute( - videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', - ), - ); + AutoRouter.of(context).push( + VideoViewerRoute( + videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', + ), + ); + } } }, - onLongPress: () {}, + onLongPress: () { + // Enable multi selecte function + ref.watch(homePageStateProvider.notifier).enableMultiSelect({asset}); + HapticFeedback.heavyImpact(); + }, child: Hero( tag: asset.id, - child: CachedNetworkImage( - cacheKey: "${asset.id}-${cacheKey.value}", - width: 300, - height: 300, - memCacheHeight: asset.type == 'IMAGE' ? 250 : 400, - fit: BoxFit.cover, - imageUrl: thumbnailRequestUrl, - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - fadeInDuration: const Duration(milliseconds: 250), - progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( - scale: 0.2, - child: CircularProgressIndicator(value: downloadProgress.progress), - ), - errorWidget: (context, url, error) { - debugPrint("Error Loading Thumbnail Widget $error"); - cacheKey.value += 1; - return const Icon(Icons.error); - }, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + border: isMultiSelectEnable && selectedAsset.contains(asset) + ? Border.all(color: Theme.of(context).primaryColorLight, width: 10) + : const Border(), + ), + child: CachedNetworkImage( + cacheKey: "${asset.id}-${cacheKey.value}", + width: 300, + height: 300, + memCacheHeight: asset.type == 'IMAGE' ? 250 : 400, + fit: BoxFit.cover, + imageUrl: thumbnailRequestUrl, + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + fadeInDuration: const Duration(milliseconds: 250), + progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( + scale: 0.2, + child: CircularProgressIndicator(value: downloadProgress.progress), + ), + errorWidget: (context, url, error) { + debugPrint("Error Loading Thumbnail Widget $error"); + cacheKey.value += 1; + return const Icon(Icons.error); + }, + ), + ), + Container( + child: isMultiSelectEnable + ? Padding( + padding: const EdgeInsets.all(3.0), + child: Align( + alignment: Alignment.topLeft, + child: _buildSelectionIcon(asset), + ), + ) + : Container(), + ), + ], ), ), ); diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 8e277486dd..f302eeb36c 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -1,24 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/daily_title_text.dart'; import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart'; +import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; -import 'package:intl/intl.dart'; class HomePage extends HookConsumerWidget { const HomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { - final ValueNotifier _showBackToTopBtn = useState(false); ScrollController _scrollController = useScrollController(); - - List assetGroup = ref.watch(assetProvider); - List imageGridGroup = []; + List _assetGroup = ref.watch(assetProvider); + List _imageGridGroup = []; _scrollControllerCallback() { var endOfPage = _scrollController.position.maxScrollExtent; @@ -26,12 +25,6 @@ class HomePage extends HookConsumerWidget { if (_scrollController.offset >= endOfPage - (endOfPage * 0.1) && !_scrollController.position.outOfRange) { ref.read(assetProvider.notifier).getOlderAsset(); } - - if (_scrollController.offset >= 400) { - _showBackToTopBtn.value = true; - } else { - _showBackToTopBtn.value = false; - } } useEffect(() { @@ -49,18 +42,18 @@ class HomePage extends HookConsumerWidget { // Remove and force getting new widget again if there is not many widget on screen. // Otherwise do nothing. - if (imageGridGroup.isNotEmpty && imageGridGroup.length < 20) { + if (_imageGridGroup.isNotEmpty && _imageGridGroup.length < 20) { ref.read(assetProvider.notifier).getOlderAsset(); - } else if (imageGridGroup.isEmpty) { + } else if (_imageGridGroup.isEmpty) { ref.read(assetProvider.notifier).getImmichAssets(); } } Widget _buildBody() { - if (assetGroup.isNotEmpty) { - String lastGroupDate = assetGroup[0].date; + if (_assetGroup.isNotEmpty) { + String lastGroupDate = _assetGroup[0].date; - for (var group in assetGroup) { + for (var group in _assetGroup) { var dateTitle = group.date; var assetGroup = group.assets; @@ -71,19 +64,19 @@ class HomePage extends HookConsumerWidget { if (currentMonth != null && previousMonth != null) { if ((currentMonth - previousMonth) != 0) { - imageGridGroup.add( + _imageGridGroup.add( MonthlyTitleText(isoDate: dateTitle), ); } } // Add Daily Title Group - imageGridGroup.add( - DailyTitleText(isoDate: dateTitle), + _imageGridGroup.add( + DailyTitleText(isoDate: dateTitle, assetGroup: assetGroup), ); // Add Image Group - imageGridGroup.add( + _imageGridGroup.add( ImageGrid(assetGroup: assetGroup), ); // @@ -100,10 +93,10 @@ class HomePage extends HookConsumerWidget { controller: _scrollController, slivers: [ ImmichSliverAppBar( - imageGridGroup: imageGridGroup, + imageGridGroup: _imageGridGroup, onPopBack: onPopBackFromBackupPage, ), - ...imageGridGroup, + ..._imageGridGroup, ], ), ), @@ -116,69 +109,3 @@ class HomePage extends HookConsumerWidget { ); } } - -class MonthlyTitleText extends StatelessWidget { - const MonthlyTitleText({ - Key? key, - required this.isoDate, - }) : super(key: key); - - final String isoDate; - - @override - Widget build(BuildContext context) { - var monthTitleText = DateFormat('MMMM, y').format(DateTime.parse(isoDate)); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 10.0, top: 32), - child: Text( - monthTitleText, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).primaryColor, - ), - ), - ), - ); - } -} - -class DailyTitleText extends StatelessWidget { - const DailyTitleText({ - Key? key, - required this.isoDate, - }) : super(key: key); - - final String isoDate; - - @override - Widget build(BuildContext context) { - var currentYear = DateTime.now().year; - var groupYear = DateTime.parse(isoDate).year; - var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy'; - var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate)); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 24.0, bottom: 24.0, left: 3.0), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 8.0, bottom: 5.0, top: 5.0), - child: Text( - dateText, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ), - ], - ), - ), - ); - } -}