Compare commits

...

13 Commits

Author SHA1 Message Date
github-actions
3d515f5072 chore: version v1.138.1 2025-08-18 15:23:35 +00:00
Alex
ec01db5c8b refactor: bottom sheet action button (#20964)
* fix: incorrect archive action shown in asset viewer'

* Refactor

* use enums syntax and add tests
2025-08-18 10:20:08 -05:00
bo0tzz
cd6d8fcdfe chore: elaborate dupe bot comment (#21025)
Hopefully this stops people opening new threads
2025-08-18 13:36:53 +00:00
Alex
1198311d64 fix: sync block login progress (#20939) 2025-08-14 19:08:04 -05:00
Alex
1a4eab9655 fix: locked photos shown in beta timeline favorite page (#20937) 2025-08-14 23:03:33 +00:00
Brandon Wees
1926c90780 feat(mobile): shared album activities (#20714)
* feat(mobile): shared album activities

* add like buttons and fix behavior of unliking

* fix: conditionally show activity button and fix title truncations

* fix(mobile): newest/oldest album sort (#20743)

* fix(mobile): newest/oldest album sort

* chore: use sqlite to determine album asset timestamps

* Fix missing future

Co-authored-by: Alex <alex.tran1502@gmail.com>

* fix: async handling of sort

* chore: tests

* chore: code review changes

* fix: use created at for newest asset

* fix: use localDateTime for sorting

* chore: cleanup

* chore: use final

* feat: loading indicator

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-08-14 22:50:56 +00:00
Alex
4d5975b717 fix: pinch in finished as zoomed in (#20936) 2025-08-14 17:39:14 -05:00
Alex
8cbd6b29c4 fix: sync remote before starting backup (#20906) 2025-08-14 17:19:08 -05:00
Alex
8c1b630a2b fix: backup resume more reliable on app start up (#20907) 2025-08-14 17:09:32 -05:00
Brandon Wees
c961d2aaf7 fix(mobile): don't show view in timeline button when opening cast dialog (#20934)
fix: don't show view in timeline button when opening cast dialog
2025-08-14 17:09:17 -05:00
Brandon Wees
41c75dc93e fix(mobile): always show cast button (#20935) 2025-08-14 17:09:01 -05:00
Daniel Dietzler
f92247c99b fix: oauth auto-login infinite loop (#20904) 2025-08-13 19:45:06 -04:00
renovate[bot]
53f9fc2d1c chore(deps): update docker.io/valkey/valkey:8-bookworm docker digest to 5b8f8c3 (#20874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 21:49:38 +02:00
47 changed files with 1349 additions and 99 deletions

View File

@@ -51,7 +51,7 @@ jobs:
run: |
gh api graphql \
-f issueId="$NODE_ID" \
-f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
-f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
-f query='
mutation CommentAndCloseIssue($issueId: ID!, $body: String!) {
addComment(input: {
@@ -77,7 +77,7 @@ jobs:
run: |
gh api graphql \
-f discussionId="$NODE_ID" \
-f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
-f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
-f query='
mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
addDiscussionComment(input: {

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.78",
"version": "2.2.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.78",
"version": "2.2.79",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"chokidar": "^4.0.3",
@@ -54,7 +54,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.138.0",
"version": "1.138.1",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.78",
"version": "2.2.79",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -117,7 +117,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -1,4 +1,8 @@
[
{
"label": "v1.138.1",
"url": "https://v1.138.1.archive.immich.app"
},
{
"label": "v1.138.0",
"url": "https://v1.138.0.archive.immich.app"

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.138.0",
"version": "1.138.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.138.0",
"version": "1.138.1",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -46,7 +46,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.78",
"version": "2.2.79",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -95,7 +95,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.138.0",
"version": "1.138.1",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.138.0",
"version": "1.138.1",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -1195,6 +1195,7 @@
"library_page_sort_title": "Album title",
"licenses": "Licenses",
"light": "Light",
"like": "Like",
"like_deleted": "Like deleted",
"link_motion_video": "Link motion video",
"link_to_oauth": "Link to OAuth",

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3003,
"android.injected.version.name" => "1.138.0",
"android.injected.version.code" => 3004,
"android.injected.version.name" => "1.138.1",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.138.0"
version_number: "1.138.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -23,6 +23,7 @@ class RemoteAlbum {
final AlbumAssetOrder order;
final int assetCount;
final String ownerName;
final bool isShared;
const RemoteAlbum({
required this.id,
@@ -36,6 +37,7 @@ class RemoteAlbum {
required this.order,
required this.assetCount,
required this.ownerName,
required this.isShared,
});
@override
@@ -52,6 +54,7 @@ class RemoteAlbum {
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
assetCount: $assetCount
ownerName: $ownerName
isShared: $isShared
}''';
}
@@ -69,7 +72,8 @@ class RemoteAlbum {
isActivityEnabled == other.isActivityEnabled &&
order == other.order &&
assetCount == other.assetCount &&
ownerName == other.ownerName;
ownerName == other.ownerName &&
isShared == other.isShared;
}
@override
@@ -84,7 +88,8 @@ class RemoteAlbum {
isActivityEnabled.hashCode ^
order.hashCode ^
assetCount.hashCode ^
ownerName.hashCode;
ownerName.hashCode ^
isShared.hashCode;
}
RemoteAlbum copyWith({
@@ -99,6 +104,7 @@ class RemoteAlbum {
AlbumAssetOrder? order,
int? assetCount,
String? ownerName,
bool? isShared,
}) {
return RemoteAlbum(
id: id ?? this.id,
@@ -112,6 +118,7 @@ class RemoteAlbum {
order: order ?? this.order,
assetCount: assetCount ?? this.assetCount,
ownerName: ownerName ?? this.ownerName,
isShared: isShared ?? this.isShared,
);
}
}

View File

@@ -31,11 +31,17 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
useColumns: false,
),
leftOuterJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), useColumns: false),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
]);
query
..where(_db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount])
..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..groupBy([_db.remoteAlbumEntity.id]);
if (sortBy.isNotEmpty) {
@@ -53,7 +59,11 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.map(
(row) => row
.readTable(_db.remoteAlbumEntity)
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
.toDto(
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
),
)
.get();
}
@@ -78,17 +88,27 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false,
),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
])
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount])
..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..groupBy([_db.remoteAlbumEntity.id]);
return query
.map(
(row) => row
.readTable(_db.remoteAlbumEntity)
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
.toDto(
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
),
)
.getSingleOrNull();
}
@@ -254,13 +274,24 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false,
),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
])
..where(_db.remoteAlbumEntity.id.equals(albumId))
..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..groupBy([_db.remoteAlbumEntity.id]);
return query.map((row) {
final album = row.readTable(_db.remoteAlbumEntity).toDto(ownerName: row.read(_db.userEntity.name)!);
final album = row
.readTable(_db.remoteAlbumEntity)
.toDto(
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
);
return album;
}).watchSingleOrNull();
}
@@ -293,7 +324,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}
extension on RemoteAlbumEntityData {
RemoteAlbum toDto({int assetCount = 0, required String ownerName}) {
RemoteAlbum toDto({int assetCount = 0, required String ownerName, required bool isShared}) {
return RemoteAlbum(
id: id,
name: name,
@@ -306,6 +337,7 @@ extension on RemoteAlbumEntityData {
order: order,
assetCount: assetCount,
ownerName: ownerName,
isShared: isShared,
);
}
}

View File

@@ -258,7 +258,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
);
TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) => row.deletedAt.isNull() & row.isFavorite.equals(true) & row.ownerId.equals(userId),
filter: (row) =>
row.deletedAt.isNull() &
row.isFavorite.equals(true) &
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
groupBy: groupBy,
);

View File

@@ -7,6 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -39,6 +40,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
return;
}
await ref.read(backgroundSyncProvider).syncRemote();
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
}

View File

@@ -0,0 +1,104 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
@RoutePage()
class DriftActivitiesPage extends HookConsumerWidget {
const DriftActivitiesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider)!;
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
final listViewScrollController = useScrollController();
void scrollToBottom() {
listViewScrollController.animateTo(
listViewScrollController.position.maxScrollExtent + 80,
duration: const Duration(milliseconds: 600),
curve: Curves.fastOutSlowIn,
);
}
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
scrollToBottom();
}
return Scaffold(
appBar: AppBar(
title: asset == null ? Text(album.name) : null,
actions: [const LikeActivityActionButton(menuItem: true)],
actionsPadding: const EdgeInsets.only(right: 8),
),
body: activities.widgetWhen(
onData: (data) {
final liked = data.firstWhereOrNull(
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
);
return SafeArea(
child: Stack(
children: [
ListView.builder(
controller: listViewScrollController,
itemCount: data.length + 1,
itemBuilder: (context, index) {
if (index == data.length) {
return const SizedBox(height: 80);
}
final activity = data[index];
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
return Padding(
padding: const EdgeInsets.all(5),
child: DismissibleActivity(
activity.id,
ActivityTile(activity),
onDismiss: canDelete
? (activityId) async => await activityNotifier.removeActivity(activity.id)
: null,
),
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)),
),
child: DriftActivityTextField(
isEnabled: album.isActivityEnabled,
likeId: liked?.id,
onSubmit: onAddComment,
),
),
),
],
),
);
},
),
resizeToAvoidBottomInset: true,
);
}
}

View File

@@ -165,6 +165,10 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
}
}
Future<void> showActivity(BuildContext context) async {
context.pushRoute(const DriftActivitiesRoute());
}
void showOptionSheet(BuildContext context) {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false;
@@ -241,6 +245,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditTitle: () => showEditTitleAndDescription(context),
onActivity: () => showActivity(context),
),
bottomSheet: RemoteAlbumBottomSheet(album: _album),
),

View File

@@ -0,0 +1,64 @@
import 'package:collection/collection.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class LikeActivityActionButton extends ConsumerWidget {
const LikeActivityActionButton({super.key, this.menuItem = false});
final bool menuItem;
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider);
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
onTap(Activity? liked) async {
if (user == null) {
return;
}
if (liked != null) {
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).removeActivity(liked.id);
} else {
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).addLike();
}
ref.invalidate(albumActivityProvider(album?.id ?? "", asset?.id));
}
return activities.when(
data: (data) {
final liked = data.firstWhereOrNull(
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
);
return BaseActionButton(
maxWidth: 60,
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
label: "like".t(context: context),
onPressed: () => onTap(liked),
menuItem: menuItem,
);
},
// default to empty heart during loading
loading: () => BaseActionButton(
iconData: Icons.favorite_border,
label: "like".t(context: context),
menuItem: menuItem,
),
error: (error, stack) => Text("Error: $error"),
);
}
}

View File

@@ -0,0 +1,109 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class DriftActivityTextField extends ConsumerStatefulWidget {
final bool isEnabled;
final String? likeId;
final Function(String) onSubmit;
final Function()? onKeyboardFocus;
const DriftActivityTextField({
required this.onSubmit,
this.isEnabled = true,
this.likeId,
this.onKeyboardFocus,
super.key,
});
@override
ConsumerState<DriftActivityTextField> createState() => _DriftActivityTextFieldState();
}
class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField> {
late FocusNode inputFocusNode;
late TextEditingController inputController;
bool sendEnabled = false;
@override
void initState() {
super.initState();
inputController = TextEditingController();
inputFocusNode = FocusNode();
inputFocusNode.requestFocus();
inputFocusNode.addListener(() {
if (inputFocusNode.hasFocus) {
widget.onKeyboardFocus?.call();
}
});
inputController.addListener(() {
setState(() {
sendEnabled = inputController.text.trim().isNotEmpty;
});
});
}
@override
void dispose() {
inputController.dispose();
inputFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
// Pass text to callback and reset controller
void onEditingComplete() {
if (inputController.text.trim().isEmpty) {
return;
}
widget.onSubmit(inputController.text);
inputController.clear();
inputFocusNode.unfocus();
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: TextField(
controller: inputController,
enabled: widget.isEnabled,
focusNode: inputFocusNode,
textInputAction: TextInputAction.send,
autofocus: false,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12), // Adjust as needed
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
prefixIcon: user != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: UserCircleAvatar(user: user, size: 30, radius: 15),
)
: null,
suffixIcon: IconButton(
onPressed: sendEnabled ? onEditingComplete : null,
icon: const Icon(Icons.send),
iconSize: 24,
color: context.primaryColor,
disabledColor: context.colorScheme.secondaryContainer,
),
hintText: !widget.isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(),
hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]),
),
onEditingComplete: onEditingComplete,
onTapOutside: (_) => inputFocusNode.unfocus(),
),
);
}
}

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@@ -31,6 +32,7 @@ class ViewerBottomBar extends ConsumerWidget {
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final isInLockedView = ref.watch(inLockedViewProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
if (!showControls) {
opacity = 0;
@@ -40,10 +42,15 @@ class ViewerBottomBar extends ConsumerWidget {
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer),
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
if (isOwner) ...[
if (asset.hasRemote && isOwner && isArchived)
const UnArchiveActionButton(source: ActionSource.viewer)
else
const ArchiveActionButton(source: ActionSource.viewer),
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
];
return IgnorePointer(

View File

@@ -6,17 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -25,6 +14,8 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -44,32 +35,25 @@ class AssetDetailBottomSheet extends ConsumerWidget {
}
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.viewer),
const ArchiveActionButton(source: ActionSource.viewer),
if (!asset.hasLocal) const DownloadActionButton(source: ActionSource.viewer),
isTrashEnable
? const TrashActionButton(source: ActionSource.viewer)
: const DeletePermanentActionButton(source: ActionSource.viewer),
const DeleteActionButton(source: ActionSource.viewer),
const MoveToLockFolderActionButton(source: ActionSource.viewer),
],
if (asset.storage == AssetState.local) ...[
const DeleteLocalActionButton(source: ActionSource.viewer),
const UploadActionButton(source: ActionSource.timeline),
],
if (currentAlbum != null) RemoveFromAlbumActionButton(albumId: currentAlbum.id, source: ActionSource.viewer),
];
final buttonContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView,
currentAlbum: currentAlbum,
source: ActionSource.viewer,
);
final lockedViewActions = <Widget>[];
final actions = ActionButtonBuilder.build(buttonContext);
return BaseBottomSheet(
actions: isInLockedView ? lockedViewActions : actions,
actions: actions,
slivers: const [_AssetDetailBottomSheet()],
controller: controller,
initialChildSize: initialChildSize,

View File

@@ -13,9 +13,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
@@ -28,12 +28,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
return const SizedBox.shrink();
}
final album = ref.watch(currentRemoteAlbumProvider);
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final previousRouteName = ref.watch(previousRouteNameProvider);
final showViewInTimelineButton = previousRouteName != TabShellRoute.name && previousRouteName != null;
final showViewInTimelineButton =
previousRouteName != TabShellRoute.name &&
previousRouteName != AssetViewerRoute.name &&
previousRouteName != null;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
@@ -44,10 +49,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
}
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final websocketConnected = ref.watch(websocketProvider.select((c) => c.isConnected));
final actions = <Widget>[
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
icon: const Icon(Icons.chat_outlined),
onPressed: () {
context.navigateTo(const DriftActivitiesRoute());
},
),
if (showViewInTimelineButton)
IconButton(
onPressed: () async {
@@ -67,7 +78,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
];
final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
const _KebabMenu(),
];

View File

@@ -69,10 +69,8 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent,
builder: (BuildContext context, ScrollController scrollController) {
return Card(
color: widget.backgroundColor ?? context.colorScheme.surface,
borderOnForeground: false,
clipBehavior: Clip.antiAlias,
elevation: 6.0,
color: widget.backgroundColor ?? context.colorScheme.surfaceContainer,
elevation: 3.0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
margin: const EdgeInsets.symmetric(horizontal: 0),
child: CustomScrollView(

View File

@@ -165,6 +165,7 @@ class AlbumApiRepository extends ApiRepository {
order: dto.order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: dto.assetCount,
ownerName: dto.owner.name,
isShared: dto.albumUsers.length > 2,
);
}
}

View File

@@ -112,6 +112,7 @@ extension on AlbumResponseDto {
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: assetCount,
ownerName: owner.name,
isShared: albumUsers.length > 2,
);
}
}

View File

@@ -80,6 +80,7 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
@@ -339,6 +340,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftEditImageRoute.page),
AutoRoute(page: DriftCropImageRoute.page),
AutoRoute(page: DriftFilterImageRoute.page),
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -667,6 +667,22 @@ class CropImageRouteArgs {
}
}
/// generated route for
/// [DriftActivitiesPage]
class DriftActivitiesRoute extends PageRouteInfo<void> {
const DriftActivitiesRoute({List<PageRouteInfo>? children})
: super(DriftActivitiesRoute.name, initialChildren: children);
static const String name = 'DriftActivitiesRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftActivitiesPage();
},
);
}
/// generated route for
/// [DriftAlbumOptionsPage]
class DriftAlbumOptionsRoute extends PageRouteInfo<void> {

View File

@@ -1,3 +1,4 @@
import 'package:immich_mobile/constants/errors.dart';
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/repositories/activity_api.repository.dart';
@@ -30,7 +31,11 @@ class ActivityService with ErrorLoggerMixin {
Future<bool> removeActivity(String id) async {
return logError(
() async {
await _activityApiRepository.delete(id);
try {
await _activityApiRepository.delete(id);
} on NoResponseDtoError {
return true;
}
return true;
},
defaultValue: false,

View File

@@ -0,0 +1,160 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class ActionButtonContext {
final BaseAsset asset;
final bool isOwner;
final bool isArchived;
final bool isTrashEnabled;
final bool isInLockedView;
final RemoteAlbum? currentAlbum;
final ActionSource source;
const ActionButtonContext({
required this.asset,
required this.isOwner,
required this.isArchived,
required this.isTrashEnabled,
required this.isInLockedView,
required this.currentAlbum,
required this.source,
});
}
enum ActionButtonType {
share,
shareLink,
archive,
unarchive,
download,
trash,
deletePermanent,
delete,
moveToLockFolder,
removeFromLockFolder,
deleteLocal,
upload,
removeFromAlbum,
likeActivity;
bool shouldShow(ActionButtonContext context) {
return switch (this) {
ActionButtonType.share => true,
ActionButtonType.shareLink =>
!context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.archive =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
!context.isArchived,
ActionButtonType.unarchive =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.isArchived,
ActionButtonType.download =>
!context.isInLockedView && //
context.asset.hasRemote && //
!context.asset.hasLocal,
ActionButtonType.trash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
!context.isTrashEnabled ||
context.isInLockedView,
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.moveToLockFolder =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.removeFromLockFolder =>
context.isOwner && //
context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
ActionButtonType.removeFromAlbum =>
context.isOwner && //
!context.isInLockedView && //
context.currentAlbum != null,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
context.currentAlbum!.isActivityEnabled &&
context.currentAlbum!.isShared,
};
}
Widget buildButton(ActionButtonContext context) {
return switch (this) {
ActionButtonType.share => ShareActionButton(source: context.source),
ActionButtonType.shareLink => ShareLinkActionButton(source: context.source),
ActionButtonType.archive => ArchiveActionButton(source: context.source),
ActionButtonType.unarchive => UnArchiveActionButton(source: context.source),
ActionButtonType.download => DownloadActionButton(source: context.source),
ActionButtonType.trash => TrashActionButton(source: context.source),
ActionButtonType.deletePermanent => DeletePermanentActionButton(source: context.source),
ActionButtonType.delete => DeleteActionButton(source: context.source),
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(source: context.source),
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(source: context.source),
ActionButtonType.deleteLocal => DeleteLocalActionButton(source: context.source),
ActionButtonType.upload => UploadActionButton(source: context.source),
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
albumId: context.currentAlbum!.id,
source: context.source,
),
ActionButtonType.likeActivity => const LikeActivityActionButton(),
};
}
}
class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = [
ActionButtonType.share,
ActionButtonType.shareLink,
ActionButtonType.likeActivity,
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.download,
ActionButtonType.trash,
ActionButtonType.deletePermanent,
ActionButtonType.delete,
ActionButtonType.moveToLockFolder,
ActionButtonType.removeFromLockFolder,
ActionButtonType.deleteLocal,
ActionButtonType.upload,
ActionButtonType.removeFromAlbum,
];
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
}

View File

@@ -223,11 +223,11 @@ class _DeviceAsset {
const _DeviceAsset({required this.assetId, this.hash, this.dateTime});
}
Future<void> runNewSync(WidgetRef ref, {bool full = false}) async {
Future<List<void>> runNewSync(WidgetRef ref, {bool full = false}) async {
ref.read(backupProvider.notifier).cancelBackup();
final backgroundManager = ref.read(backgroundSyncProvider);
Future.wait([
return Future.wait([
backgroundManager.syncLocal(full: full).then((_) {
Logger("runNewSync").fine("Hashing assets after syncLocal");
backgroundManager.hashAssets();

View File

@@ -28,12 +28,14 @@ class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
this.onShowOptions,
this.onToggleAlbumOrder,
this.onEditTitle,
this.onActivity,
});
final IconData icon;
final void Function()? onShowOptions;
final void Function()? onToggleAlbumOrder;
final void Function()? onEditTitle;
final void Function()? onActivity;
@override
ConsumerState<RemoteAlbumSliverAppBar> createState() => _MesmerizingSliverAppBarState();
@@ -101,12 +103,33 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onToggleAlbumOrder,
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onActivity,
),
if (widget.onShowOptions != null)
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
],
title: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95
? Text(
currentAlbum.name,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
)
: null,
);
},
),
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
@@ -122,16 +145,6 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
});
return FlexibleSpaceBar(
centerTitle: true,
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95
? Text(
currentAlbum.name,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
)
: null,
),
background: _ExpandedBackground(
scrollProgress: scrollProgress,
icon: widget.icon,

View File

@@ -179,7 +179,7 @@ class LoginForm extends HookConsumerWidget {
final isBeta = Store.isBetaTimelineEnabled;
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
await runNewSync(ref);
context.replaceRoute(const TabShellRoute());
return;
}

View File

@@ -192,7 +192,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
final scaleState = getScaleStateFromNewScale(scale);
if (scaleState == PhotoViewScaleState.zoomedOut) {
scaleStateController.scaleState = PhotoViewScaleState.originalSize;
scaleStateController.scaleState = PhotoViewScaleState.initial;
} else if (scaleState == PhotoViewScaleState.zoomedIn) {
animateRotation(controller.rotation, 0);
if (_shouldAllowPanRotate()) {

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.138.0
- API version: 1.138.1
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.138.0+3003
version: 1.138.1+3004
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -55,6 +55,7 @@ void main() {
updatedAt: DateTime(2023, 1, 2),
ownerId: 'owner1',
ownerName: "Test User",
isShared: false,
);
final albumB = RemoteAlbum(
@@ -68,6 +69,7 @@ void main() {
updatedAt: DateTime(2023, 2, 2),
ownerId: 'owner2',
ownerName: "Test User",
isShared: false,
);
group('sortAlbums', () {

View File

@@ -0,0 +1,717 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
LocalAsset createLocalAsset({
String? remoteId,
String name = 'test.jpg',
String? checksum = 'test-checksum',
AssetType type = AssetType.image,
DateTime? createdAt,
DateTime? updatedAt,
bool isFavorite = false,
}) {
return LocalAsset(
id: 'local-id',
remoteId: remoteId,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt ?? DateTime.now(),
updatedAt: updatedAt ?? DateTime.now(),
isFavorite: isFavorite,
);
}
RemoteAsset createRemoteAsset({
String? localId,
String name = 'test.jpg',
String checksum = 'test-checksum',
AssetType type = AssetType.image,
DateTime? createdAt,
DateTime? updatedAt,
bool isFavorite = false,
}) {
return RemoteAsset(
id: 'remote-id',
localId: localId,
name: name,
checksum: checksum,
type: type,
ownerId: 'owner-id',
createdAt: createdAt ?? DateTime.now(),
updatedAt: updatedAt ?? DateTime.now(),
isFavorite: isFavorite,
);
}
RemoteAlbum createRemoteAlbum({
String id = 'test-album-id',
String name = 'Test Album',
bool isActivityEnabled = false,
bool isShared = false,
}) {
return RemoteAlbum(
id: id,
name: name,
ownerId: 'owner-id',
description: 'Test Description',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
isActivityEnabled: isActivityEnabled,
isShared: isShared,
order: AlbumAssetOrder.asc,
assetCount: 0,
ownerName: 'Test Owner',
);
}
void main() {
group('ActionButtonContext', () {
test('should create context with all required parameters', () {
final asset = createLocalAsset();
final context = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(context.asset, isA<BaseAsset>());
expect(context.isOwner, isTrue);
expect(context.isArchived, isFalse);
expect(context.isTrashEnabled, isTrue);
expect(context.isInLockedView, isFalse);
expect(context.currentAlbum, isNull);
expect(context.source, ActionSource.timeline);
});
});
group('ActionButtonType.shouldShow', () {
late BaseAsset mergedAsset;
setUp(() {
mergedAsset = createLocalAsset(remoteId: 'remote-id');
});
group('share button', () {
test('should show when not in locked view', () {
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
});
test('should show when in locked view', () {
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: true,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
});
});
group('shareLink button', () {
test('should show when not in locked view and asset has remote', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isTrue);
});
test('should not show when in locked view', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: true,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
});
test('should not show when asset has no remote', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
});
});
group('archive button', () {
test('should show when owner, not locked, has remote, and not archived', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isTrue);
});
test('should not show when not owner', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: false,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
});
test('should not show when in locked view', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: true,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
});
test('should not show when asset has no remote', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
});
test('should not show when already archived', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: true,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
});
});
group('unarchive button', () {
test('should show when owner, not locked, has remote, and is archived', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: true,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isTrue);
});
test('should not show when not archived', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
});
test('should not show when not owner', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: false,
isArchived: true,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
});
});
group('download button', () {
test('should show when not locked, has remote, and no local copy', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isTrue);
});
test('should not show when has local copy', () {
final mergedAsset = createLocalAsset(remoteId: 'remote-id');
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
});
test('should not show when in locked view', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: true,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
});
});
group('trash button', () {
test('should show when owner, not locked, has remote, and trash enabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isTrue);
});
test('should not show when trash disabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
});
group('deletePermanent button', () {
test('should show when owner, not locked, has remote, and trash disabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
});
test('should not show when trash enabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
});
});
group('delete button', () {
test('should show when owner, not locked, and has remote', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.delete.shouldShow(context), isTrue);
});
});
group('moveToLockFolder button', () {
test('should show when owner, not locked, and has remote', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue);
});
});
group('deleteLocal button', () {
test('should show when not locked and asset is local only', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
});
test('should not show when asset is not local only', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
});
});
group('upload button', () {
test('should show when not locked and asset is local only', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.upload.shouldShow(context), isTrue);
});
});
group('removeFromAlbum button', () {
test('should show when owner, not locked, and has current album', () {
final album = createRemoteAlbum();
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue);
});
test('should not show when no current album', () {
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse);
});
});
group('likeActivity button', () {
test('should show when not locked, has album, activity enabled, and shared', () {
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isTrue);
});
test('should not show when activity not enabled', () {
final album = createRemoteAlbum(isActivityEnabled: false, isShared: true);
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
});
test('should not show when album not shared', () {
final album = createRemoteAlbum(isActivityEnabled: true, isShared: false);
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
});
test('should not show when no album', () {
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
});
});
});
group('ActionButtonType.buildButton', () {
late BaseAsset asset;
late ActionButtonContext context;
setUp(() {
asset = createLocalAsset(remoteId: 'remote-id');
context = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
});
test('should build correct widget for each button type', () {
for (final buttonType in ActionButtonType.values) {
if (buttonType == ActionButtonType.removeFromAlbum) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else {
final widget = buttonType.buildButton(context);
expect(widget, isA<Widget>());
}
}
});
});
group('ActionButtonBuilder', () {
test('should return buttons that should show', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
final widgets = ActionButtonBuilder.build(context);
expect(widgets, isNotEmpty);
expect(widgets.length, greaterThan(0));
});
test('should include album-specific buttons when album is present', () {
final remoteAsset = createRemoteAsset();
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
source: ActionSource.timeline,
);
final widgets = ActionButtonBuilder.build(context);
expect(widgets, isNotEmpty);
});
test('should only include local buttons for local assets', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
final widgets = ActionButtonBuilder.build(context);
expect(widgets, isNotEmpty);
});
test('should respect archived state', () {
final remoteAsset = createRemoteAsset();
final archivedContext = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: true,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
final archivedWidgets = ActionButtonBuilder.build(archivedContext);
final nonArchivedContext = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
source: ActionSource.timeline,
);
final nonArchivedWidgets = ActionButtonBuilder.build(nonArchivedContext);
expect(archivedWidgets, isNotEmpty);
expect(nonArchivedWidgets, isNotEmpty);
});
});
}

View File

@@ -9499,7 +9499,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.138.0",
"version": "1.138.1",
"contact": {}
},
"tags": [],

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.138.0",
"version": "1.138.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.138.0",
"version": "1.138.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.138.0",
"version": "1.138.1",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.138.0
* 1.138.1
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.138.0",
"version": "1.138.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.138.0",
"version": "1.138.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^11.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.138.0",
"version": "1.138.1",
"description": "",
"author": "",
"private": true,

6
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.138.0",
"version": "1.138.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.138.0",
"version": "1.138.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -94,7 +94,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.138.0",
"version": "1.138.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.138.0",
"version": "1.138.1",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {

View File

@@ -30,8 +30,8 @@
eventManager.emit('auth.login', user);
};
const onFirstLogin = async () => await goto(AppRoute.AUTH_CHANGE_PASSWORD);
const onOnboarding = async () => await goto(AppRoute.AUTH_ONBOARDING);
const onFirstLogin = () => goto(AppRoute.AUTH_CHANGE_PASSWORD);
const onOnboarding = () => goto(AppRoute.AUTH_ONBOARDING);
onMount(async () => {
if (!$featureFlags.oauth) {
@@ -54,6 +54,7 @@
console.error('Error [login-form] [oauth.callback]', error);
oauthError = getServerErrorMessage(error) || $t('errors.unable_to_complete_oauth_login');
oauthLoading = false;
return;
}
}