From f32cd74232a62b63fbbade4231c892b78e8bca46 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:01:04 +0530 Subject: [PATCH] feat: show stacks in asset viewer (#19935) * feat: show stacks in asset viewer * fix: global key issue and flash on stack asset change * feat(mobile): stack and unstack action (#19941) * feat(mobile): stack and unstack action * add custom model * use stackId from ActionSource * Update mobile/lib/providers/infrastructure/action.provider.dart Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * fix: lint * fix: bad merge * fix: test --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Co-authored-by: Daimolean <92239625+wuzihao051119@users.noreply.github.com> Co-authored-by: wuzihao051119 --- i18n/en.json | 2 + .../drift_schemas/main/drift_schema_v1.json | Bin 27630 -> 27992 bytes .../drift_schemas/main/drift_schema_v2.json | Bin 27821 -> 27992 bytes .../lib/immich_mobile_immich_lint.dart | 2 +- .../models/asset/local_asset.model.dart | 1 + .../models/asset/remote_asset.model.dart | 21 +++- mobile/lib/domain/models/stack.model.dart | 24 ++++ mobile/lib/domain/models/timeline.model.dart | 6 + mobile/lib/domain/services/asset.service.dart | 11 ++ mobile/lib/domain/utils/event_stream.dart | 10 +- .../entities/merged_asset.drift | 39 +++++- .../entities/merged_asset.drift.dart | Bin 5646 -> 6681 bytes .../entities/remote_asset.entity.dart | 3 + .../entities/remote_asset.entity.drift.dart | Bin 50716 -> 52935 bytes .../repositories/db.repository.drift.dart | Bin 11199 -> 11199 bytes .../repositories/db.repository.steps.dart | Bin 35726 -> 36018 bytes .../repositories/remote_asset.repository.dart | 100 ++++++++++++++- .../repositories/sync_stream.repository.dart | 1 + .../repositories/timeline.repository.dart | 2 + .../pages/dev/main_timeline.page.dart | 5 +- .../stack_action_button.widget.dart | 42 ++++++- .../unstack_action_button.widget.dart | 49 ++++++++ .../asset_viewer/asset_stack.provider.dart | 24 ++++ .../asset_viewer/asset_stack.widget.dart | 119 ++++++++++++++++++ .../asset_viewer/asset_viewer.page.dart | 37 +++++- .../asset_viewer/asset_viewer.state.dart | 30 ++++- .../archive_bottom_sheet.widget.dart | 2 +- .../favorite_bottom_sheet.widget.dart | 2 +- .../general_bottom_sheet.widget.dart | 2 +- .../remote_album_bottom_sheet.widget.dart | 2 +- .../widgets/images/thumbnail_tile.widget.dart | 54 +++++++- .../widgets/timeline/timeline.state.dart | 4 + .../widgets/timeline/timeline.widget.dart | 3 + .../infrastructure/action.provider.dart | 72 ++++++++--- .../repositories/asset_api.repository.dart | 26 +++- mobile/lib/services/action.service.dart | 10 ++ .../common/mesmerizing_sliver_app_bar.dart | 1 + .../common/remote_album_sliver_app_bar.dart | 1 + mobile/lib/widgets/map/map_thumbnail.dart | 10 +- .../test/drift/main/generated/schema_v1.dart | Bin 172666 -> 175636 bytes .../test/drift/main/generated/schema_v2.dart | Bin 174376 -> 175636 bytes 41 files changed, 659 insertions(+), 58 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart diff --git a/i18n/en.json b/i18n/en.json index 89780f62b..dfe2954c9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1795,6 +1795,7 @@ "sort_title": "Title", "source": "Source", "stack": "Stack", + "stack_action_prompt": "{count} stacked", "stack_duplicates": "Stack duplicates", "stack_select_one_photo": "Select one main photo for the stack", "stack_selected_photos": "Stack selected photos", @@ -1905,6 +1906,7 @@ "unselect_all_duplicates": "Unselect all duplicates", "unselect_all_in": "Unselect all in {group}", "unstack": "Un-stack", + "unstack_action_prompt": "{count} unstacked", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "untagged": "Untagged", "up_next": "Up next", diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 03656ce0b479f7b362f6132646fe46d9993ddb27..c19bcfb945525dd24fb8fb5fbf917d2042395539 100644 GIT binary patch delta 294 zcmaENo$8i3hDs2+$B!BkFYvctc z8_3s8_E4~#{8T=9@>KlhjVl`C$x^>1UI+!HRqVrZme01^SJ=iSU2+|Dw&DoJ4TobY>Glhx9I R(mIBwU=8b&@|l@pYXKSSX4L=y delta 230 zcmca{i}Brc#tjdIH_sQ*Vx0U@HEr`Pu>+iw)75GwhpP!p?pN2GETf)0`LUYi2bBf@Sg+LxIWmVj7d*8?tWJ)b3`RoM9@+Y@}le65RaWu!?!|4p)#F z+><@U#5d12l@*wL++Sex3D^IeBE~vOMX712MX7nosl`fG(S|yPMnHXvoB#N?F;Di6 x5MVJh(lG#uOn&3ayZK0PJIiFtWP#1m5%;(zKTQKl>lm7XHRL7dGc(230szC?TB`s6 delta 225 zcmca{i*fBu#tkP$Cx28;+k8vx0O#a%wVKJ{Y66q{)io!}s3%W;tY$fRhk6L3*=Ami zSeD5=8b+Jv=$v4goTVc$`IVT)<~fEF*e0hK3NRb#SZsE%Sj0S8*8-%Qd-Gpw69GoU z$=wRZlMSMHHy8Pw;}kK{Q7TGJOD#&xOHM6TvWhm;F*Ka)V4( LintCode( + static LintCode makeCode(String name, LintOptions options) => LintCode( name: name, problemMessage: options.json["message"] as String, errorSeverity: ErrorSeverity.WARNING, diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 0c3e8fa94..3466a0f25 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -45,6 +45,7 @@ class LocalAsset extends BaseAsset { }'''; } + // Not checking for remoteId here @override bool operator ==(Object other) { if (other is! LocalAsset) return false; diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 9e4cfa1f1..760a16170 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -14,6 +14,8 @@ class RemoteAsset extends BaseAsset { final String? thumbHash; final AssetVisibility visibility; final String ownerId; + final String? stackId; + final int stackCount; const RemoteAsset({ required this.id, @@ -31,6 +33,8 @@ class RemoteAsset extends BaseAsset { this.thumbHash, this.visibility = AssetVisibility.timeline, super.livePhotoVideoId, + this.stackId, + this.stackCount = 0, }); @override @@ -56,9 +60,14 @@ class RemoteAsset extends BaseAsset { isFavorite: $isFavorite, thumbHash: ${thumbHash ?? ""}, visibility: $visibility, + stackId: ${stackId ?? ""}, + stackCount: $stackCount, + checksum: $checksum, + livePhotoVideoId: ${livePhotoVideoId ?? ""}, }'''; } + // Not checking for localId here @override bool operator ==(Object other) { if (other is! RemoteAsset) return false; @@ -67,7 +76,9 @@ class RemoteAsset extends BaseAsset { id == other.id && ownerId == other.ownerId && thumbHash == other.thumbHash && - visibility == other.visibility; + visibility == other.visibility && + stackId == other.stackId && + stackCount == other.stackCount; } @override @@ -77,7 +88,9 @@ class RemoteAsset extends BaseAsset { ownerId.hashCode ^ localId.hashCode ^ thumbHash.hashCode ^ - visibility.hashCode; + visibility.hashCode ^ + stackId.hashCode ^ + stackCount.hashCode; RemoteAsset copyWith({ String? id, @@ -95,6 +108,8 @@ class RemoteAsset extends BaseAsset { String? thumbHash, AssetVisibility? visibility, String? livePhotoVideoId, + String? stackId, + int? stackCount, }) { return RemoteAsset( id: id ?? this.id, @@ -112,6 +127,8 @@ class RemoteAsset extends BaseAsset { thumbHash: thumbHash ?? this.thumbHash, visibility: visibility ?? this.visibility, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + stackId: stackId ?? this.stackId, + stackCount: stackCount ?? this.stackCount, ); } } diff --git a/mobile/lib/domain/models/stack.model.dart b/mobile/lib/domain/models/stack.model.dart index 5404eb8f4..d7faf07a2 100644 --- a/mobile/lib/domain/models/stack.model.dart +++ b/mobile/lib/domain/models/stack.model.dart @@ -82,3 +82,27 @@ class Stack { primaryAssetId.hashCode; } } + +class StackResponse { + final String id; + final String primaryAssetId; + final List assetIds; + + const StackResponse({ + required this.id, + required this.primaryAssetId, + required this.assetIds, + }); + + @override + bool operator ==(covariant StackResponse other) { + if (identical(this, other)) return true; + + return other.id == id && + other.primaryAssetId == primaryAssetId && + other.assetIds == assetIds; + } + + @override + int get hashCode => id.hashCode ^ primaryAssetId.hashCode ^ assetIds.hashCode; +} diff --git a/mobile/lib/domain/models/timeline.model.dart b/mobile/lib/domain/models/timeline.model.dart index 4a49708b7..f3b688b8b 100644 --- a/mobile/lib/domain/models/timeline.model.dart +++ b/mobile/lib/domain/models/timeline.model.dart @@ -1,3 +1,5 @@ +import 'package:immich_mobile/domain/utils/event_stream.dart'; + enum GroupAssetsBy { day, month, @@ -38,3 +40,7 @@ class TimeBucket extends Bucket { @override int get hashCode => super.hashCode ^ date.hashCode; } + +class TimelineReloadEvent extends Event { + const TimelineReloadEvent(); +} diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 2c9b49318..63b1aad8c 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -24,6 +24,17 @@ class AssetService { : _remoteAssetRepository.watchAsset(id); } + Future> getStack(RemoteAsset asset) async { + if (asset.stackId == null) { + return []; + } + + return _remoteAssetRepository.getStackChildren(asset).then((assets) { + // Include the primary asset in the stack as the first item + return [asset, ...assets]; + }); + } + Future getExif(BaseAsset asset) async { if (!asset.hasRemote) { return null; diff --git a/mobile/lib/domain/utils/event_stream.dart b/mobile/lib/domain/utils/event_stream.dart index 65ee17e12..e728ece58 100644 --- a/mobile/lib/domain/utils/event_stream.dart +++ b/mobile/lib/domain/utils/event_stream.dart @@ -1,17 +1,9 @@ import 'dart:async'; -sealed class Event { +class Event { const Event(); } -class TimelineReloadEvent extends Event { - const TimelineReloadEvent(); -} - -class ViewerOpenBottomSheetEvent extends Event { - const ViewerOpenBottomSheetEvent(); -} - class EventStream { EventStream._(); diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index e07edbc0c..3dc7221c1 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -1,5 +1,6 @@ import 'remote_asset.entity.dart'; import 'local_asset.entity.dart'; +import 'stack.entity.dart'; mergedAsset: SELECT * FROM ( @@ -18,13 +19,33 @@ mergedAsset: SELECT * FROM rae.checksum, rae.owner_id, rae.live_photo_video_id, - 0 as orientation + 0 as orientation, + rae.stack_id, + COALESCE(stack_count.total_count, 0) AS stack_count FROM remote_asset_entity rae LEFT JOIN local_asset_entity lae ON rae.checksum = lae.checksum + LEFT JOIN + stack_entity se ON rae.stack_id = se.id + LEFT JOIN + (SELECT + stack_id, + COUNT(*) AS total_count + FROM remote_asset_entity + WHERE deleted_at IS NULL + AND visibility = 0 + AND stack_id IS NOT NULL + GROUP BY stack_id + ) AS stack_count ON rae.stack_id = stack_count.stack_id WHERE - rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ? + rae.deleted_at IS NULL + AND rae.visibility = 0 + AND rae.owner_id in ? + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) UNION ALL SELECT NULL as remote_id, @@ -41,7 +62,9 @@ mergedAsset: SELECT * FROM lae.checksum, NULL as owner_id, NULL as live_photo_video_id, - lae.orientation + lae.orientation, + NULL as stack_id, + 0 AS stack_count FROM local_asset_entity lae LEFT JOIN @@ -68,8 +91,16 @@ FROM remote_asset_entity rae LEFT JOIN local_asset_entity lae ON rae.checksum = lae.checksum + LEFT JOIN + stack_entity se ON rae.stack_id = se.id WHERE - rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ? + rae.deleted_at IS NULL + AND rae.visibility = 0 + AND rae.owner_id in ? + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) UNION ALL SELECT lae.name, diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index 4ee0643706217442f40cef55037cfd4599f12667..ac3db868e1ee858da3b4cd14d5b66521b5e98498 100644 GIT binary patch delta 866 zcmbVLPixdb6elgUf3%CXwFm`yNMW-QhAp%O?W&~Ps4F4SO}3@IglRLo9gJB?o=Q)i zy%b7LdJ&`_pb$Tfo;>;)3QlH{u1XJLPLub3Z+?GXa%_BDJv-G-eyra~ksiDxEJlt; zBusc8Nk|i{&wzMn3Vbd9qD_x)8jw&%Bs$=s0d3c`Ew63qs&p`X6C=SP(kPH;1DXvm zJs7pK@3U-RTaSHs=60O2p@=1rf$RK10uLZ%goiL1(ml(z+I~5#bo3lwzbm;$^nz>Y z{mxUXYeC2&hAa#yLdOHAXWL*pTkw{re4j_cwV-LrQe{r!Twh_Iblu)_*xbFK{L2+y zrn<;9sHseT_O5d*&{)#3?0ZmheBJxH)n3&a;jnYDu zY>|PGU%g7s9It%PCM0qGbMkzA5|sMv^VIEDrKjF7nkx5k8IzDAb4B56y0yguEJ7z`Me zUKek?Sh@sJ8Ok^2&a`Hq$20;D6CMv9sliUTA#za-5r8VnWJEnPr{?qXd8%Clz(by@ SA0mT=#Tl7}#ASOozy1O~pC5Gq delta 84 zcmbPf(xHq)$ diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 96193e041..0b2896538 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -34,6 +34,8 @@ class RemoteAssetEntity extends Table IntColumn get visibility => intEnum()(); + TextColumn get stackId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -55,5 +57,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { visibility: visibility, livePhotoVideoId: livePhotoVideoId, localId: null, + stackId: stackId, ); } diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index 2bb7cffe59aa6eeaa8ccd4e4132f80c3bf071fb7..543ed659857d6e99404966b7cc4b7fa9eac4392c 100644 GIT binary patch delta 1234 zcmZ`%-Aj{k6lQzpzSX%eo6hx{NpovX#cp(LuQCu;N=k^7$Tr)w>Dbb3A=xl5#8CUN z<3$%HiBX22(l4V(OQjEj41>DL3%dxq2>*7E`y_g}w+dhR9*1FaFR12*2=yZg%(HHTU31fnm!0@%JNWly+ zwCl~tmHdZ)^xhGP4$A7HbaJe_Un4mvH-c+gi*F2=c&bi+&=WxCBYpm07tONo%@xAG zSo}6_3i0OPBTl@YpMy~3vOjDg3UKcb#fPzB72^UbhVw~COva@(IpvdEs5Aj^`?*}R7@DoBT-wg28Q;VJ{Ov=qxGciuFTZh%(?Vp3X*$DA@&Yxlc>+n= bbO^2-c0%i`3S6{dIwDdSnmG*v?`Qr4hg7;@ delta 278 zcmX>;mw8SP^M)xbn>VsVF>TghU(UZdM*J4<zChzdz-R$eMg?Y1+TLjbQ9UkFwoAol{88^?%6z19NTUO1v*|EWaWwQPT z@yQdWvrOL8%dvTH%MwQBT21T8`iCnve`xDqo$Oo2zWI4iBJ1Y1N!wU97fokmW~{T(!)%XCoU7)oVg;5ar3#A@yv{x zlM|X`CjULfxw&p_AJb<24gZ-oM{VB6wAp1_H1p=eJ1dwsXYVay+Wdb%BQwaX&31=b X*ujoic1oEUWZ2})6>^i+&MgE0OSW@| diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 15d445d226448a8ccc378e6844d9ef1f404f94bf..3b826c209b30d54acf6d5c279294f1cbf45d7a49 100644 GIT binary patch delta 656 zcmY*WO=uHA6lP|3o1NXXO=#JqBq`f=v&kl5Nt!gxLX8ToMWnS-v>-|;(t}8n8hfaM zc*xC@`g!P~XAcUdvU(7NLa$yTh<12RT0J5@1ikOx&O3^mv zmg=k3<+|5|1GyWIWD^t2^C|`vjg;=O3C~3FLe3~MaKm9MZ6M?xfvSFAvDo(uDV z5FRQ$_?Zmg3-e=9(eU|C&YS6|jkXo(lrkv=?-lhg#H*;N3~@Ea^0qOvT&dNsb5U`u zsCiOARSom&6u001j7ZnKXSvV28^k@+yGZe4cn+=5w!0Z@1l*~S^At1bahy!+Xd4Qe z>7FiKzdU9e_~;AcAnr%jV0alH5dL8{--vCN(Kbco%)!p!y4gp@@!d4gGWAY0DU>)1 zVG-0gOUvvOs$5(zT$r8$z=R+J1;$1R`4XYQk zR+MX!@YwL-PEy2eOU0?UN^?0Jvo4TP>{-@-4c_*4tS(;pWSt{64ydO|(w-Bj!lrXiQ^r#F>f8e7lS delta 632 zcmY+AOK1~O6o$EXCh0u#PMf4OsgurRCX=)wNt0+46rt5(X;rKid=-&`(n|3GQt`2H zt=9g_tX#RNBrv$}QL0@Cx)4!Z33czHb>qUFXQm=`bI*Ui^B?XxXKi|Iy3=Nmxs6Y( zi8-#L%9}XKy6}-}c*sXEB@SSd*aJbsl+Z*+4B#_Q&Qup+o5 zYskpk+U?teE%JRKpR0!ON|s^S5j2!EHvj5*C5em@!aL$mlmT8Y;ia9%ypn@oEwc)K zlbKAcipOdkch#7u3Tzm!)w=hl+&aE@>2jr3GEUsMaB&V-r5>j{@SHgt!RrhwAqDS) zs&hO%!=3fm{m61`9i8YgXH-AUao*HXHe?($dui!2=9o|~8F(wjan1-(#fJ}OY6nXo zWW|LBd3MPOY~`aExBC9&_pCv-8!Ofry}zuJwE9xsz>=-vR=&r}MsOsr&>_-9BT(%q z9(nnGe9edH5H7cnv0XihS&Du&?9Jl(Ckqj}%1q&uQY{(A#O#%YYm3O5X?!cB{zpmt yG!*m~7{))DF*B2yeOMyNfak-ab1`J`1L|~3)HyW~KbS?uWW?Z>Y1F5beR@uwoc}rh z^E>CQx4D&fxFr<_Pl}EzIX9u5DKDpDkvHL9eg+EkUaC|r6H*MB7jTPxU&@lxN^B!> z**AbA>IS-2KnUK@sCV)!Xlc5IBc*6jTfl22Tvo4QHBUggZWsPBckpGlAvqQw48(9* z*MJ)B8gb%hr4c`7?%FBQiKF^9Jk0gci;<}K5s97LoIH!@Zk)vVRT4dzb z>~ci$;Xn#g++x52Xa4w*=oWZ1M)>)I*P1Mz<7DP-4V&mAdHe*FSglgL;#?wr! zu>c#hT5D}es5plM!&U*G+nTYi$>QzBPlVkHBjySuRONq~G~(PbBGKH!s2&dv30R1z z70LAsM=d*%T>BK3rkJfd4P7ig!{c_d0q#&Hv%hxB!G!3 zEglc{G7kz26{XV6#v=p`2kp2WSK@SY1n;+r5WOn;OiMmtE{WHH;|H%}z5OqUZ86ZU zdpP=HD}L&hpltshtKk_M>e$EQO_C~Pe5fSZ6$KBq3jP@tu zAsB<=wr`fp6nJ zlpJ$0C<{2-t%7B=1n0)iGH6~rh!kQ^Z67{(-Omq5>@wQD`PB@OUB&i*H5;?bb1z{jxgp=#TyN-#YuCMFc))iPc zI}^r+tYVx`M2#_KMiYr25Kdy0h6#jy(5S%=M9`=skwDO(Uo?;<;%z&kU+%dl|L6Rl z=lsq&7v5%GeUtg5n4v0W8-b9K)4rnqr|@Hii`EpMC&-GVXb?+a5GnDsxP&is&=skM zDQz|!8S^E&eMyhklk~tN=|s5n3Lc52_)RFmS!oFxL=x1O<<_{wErt$&d2GM#jC@(>lkz!QdjW^a@ z!o`Y|B;kw3lfIqfAwTNX4y;vN!2Zg7TUazo+*jC;5mzCuRAQ5|9w(Il#N$ncW-67QhkHLIcJ-2Zzy zc_ffm_d;EB5eq6M9`eZQRZ@JW@`#PBAtwwxKX2cUCV>w0Wa$uc~C~< z6V+isB__01$P9})Pnh)iM8(D?!&uTY6vm?7PCt?m0*AhbvbAwJ25m}Q-z=kz&9h?a zYGVbsxs}5gHa})s|d2tRk^c zZQ!S3yym`z3Eu%^`#A{xJ}h`SeBk%v8=o0F0znM>jj(Q%;$9#Lalni-!3fT`6oOhp zvAm~#P=w`B5KO4B+o|CI&W6m;ha<2Lv&hx2hQqis%)uWCAQLX=ilQF0n>oB2^)f9c z%)3pn#C#Zvn(+faGBFP0@d$6hK|2!QFEmNFB0DmKU*jD5M&0=K;D2Z?H*{;-a}oP=w(5dYazUxA&JA#*b4&_=*- W5FhXSoqsqvX5V!z(MP8~XZ{6H*RhHK diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 1f6f1b089..28c229b46 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,11 +1,13 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' hide ExifInfo; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -30,25 +32,66 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } Stream watchAsset(String id) { - final query = _db.remoteAssetEntity - .select() - .addColumns([_db.localAssetEntity.id]).join([ + final stackCountRef = _db.stackEntity.id.count(); + + final query = _db.remoteAssetEntity.select().addColumns([ + _db.localAssetEntity.id, + _db.stackEntity.primaryAssetId, + stackCountRef, + ]).join([ leftOuterJoin( _db.localAssetEntity, _db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum), useColumns: false, ), + leftOuterJoin( + _db.stackEntity, + _db.stackEntity.primaryAssetId.equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAssetEntity.createAlias('stacked_assets'), + _db.stackEntity.id.equalsExp( + _db.remoteAssetEntity.createAlias('stacked_assets').stackId, + ), + useColumns: false, + ), ]) - ..where(_db.remoteAssetEntity.id.equals(id)); + ..where(_db.remoteAssetEntity.id.equals(id)) + ..groupBy([ + _db.remoteAssetEntity.id, + _db.localAssetEntity.id, + _db.stackEntity.primaryAssetId, + ]); return query.map((row) { final asset = row.readTable(_db.remoteAssetEntity).toDto(); + final primaryAssetId = row.read(_db.stackEntity.primaryAssetId); + final stackCount = + primaryAssetId == id ? (row.read(stackCountRef) ?? 0) : 0; + return asset.copyWith( localId: row.read(_db.localAssetEntity.id), + stackCount: stackCount, ); }).watchSingleOrNull(); } + Future> getStackChildren(RemoteAsset asset) { + if (asset.stackId == null) { + return Future.value([]); + } + + final query = _db.remoteAssetEntity.select() + ..where( + (row) => + row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not(), + ) + ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]); + + return query.map((row) => row.toDto()).get(); + } + Future getExif(String id) { return _db.managers.remoteExifEntity .filter((row) => row.assetId.id.equals(id)) @@ -146,4 +189,53 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } }); } + + Future stack(String userId, StackResponse stack) { + return _db.transaction(() async { + final stackIds = await _db.managers.stackEntity + .filter((row) => row.primaryAssetId.id.isIn(stack.assetIds)) + .map((row) => row.id) + .get(); + + await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds)); + + await _db.batch((batch) { + final companion = StackEntityCompanion( + ownerId: Value(userId), + primaryAssetId: Value(stack.primaryAssetId), + ); + + batch.insert( + _db.stackEntity, + companion.copyWith(id: Value(stack.id)), + onConflict: DoUpdate((_) => companion), + ); + + for (final assetId in stack.assetIds) { + batch.update( + _db.remoteAssetEntity, + RemoteAssetEntityCompanion( + stackId: Value(stack.id), + ), + where: (e) => e.id.equals(assetId), + ); + } + }); + }); + } + + Future unStack(List stackIds) { + return _db.transaction(() async { + await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds)); + + // TODO: delete this after adding foreign key on stackId + await _db.batch((batch) { + batch.update( + _db.remoteAssetEntity, + const RemoteAssetEntityCompanion(stackId: Value(null)), + where: (e) => e.stackId.isIn(stackIds), + ); + }); + }); + } } diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index f3f26bb01..d28c68ed7 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -137,6 +137,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { deletedAt: Value(asset.deletedAt), visibility: Value(asset.visibility.toAssetVisibility()), livePhotoVideoId: Value(asset.livePhotoVideoId), + stackId: Value(asset.stackId), ); batch.insert( diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index a6d89b5e8..0c3eee59a 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -89,6 +89,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository { isFavorite: row.isFavorite, durationInSeconds: row.durationInSeconds, livePhotoVideoId: row.livePhotoVideoId, + stackId: row.stackId, + stackCount: row.stackCount, ) : LocalAsset( id: row.localId!, diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 7216b638e..0582399ea 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -13,7 +13,7 @@ class MainTimelinePage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); - return memoryLaneProvider.when( + return memoryLaneProvider.maybeWhen( data: (memories) { return memories.isEmpty ? const Timeline(showStorageIndicator: true) @@ -26,8 +26,7 @@ class MainTimelinePage extends ConsumerWidget { showStorageIndicator: true, ); }, - loading: () => const Timeline(showStorageIndicator: true), - error: (error, stackTrace) => const Timeline(showStorageIndicator: true), + orElse: () => const Timeline(showStorageIndicator: true), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart index dc42eb96f..13782c009 100644 --- a/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart @@ -1,16 +1,56 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class StackActionButton extends ConsumerWidget { - const StackActionButton({super.key}); + final ActionSource source; + + const StackActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final user = ref.watch(currentUserProvider); + if (user == null) { + throw Exception('User must be logged in to access stack action'); + } + + final result = + await ref.read(actionProvider.notifier).stack(user.id, source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'stack_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success + ? successMessage + : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { return BaseActionButton( iconData: Icons.filter_none_rounded, label: "stack".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart new file mode 100644 index 000000000..c2757043a --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class UnStackActionButton extends ConsumerWidget { + final ActionSource source; + + const UnStackActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).unStack(source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'unstack_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success + ? successMessage + : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.filter_none_rounded, + label: "unstack".t(context: context), + onPressed: () => _onTap(context, ref), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart new file mode 100644 index 000000000..cb4e02b56 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart @@ -0,0 +1,24 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; + +class StackChildrenNotifier + extends AutoDisposeFamilyAsyncNotifier, BaseAsset?> { + @override + Future> build(BaseAsset? asset) async { + if (asset == null || + asset is! RemoteAsset || + asset.stackId == null || + // The stackCount check is to ensure we only fetch stacks for timelines that have stacks + asset.stackCount == 0) { + return const []; + } + + return ref.watch(assetServiceProvider).getStack(asset); + } +} + +final stackChildrenNotifier = AsyncNotifierProvider.autoDispose + .family, BaseAsset?>( + StackChildrenNotifier.new, +); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart new file mode 100644 index 000000000..8b3d0c657 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; + +class AssetStackRow extends ConsumerWidget { + const AssetStackRow({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + int opacity = ref.watch( + assetViewerProvider.select((state) => state.backgroundOpacity), + ); + final showControls = + ref.watch(assetViewerProvider.select((s) => s.showingControls)); + + if (!showControls) { + opacity = 0; + } + + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); + + return IgnorePointer( + ignoring: opacity < 255, + child: AnimatedOpacity( + opacity: opacity / 255, + duration: Durations.short2, + child: ref.watch(stackChildrenNotifier(asset)).when( + data: (state) => SizedBox.square( + dimension: 80, + child: _StackList(stack: state), + ), + error: (_, __) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ), + ), + ); + } +} + +class _StackList extends ConsumerWidget { + final List stack; + + const _StackList({required this.stack}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only( + left: 5, + right: 5, + bottom: 30, + ), + itemCount: stack.length, + itemBuilder: (ctx, index) { + final asset = stack[index]; + return Padding( + padding: const EdgeInsets.only(right: 5), + child: GestureDetector( + onTap: () { + ref.read(assetViewerProvider.notifier).setStackIndex(index); + ref.read(currentAssetNotifier.notifier).setAsset(asset); + }, + child: Container( + height: 60, + width: 60, + decoration: index == + ref.watch(assetViewerProvider.select((s) => s.stackIndex)) + ? const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ) + : const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: null, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Stack( + fit: StackFit.expand, + children: [ + Image( + fit: BoxFit.cover, + image: getThumbnailImageProvider( + remoteId: asset.id, + size: const Size.square(60), + ), + ), + if (asset.isVideo) + const Icon( + Icons.play_circle_outline_rounded, + color: Colors.white, + size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Color.fromRGBO(0, 0, 0, 0.6), + offset: Offset(0.0, 0.0), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 1c0f28413..50f4a0919 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -5,10 +5,13 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; @@ -85,6 +88,7 @@ class _AssetViewerState extends ConsumerState { double previousExtent = _kBottomSheetMinimumExtent; Offset dragDownPosition = Offset.zero; int totalAssets = 0; + int stackIndex = 0; BuildContext? scaffoldContext; Map videoPlayerKeys = {}; @@ -167,6 +171,10 @@ class _AssetViewerState extends ConsumerState { void _onAssetChanged(int index) { final asset = ref.read(timelineServiceProvider).getAsset(index); + // Always holds the current asset from the timeline + ref.read(assetViewerProvider.notifier).setAsset(asset); + // The currentAssetNotifier actually holds the current asset that is displayed + // which could be stack children as well ref.read(currentAssetNotifier.notifier).setAsset(asset); if (asset.isVideo || asset.isMotionPhoto) { ref.read(videoPlaybackValueProvider.notifier).reset(); @@ -488,7 +496,12 @@ class _AssetViewerState extends ConsumerState { ImageChunkEvent? progress, int index, ) { - final asset = ref.read(timelineServiceProvider).getAsset(index); + BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index); + final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + asset = stackChildren + .elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); + } return Container( width: double.infinity, height: double.infinity, @@ -516,9 +529,14 @@ class _AssetViewerState extends ConsumerState { PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { scaffoldContext ??= ctx; - final asset = ref.read(timelineServiceProvider).getAsset(index); - final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); + BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index); + final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + asset = stackChildren + .elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); + } + final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); if (asset.isImage && !isPlayingMotionVideo) { return _imageBuilder(ctx, asset); } @@ -604,6 +622,7 @@ class _AssetViewerState extends ConsumerState { // Using multiple selectors to avoid unnecessary rebuilds for other state changes ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); + ref.watch(assetViewerProvider.select((s) => s.stackIndex)); ref.watch(isPlayingMotionVideoProvider); // Listen for casting changes and send initial asset to the cast provider @@ -645,7 +664,17 @@ class _AssetViewerState extends ConsumerState { backgroundDecoration: BoxDecoration(color: backgroundColor), enablePanAlways: true, ), - bottomNavigationBar: const ViewerBottomBar(), + bottomNavigationBar: showingBottomSheet + ? const SizedBox.shrink() + : const Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AssetStackRow(), + ViewerBottomBar(), + ], + ), ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index 020d1d9b2..825b637e8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -1,26 +1,40 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +class ViewerOpenBottomSheetEvent extends Event { + const ViewerOpenBottomSheetEvent(); +} + class AssetViewerState { final int backgroundOpacity; final bool showingBottomSheet; final bool showingControls; + final BaseAsset? currentAsset; + final int stackIndex; const AssetViewerState({ this.backgroundOpacity = 255, this.showingBottomSheet = false, this.showingControls = true, + this.currentAsset, + this.stackIndex = 0, }); AssetViewerState copyWith({ int? backgroundOpacity, bool? showingBottomSheet, bool? showingControls, + BaseAsset? currentAsset, + int? stackIndex, }) { return AssetViewerState( backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, showingControls: showingControls ?? this.showingControls, + currentAsset: currentAsset ?? this.currentAsset, + stackIndex: stackIndex ?? this.stackIndex, ); } @@ -36,14 +50,18 @@ class AssetViewerState { return other is AssetViewerState && other.backgroundOpacity == backgroundOpacity && other.showingBottomSheet == showingBottomSheet && - other.showingControls == showingControls; + other.showingControls == showingControls && + other.currentAsset == currentAsset && + other.stackIndex == stackIndex; } @override int get hashCode => backgroundOpacity.hashCode ^ showingBottomSheet.hashCode ^ - showingControls.hashCode; + showingControls.hashCode ^ + currentAsset.hashCode ^ + stackIndex.hashCode; } class AssetViewerStateNotifier extends AutoDisposeNotifier { @@ -52,6 +70,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier { return const AssetViewerState(); } + void setAsset(BaseAsset? asset) { + state = state.copyWith(currentAsset: asset, stackIndex: 0); + } + void setOpacity(int opacity) { state = state.copyWith( backgroundOpacity: opacity, @@ -76,6 +98,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier { void toggleControls() { state = state.copyWith(showingControls: !state.showingControls); } + + void setStackIndex(int index) { + state = state.copyWith(stackIndex: index); + } } final assetViewerProvider = diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 7b1175e8b..a3d24ec8e 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -49,7 +49,7 @@ class ArchiveBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 0615a857a..eefe19194 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -49,7 +49,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 61414252d..f9a9dd320 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -49,7 +49,7 @@ class GeneralBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index c2d0d5c85..b5fecdf7a 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -52,7 +52,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 7e3776adb..ce3d39629 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -53,6 +53,9 @@ class ThumbnailTile extends ConsumerWidget { ) : const BoxDecoration(); + final hasStack = + asset is RemoteAsset && (asset as RemoteAsset).stackCount > 0; + return Stack( children: [ AnimatedContainer( @@ -75,6 +78,19 @@ class ThumbnailTile extends ConsumerWidget { ), ), ), + if (hasStack) + Align( + alignment: Alignment.topRight, + child: Padding( + padding: EdgeInsets.only( + right: 10.0, + top: asset.isVideo ? 24.0 : 6.0, + ), + child: _StackIndicator( + stackCount: (asset as RemoteAsset).stackCount, + ), + ), + ), if (asset.isVideo) Align( alignment: Alignment.topRight, @@ -182,6 +198,40 @@ class _SelectionIndicator extends StatelessWidget { } } +class _StackIndicator extends StatelessWidget { + final int stackCount; + + const _StackIndicator({required this.stackCount}); + + @override + Widget build(BuildContext context) { + return Row( + spacing: 3, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + // CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + stackCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Color.fromRGBO(0, 0, 0, 0.6), + ), + ], + ), + ), + const _TileOverlayIcon(Icons.burst_mode_rounded), + ], + ); + } +} + class _VideoIndicator extends StatelessWidget { final Duration duration; const _VideoIndicator(this.duration); @@ -192,8 +242,8 @@ class _VideoIndicator extends StatelessWidget { spacing: 3, mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, - // CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center - crossAxisAlignment: CrossAxisAlignment.end, + // CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( duration.format(), diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 91003fb1a..6faa4da9f 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -15,6 +15,7 @@ class TimelineArgs { final double spacing; final int columnCount; final bool showStorageIndicator; + final bool withStack; final GroupAssetsBy? groupBy; const TimelineArgs({ @@ -23,6 +24,7 @@ class TimelineArgs { this.spacing = kTimelineSpacing, this.columnCount = kTimelineColumnCount, this.showStorageIndicator = false, + this.withStack = false, this.groupBy, }); @@ -33,6 +35,7 @@ class TimelineArgs { maxHeight == other.maxHeight && columnCount == other.columnCount && showStorageIndicator == other.showStorageIndicator && + withStack == other.withStack && groupBy == other.groupBy; } @@ -43,6 +46,7 @@ class TimelineArgs { spacing.hashCode ^ columnCount.hashCode ^ showStorageIndicator.hashCode ^ + withStack.hashCode ^ groupBy.hashCode; } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index d27993741..873908832 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -28,6 +28,7 @@ class Timeline extends StatelessWidget { this.topSliverWidget, this.topSliverWidgetHeight, this.showStorageIndicator = false, + this.withStack = false, this.appBar = const ImmichSliverAppBar( floating: true, pinned: false, @@ -42,6 +43,7 @@ class Timeline extends StatelessWidget { final bool showStorageIndicator; final Widget? appBar; final Widget? bottomSheet; + final bool withStack; final GroupAssetsBy? groupBy; @override @@ -58,6 +60,7 @@ class Timeline extends StatelessWidget { settingsProvider.select((s) => s.get(Setting.tilesPerRow)), ), showStorageIndicator: showStorageIndicator, + withStack: withStack, groupBy: groupBy, ), ), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index b53417d02..456b072a3 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -50,7 +50,7 @@ class ActionNotifier extends Notifier { return _getIdsForSource(source).toIds().toList(growable: false); } - List _getOwnedRemoteForSource(ActionSource source) { + List _getOwnedRemoteIdsForSource(ActionSource source) { final ownerId = ref.read(currentUserProvider)?.id; return _getIdsForSource(source) .ownedAssets(ownerId) @@ -58,6 +58,20 @@ class ActionNotifier extends Notifier { .toList(growable: false); } + List _getOwnedRemoteAssetsForSource(ActionSource source) { + final ownerId = ref.read(currentUserProvider)?.id; + return _getIdsForSource(source).ownedAssets(ownerId).toList(); + } + + Iterable _getIdsForSource(ActionSource source) { + final Set assets = _getAssets(source); + return switch (T) { + const (RemoteAsset) => assets.whereType(), + const (LocalAsset) => assets.whereType(), + _ => const [], + } as Iterable; + } + Set _getAssets(ActionSource source) { return switch (source) { ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, @@ -68,15 +82,6 @@ class ActionNotifier extends Notifier { }; } - Iterable _getIdsForSource(ActionSource source) { - final Set assets = _getAssets(source); - return switch (T) { - const (RemoteAsset) => assets.whereType(), - const (LocalAsset) => assets.whereType(), - _ => const [], - } as Iterable; - } - Future shareLink( ActionSource source, BuildContext context, @@ -96,7 +101,7 @@ class ActionNotifier extends Notifier { } Future favorite(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.favorite(ids); return ActionResult(count: ids.length, success: true); @@ -111,7 +116,7 @@ class ActionNotifier extends Notifier { } Future unFavorite(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.unFavorite(ids); return ActionResult(count: ids.length, success: true); @@ -126,7 +131,7 @@ class ActionNotifier extends Notifier { } Future archive(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.archive(ids); return ActionResult(count: ids.length, success: true); @@ -141,7 +146,7 @@ class ActionNotifier extends Notifier { } Future unArchive(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.unArchive(ids); return ActionResult(count: ids.length, success: true); @@ -156,7 +161,7 @@ class ActionNotifier extends Notifier { } Future moveToLockFolder(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.moveToLockFolder(ids); return ActionResult(count: ids.length, success: true); @@ -171,7 +176,7 @@ class ActionNotifier extends Notifier { } Future removeFromLockFolder(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.removeFromLockFolder(ids); return ActionResult(count: ids.length, success: true); @@ -186,7 +191,7 @@ class ActionNotifier extends Notifier { } Future trash(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.trash(ids); return ActionResult(count: ids.length, success: true); @@ -201,7 +206,7 @@ class ActionNotifier extends Notifier { } Future delete(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.delete(ids); return ActionResult(count: ids.length, success: true); @@ -234,7 +239,7 @@ class ActionNotifier extends Notifier { ActionSource source, BuildContext context, ) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { final isEdited = await _service.editLocation(ids, context); if (!isEdited) { @@ -270,6 +275,35 @@ class ActionNotifier extends Notifier { } } + Future stack(String userId, ActionSource source) async { + final ids = _getOwnedRemoteIdsForSource(source); + try { + await _service.stack(userId, ids); + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to stack assets', error, stack); + return ActionResult( + count: ids.length, + success: false, + error: error.toString(), + ); + } + } + + Future unStack(ActionSource source) async { + final assets = _getOwnedRemoteAssetsForSource(source); + try { + await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList()); + return ActionResult(count: assets.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to unstack assets', error, stack); + return ActionResult( + count: assets.length, + success: false, + ); + } + } + Future shareAssets(ActionSource source) async { final ids = _getAssets(source).toList(growable: false); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index cd49369a2..4c854973b 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; @@ -11,14 +12,16 @@ final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( ref.watch(apiServiceProvider).assetsApi, ref.watch(apiServiceProvider).searchApi, + ref.watch(apiServiceProvider).stacksApi, ), ); class AssetApiRepository extends ApiRepository { final AssetsApi _api; final SearchApi _searchApi; + final StacksApi _stacksApi; - AssetApiRepository(this._api, this._searchApi); + AssetApiRepository(this._api, this._searchApi, this._stacksApi); Future update(String id, {String? description}) async { final response = await checkNull( @@ -84,6 +87,17 @@ class AssetApiRepository extends ApiRepository { ); } + Future stack(List ids) async { + final responseDto = + await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids))); + + return responseDto.toStack(); + } + + Future unStack(List ids) async { + return _stacksApi.deleteStacks(BulkIdsDto(ids: ids)); + } + Future downloadAsset(String id) { return _api.downloadAssetWithHttpInfo(id); } @@ -102,3 +116,13 @@ class AssetApiRepository extends ApiRepository { return response.originalMimeType; } } + +extension on StackResponseDto { + StackResponse toStack() { + return StackResponse( + id: id, + primaryAssetId: primaryAssetId, + assetIds: assets.map((asset) => asset.id).toList(), + ); + } +} diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 9559c5d31..adefd5da1 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -166,6 +166,16 @@ class ActionService { return removedCount; } + Future stack(String userId, List remoteIds) async { + final stack = await _assetApiRepository.stack(remoteIds); + await _remoteAssetRepository.stack(userId, stack); + } + + Future unStack(List stackIds) async { + await _remoteAssetRepository.unStack(stackIds); + await _assetApiRepository.unStack(stackIds); + } + Future shareAssets(List assets) { return _assetMediaRepository.shareAssets(assets); } diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index faaccfa51..eace57fe5 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 2d35a6702..41eed09d8 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index 06935cd4b..1e4b061be 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -63,8 +63,14 @@ class MapThumbnail extends HookConsumerWidget { } Future onStyleLoaded() async { - if (showMarkerPin && controller.value != null) { - await controller.value?.addMarkerAtLatLng(centre); + try { + if (showMarkerPin && controller.value != null) { + await controller.value?.addMarkerAtLatLng(centre); + } + } finally { + // Calling methods on the controller after it is disposed will throw an error + // We do not have a way to check if the controller is disposed for now + // https://github.com/maplibre/flutter-maplibre-gl/issues/192 } styleLoaded.value = true; } diff --git a/mobile/test/drift/main/generated/schema_v1.dart b/mobile/test/drift/main/generated/schema_v1.dart index 0172201921c61006a55eef6b7a2933c3fa980372..38dbb9f827ad478c791060a52cf1a5aa47e3b258 100644 GIT binary patch delta 2129 zcmbtVT})eL7|!>tU1^83h0>1l)2|K!M1;;njb+dxQB%fPac0?sP+Hruv(=Wup&L z$YO3W5cwk}^o6M@gWlmjfAE^lScua{_kM{wV6>D}sKEx9Fti=9(dC244Qmh8!q+_( z9U)>yxcYlQCt&5B=xhn!k8u&{xQ(83*msB>V`3v7qAC<_=RqA~V!dj|Zb~hhFrrVm=A(FrDkF3a)K+*pn^f%ZeGPg_ z2x*i?#XA{zCxz6bgJYPQSY%Wsqlry^jIIf^6TTTT(zPV2mNm%#QAN+3M&;b)7V)Iw7$>;^SAB5J#V9zr%`pOpfES77t2y_3`bz~#BXhd5x%gp z5xM@pB$eT!wnqGPoSAlM_41R60D$B{4CpU0cA!WWWadwc{L^Rq=M4I#80sO^m zRIdzwiZ-9>yoV6x7*Q5AFNH|56esg7&2*|6f`fLEb|XPfaI}A#a2X@=J2j`?^Vnqx;%%k{|D^J4<7&k delta 587 zcmY+CTWHd89L4#4z5MmBsdJ$#%C$zYgwkz9m&In6!72?KL`EA;DbSco%{g{4$A%;f zD*ZOT^yD69i&w&{L=fk;H5LX|GIlB!{ z%Vk)xFHVu2(1_IvCHAH6p!I-54EuU~9$&XF)Q{GQ6g5Yfz+DH^0$#HB;isPldcF$; zo^Xr;S$8uj?s00k+nHokaDxU-?IIp~H$dtVGxIm?3liLWqLIF>vdwt1RfF1Wf>Olf zXpgIsE-BjXDKOY7r!_xVJ^$!xl3=K#8Lu1eVz1G{Uv$m`E_Usp{8p&=?Wbxt6Upg^ zwX}|bj^CWoGyLpP@cSMq)A|CTmQ8=_^CM@vBv|LIr??r^f9y)inL*^UUM|J&{!Ut% zVTbu*U>NYmc_Y4UFp%>&GhwR6f`=}=|Az*5_kaW^!W;RAuoAh_+Z6B zuZ42nQb6Rv(~q{5@@*xyKEt2B?5@pwCSf}x>l7&S^Jx%dRGb7A kT}^`}zc393mY3avbBvy4K%`;@ROLfdVYWs=S8hY>7d4*ewg3PC diff --git a/mobile/test/drift/main/generated/schema_v2.dart b/mobile/test/drift/main/generated/schema_v2.dart index bdba8db557b707f97d09451f0e16895ce0b4516a..8345cef9061b8970a3fe164069c25e31f5585cbe 100644 GIT binary patch delta 960 zcmZ`%T}YE*6y_W^_oI3N5vp$XsrbZ0KlngjzNzb z>%bnNnan}qX&6VYZq%z{U!6B&wj!#Qe`zqp(WMM9io+K_C}ILebeK67pQ%uBb_7-i-%$<{3%4&?AZ&VAI<1$r?(Qv~G62DRvKX#%p|hFqC3 z+=vD`(hKQg%l?dM57wa=ZggSdIf%?WzRszkjQ^EMzg5yWfZy1HCGJ)jk7{+GyG{HooM@+fGA zWU9}PRKIew8&g>#T_nXke)auX3U7i|jCH?7{?(WzZ_hr$D$)IM6KH-bqB(fS1V+Z% zuCZ8WtkBHbJKgRwxzp&a$@!B`jX7c?pvRFy$kbSB{mG!U2=x?3IJXl%-*%;l>48uGDZnzSh!iwGSX>*@ y_Mp4CZ!H{J3q$#fkbfLaK<_57g6e~q5_%TIG-c@f1ZIKsQRwF+b^-NG;ly8UhE)v! delta 447 zcmX|;Pe_vi7{+8sCM~MdNSQNsb zgKcl};_2(4DX!MymA;b@M2t!(j1GlldFh|gE+rG8)}f9M{GJ~VJhzv%*(I$wd5BZ( z*hWpNhpt)=D178Hh3{B=AO(uVJMXIW^|#J1J5Z5q%tkSd641%jVXmn_P(GX*ck-_d z*h#0OKJpi`Y%#d0JLAp8ofLVbQZnl1Et{}kzQmt7Xi|SjgY_YKb=GjOy$5Um_PEIe zjg3C6;~juoWp+LQ^gW@