Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,42 @@
import 'package:auto_route/auto_route.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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@RoutePage()
class CleanupPreviewPage extends StatelessWidget {
final List<LocalAsset> assets;
const CleanupPreviewPage({super.key, required this.assets});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('cleanup_preview_title'.t(context: context, args: {'count': assets.length.toString()})),
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: context.colorScheme.surface,
),
body: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref
.watch(timelineFactoryProvider)
.fromAssetsWithBuckets(assets.cast<BaseAsset>(), TimelineOrigin.search);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, groupBy: GroupAssetsBy.day, readOnly: true),
),
);
}
}

View file

@ -0,0 +1,21 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
@RoutePage()
class MainTimelinePage extends ConsumerWidget {
const MainTimelinePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
topSliverWidgetHeight: hasMemories ? 200 : 0,
showStorageIndicator: true,
);
}
}

View file

@ -0,0 +1,199 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
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/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class _Stat {
const _Stat({required this.name, required this.load});
final String name;
final Future<int> Function(Drift _) load;
}
class _Summary extends StatelessWidget {
final String name;
final Widget? leading;
final Future<int> countFuture;
final void Function()? onTap;
const _Summary({required this.name, required this.countFuture, this.leading, this.onTap});
@override
Widget build(BuildContext context) {
return FutureBuilder<int>(
future: countFuture,
builder: (ctx, snapshot) {
final Widget subtitle;
if (snapshot.connectionState == ConnectionState.waiting) {
subtitle = const CircularProgressIndicator();
} else if (snapshot.hasError) {
subtitle = const Icon(Icons.error_rounded);
} else {
subtitle = Text('${snapshot.data ?? 0}', style: ctx.textTheme.bodyLarge);
}
return ListTile(leading: leading, title: Text(name), trailing: subtitle, onTap: onTap);
},
);
}
}
final _localStats = [
_Stat(name: 'Local Assets', load: (db) => db.managers.localAssetEntity.count()),
_Stat(name: 'Local Albums', load: (db) => db.managers.localAlbumEntity.count()),
];
@RoutePage()
class LocalMediaSummaryPage extends StatelessWidget {
const LocalMediaSummaryPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('local_media_summary'.tr())),
body: Consumer(
builder: (ctx, ref, __) {
final db = ref.watch(driftProvider);
final albumsFuture = ref.watch(localAlbumRepository).getAll();
return CustomScrollView(
slivers: [
SliverList.builder(
itemBuilder: (_, index) {
final stat = _localStats[index];
final countFuture = stat.load(db);
return _Summary(name: stat.name, countFuture: countFuture);
},
itemCount: _localStats.length,
),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 15),
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
),
],
),
),
FutureBuilder(
future: albumsFuture,
builder: (_, snap) {
final albums = snap.data ?? [];
if (albums.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
albums.sortBy((a) => a.name);
return SliverList.builder(
itemBuilder: (_, index) {
final album = albums[index];
final countFuture = db.managers.localAlbumAssetEntity
.filter((f) => f.albumId.id.equals(album.id))
.count();
return _Summary(
leading: const Icon(Icons.photo_album_rounded),
name: album.name,
countFuture: countFuture,
onTap: () => context.router.push(LocalTimelineRoute(album: album)),
);
},
itemCount: albums.length,
);
},
),
],
);
},
),
);
}
}
final _remoteStats = [
_Stat(name: 'Remote Assets', load: (db) => db.managers.remoteAssetEntity.count()),
_Stat(name: 'Exif Entities', load: (db) => db.managers.remoteExifEntity.count()),
_Stat(name: 'Remote Albums', load: (db) => db.managers.remoteAlbumEntity.count()),
_Stat(name: 'Memories', load: (db) => db.managers.memoryEntity.count()),
_Stat(name: 'Memories Assets', load: (db) => db.managers.memoryAssetEntity.count()),
_Stat(name: 'Stacks', load: (db) => db.managers.stackEntity.count()),
_Stat(name: 'People', load: (db) => db.managers.personEntity.count()),
_Stat(name: 'AssetFaces', load: (db) => db.managers.assetFaceEntity.count()),
];
@RoutePage()
class RemoteMediaSummaryPage extends StatelessWidget {
const RemoteMediaSummaryPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('remote_media_summary'.tr())),
body: Consumer(
builder: (ctx, ref, __) {
final db = ref.watch(driftProvider);
final albumsFuture = ref.watch(remoteAlbumRepository).getAll();
return CustomScrollView(
slivers: [
SliverList.builder(
itemBuilder: (_, index) {
final stat = _remoteStats[index];
final countFuture = stat.load(db);
return _Summary(name: stat.name, countFuture: countFuture);
},
itemCount: _remoteStats.length,
),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 15),
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
),
],
),
),
FutureBuilder(
future: albumsFuture,
builder: (_, snap) {
final albums = snap.data ?? [];
if (albums.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
albums.sortBy((a) => a.name);
return SliverList.builder(
itemBuilder: (_, index) {
final album = albums[index];
final countFuture = db.managers.remoteAlbumAssetEntity
.filter((f) => f.albumId.id.equals(album.id))
.count();
return _Summary(
leading: const Icon(Icons.photo_album_rounded),
name: album.name,
countFuture: countFuture,
onTap: () => context.router.push(RemoteAlbumRoute(album: album)),
);
},
itemCount: albums.length,
);
},
),
],
);
},
),
);
}
}

View file

@ -0,0 +1,100 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_ui/immich_ui.dart';
List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
final children = <Widget>[];
final items = [
(variant: ImmichVariant.filled, title: "Filled Variant"),
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
];
for (final (:variant, :title) in items) {
children.add(Text(title));
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)]));
}
return children;
}
class _ComponentTitle extends StatelessWidget {
final String title;
const _ComponentTitle(this.title);
@override
Widget build(BuildContext context) {
return Text(title, style: context.textTheme.titleLarge);
}
}
@RoutePage()
class ImmichUIShowcasePage extends StatelessWidget {
const ImmichUIShowcasePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Immich UI Showcase')),
body: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
spacing: 10,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _ComponentTitle("IconButton"),
..._showcaseBuilder(
(variant, color) =>
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("CloseButton"),
..._showcaseBuilder(
(variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("TextButton"),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
loading: true,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
loading: true,
),
const _ComponentTitle("Form"),
ImmichForm(
onSubmit: () {},
child: const Column(
spacing: 10,
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,57 @@
import 'package:auto_route/auto_route.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
@RoutePage()
class DownloadInfoPage extends ConsumerWidget {
const DownloadInfoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList();
onCancelDownload(String id) {
ref.watch(downloadStateProvider.notifier).cancelDownload(id);
}
return Scaffold(
appBar: AppBar(
title: Text("download".t(context: context)),
actions: [],
),
body: ListView.builder(
physics: const ClampingScrollPhysics(),
shrinkWrap: true,
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: DownloadTaskTile(
progress: task.value.progress,
fileName: task.value.fileName,
status: task.value.status,
onCancelDownload: () => onCancelDownload(task.key),
),
);
},
),
persistentFooterButtons: [
OutlinedButton(
onPressed: () {
tasks.map((e) => e.key).forEach(onCancelDownload);
},
style: OutlinedButton.styleFrom(side: BorderSide(color: context.colorScheme.primary)),
child: Text(
'clear_all'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
),
),
],
);
}
}

View file

@ -0,0 +1,83 @@
import 'package:auto_route/auto_route.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/album/album.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/activities/comment_bubble.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/current_album.provider.dart';
@RoutePage()
class DriftActivitiesPage extends HookConsumerWidget {
final RemoteAlbum album;
const DriftActivitiesPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activityNotifier = ref.read(albumActivityProvider(album.id).notifier);
final activities = ref.watch(albumActivityProvider(album.id));
final listViewScrollController = useScrollController();
void scrollToBottom() {
listViewScrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.fastOutSlowIn);
}
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
scrollToBottom();
}
return ProviderScope(
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
child: Scaffold(
appBar: AppBar(
title: Text(album.name),
actions: [const LikeActivityActionButton(iconOnly: true)],
actionsPadding: const EdgeInsets.only(right: 8),
),
body: activities.widgetWhen(
onData: (data) {
final List<Widget> activityWidgets = [];
for (final activity in data.reversed) {
activityWidgets.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: CommentBubble(activity: activity),
),
);
}
return SafeArea(
child: Stack(
children: [
ListView(
controller: listViewScrollController,
padding: const EdgeInsets.only(top: 8, bottom: 80),
reverse: true,
children: activityWidgets,
),
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, onSubmit: onAddComment),
),
),
],
),
);
},
),
resizeToAvoidBottomInset: true,
),
);
}
}

View file

@ -0,0 +1,80 @@
import 'dart:async';
import 'package:auto_route/auto_route.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/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
@RoutePage()
class DriftAlbumsPage extends ConsumerStatefulWidget {
const DriftAlbumsPage({super.key});
@override
ConsumerState<DriftAlbumsPage> createState() => _DriftAlbumsPageState();
}
class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
final ScrollController _scrollController = ScrollController();
Future<void> onRefresh() async {
await ref.read(remoteAlbumProvider.notifier).refresh();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final albumCount = ref.watch(remoteAlbumProvider.select((state) => state.albums.length));
final showScrollbar = albumCount > 10;
final scrollView = CustomScrollView(
controller: _scrollController,
slivers: [
ImmichSliverAppBar(
snap: false,
floating: false,
pinned: true,
actions: [
IconButton(
icon: const Icon(Icons.add_rounded, size: 28),
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
),
],
showUploadButton: false,
),
AlbumSelector(
onAlbumSelected: (album) {
context.router.push(RemoteAlbumRoute(album: album));
},
),
],
);
return RefreshIndicator(
onRefresh: onRefresh,
edgeOffset: 100,
child: showScrollbar
? RawScrollbar(
controller: _scrollController,
interactive: true,
thickness: 8,
radius: const Radius.circular(4),
thumbVisibility: false,
thumbColor: context.colorScheme.primary,
crossAxisMargin: 4,
mainAxisMargin: 60,
minThumbLength: 40,
child: scrollView,
)
: scrollView,
);
}
}

View file

@ -0,0 +1,239 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
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/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage()
class DriftAlbumOptionsPage extends HookConsumerWidget {
final RemoteAlbum album;
const DriftAlbumOptionsPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedUsersAsync = ref.watch(remoteAlbumSharedUsersProvider(album.id));
final userId = ref.watch(authProvider).userId;
final activityEnabled = useState(album.isActivityEnabled);
final isOwner = album.ownerId == userId;
void showErrorMessage() {
context.pop();
ImmichToast.show(
context: context,
msg: "shared_album_section_people_action_error".t(context: context),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
void leaveAlbum() async {
try {
await ref.read(remoteAlbumProvider.notifier).leaveAlbum(album.id, userId: userId);
unawaited(context.navigateTo(const DriftAlbumsRoute()));
} catch (_) {
showErrorMessage();
}
}
void removeUserFromAlbum(UserDto user) async {
try {
await ref.read(remoteAlbumProvider.notifier).removeUser(album.id, user.id);
ref.invalidate(remoteAlbumSharedUsersProvider(album.id));
} catch (_) {
showErrorMessage();
}
context.pop();
}
Future<void> addUsers() async {
final newUsers = await context.pushRoute<List<String>>(DriftUserSelectionRoute(album: album));
if (newUsers == null || newUsers.isEmpty) {
return;
}
try {
await ref.read(remoteAlbumProvider.notifier).addUsers(album.id, newUsers);
if (newUsers.isNotEmpty) {
ImmichToast.show(
context: context,
msg: "users_added_to_album_count".t(context: context, args: {'count': newUsers.length}),
toastType: ToastType.success,
);
}
ref.invalidate(remoteAlbumSharedUsersProvider(album.id));
} catch (e) {
ImmichToast.show(
context: context,
msg: "Failed to add users to album: ${e.toString()}",
toastType: ToastType.error,
);
}
}
void handleUserClick(UserDto user) {
var actions = [];
if (user.id == userId) {
actions = [
ListTile(
leading: const Icon(Icons.exit_to_app_rounded),
title: const Text("leave_album").t(context: context),
onTap: leaveAlbum,
),
];
}
if (isOwner) {
actions = [
ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text("remove_user").t(context: context),
onTap: () => removeUserFromAlbum(user),
),
];
}
showModalBottomSheet(
backgroundColor: context.colorScheme.surfaceContainer,
isScrollControlled: false,
context: context,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(mainAxisSize: MainAxisSize.min, children: [...actions]),
),
);
},
);
}
buildOwnerInfo() {
if (isOwner) {
final owner = ref.watch(currentUserProvider);
return ListTile(
leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(),
title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(owner?.email ?? "", style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
);
} else {
final usersProvider = ref.watch(driftUsersProvider);
return usersProvider.maybeWhen(
data: (users) {
final user = users.firstWhereOrNull((u) => u.id == album.ownerId);
if (user == null) {
return const SizedBox();
}
return ListTile(
leading: UserCircleAvatar(user: user, radius: 22),
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
);
},
orElse: () => const SizedBox(),
);
}
}
buildSharedUsersList() {
return sharedUsersAsync.maybeWhen(
data: (sharedUsers) => ListView.builder(
primary: false,
shrinkWrap: true,
itemCount: sharedUsers.length,
itemBuilder: (context, index) {
final user = sharedUsers[index];
return ListTile(
leading: UserCircleAvatar(user: user, radius: 22),
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
onTap: userId == user.id || isOwner ? () => handleUserClick(user) : null,
);
},
),
orElse: () => const Center(child: CircularProgressIndicator()),
);
}
buildSectionTitle(String text) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(text, style: context.textTheme.bodySmall),
);
}
return ProviderScope(
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
child: Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: () => context.maybePop(null),
),
centerTitle: true,
title: Text("options".t(context: context)),
),
body: ListView(
children: [
const SizedBox(height: 8),
if (isOwner)
SwitchListTile.adaptive(
value: activityEnabled.value,
onChanged: (bool value) async {
activityEnabled.value = value;
await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value);
},
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
dense: true,
title: Text(
"comments_and_likes",
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
).t(context: context),
subtitle: Text(
"let_others_respond",
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
).t(context: context),
),
buildSectionTitle("shared_album_section_people_title".t(context: context)),
if (isOwner) ...[
ListTile(
leading: const Icon(Icons.person_add_rounded),
title: Text("invite_people".t(context: context)),
onTap: () async => addUsers(),
),
const Divider(indent: 16),
],
buildOwnerInfo(),
buildSharedUsersList(),
],
),
),
);
}
}

View file

@ -0,0 +1,39 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftArchivePage extends StatelessWidget {
const DriftArchivePage({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access archive');
}
final timelineService = ref.watch(timelineFactoryProvider).archive(user.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(
title: 'archive'.t(context: context),
icon: Icons.archive_outlined,
),
bottomSheet: const ArchiveBottomSheet(),
),
);
}
}

View file

@ -0,0 +1,38 @@
import 'package:auto_route/auto_route.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/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class DriftAssetSelectionTimelinePage extends ConsumerWidget {
final Set<BaseAsset> lockedSelectionAssets;
const DriftAssetSelectionTimelinePage({super.key, this.lockedSelectionAssets = const {}});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ProviderScope(
overrides: [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {}, lockedSelectionAssets: lockedSelectionAssets, forceEnable: true),
),
),
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access asset selection timeline');
}
final timelineService = ref.watch(timelineFactoryProvider).remoteAssets(user.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: const Timeline(),
);
}
}

View file

@ -0,0 +1,362 @@
import 'package:auto_route/auto_route.dart';
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/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@RoutePage()
class AssetTroubleshootPage extends ConsumerWidget {
final BaseAsset asset;
const AssetTroubleshootPage({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: Text('asset_troubleshoot'.tr())),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _AssetDetailsView(asset: asset),
),
),
);
}
}
class _AssetDetailsView extends ConsumerWidget {
final BaseAsset asset;
const _AssetDetailsView({required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AssetPropertiesSection(asset: asset),
const SizedBox(height: 16),
Text(
'matching_assets'.tr(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
if (asset.checksum != null) ...[
_LocalAssetsSection(asset: asset),
const SizedBox(height: 16),
_RemoteAssetSection(asset: asset),
] else ...[
_PropertySectionCard(
title: 'Local Assets',
properties: [_PropertyItem(label: 'Status', value: 'no_checksum_local'.tr())],
),
const SizedBox(height: 16),
_PropertySectionCard(
title: 'Remote Assets',
properties: [_PropertyItem(label: 'Status', value: 'no_checksum_remote'.tr())],
),
],
],
);
}
}
class _AssetPropertiesSection extends ConsumerStatefulWidget {
final BaseAsset asset;
const _AssetPropertiesSection({required this.asset});
@override
ConsumerState createState() => _AssetPropertiesSectionState();
}
class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection> {
List<_PropertyItem> properties = [];
@override
void initState() {
super.initState();
_buildAssetProperties(widget.asset).whenComplete(() {
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(BuildContext context) {
final title = _getAssetTypeTitle(widget.asset);
return _PropertySectionCard(title: title, properties: properties);
}
Future<void> _buildAssetProperties(BaseAsset asset) async {
_addCommonProperties();
if (asset is LocalAsset) {
await _addLocalAssetProperties(asset);
} else if (asset is RemoteAsset) {
await _addRemoteAssetProperties(asset);
}
}
void _addCommonProperties() {
final asset = widget.asset;
properties.addAll([
_PropertyItem(label: 'Name', value: asset.name),
_PropertyItem(label: 'Checksum', value: asset.checksum),
_PropertyItem(label: 'Type', value: asset.type.toString()),
_PropertyItem(label: 'Created At', value: asset.createdAt.toString()),
_PropertyItem(label: 'Updated At', value: asset.updatedAt.toString()),
_PropertyItem(label: 'Width', value: asset.width?.toString()),
_PropertyItem(label: 'Height', value: asset.height?.toString()),
_PropertyItem(
label: 'Duration',
value: asset.durationInSeconds != null ? '${asset.durationInSeconds} seconds' : null,
),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
]);
}
Future<void> _addLocalAssetProperties(LocalAsset asset) async {
properties.insertAll(0, [
_PropertyItem(label: 'Local ID', value: asset.id),
_PropertyItem(label: 'Remote ID', value: asset.remoteId),
]);
properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString()));
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
if (CurrentPlatform.isIOS) {
properties.add(_PropertyItem(label: 'Cloud ID', value: asset.cloudId));
properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString()));
}
properties.add(
_PropertyItem(
label: 'GPS Coordinates',
value: asset.hasCoordinates ? '${asset.latitude}, ${asset.longitude}' : null,
),
);
}
Future<void> _addRemoteAssetProperties(RemoteAsset asset) async {
properties.insertAll(0, [
_PropertyItem(label: 'Remote ID', value: asset.id),
_PropertyItem(label: 'Local ID', value: asset.localId),
_PropertyItem(label: 'Owner ID', value: asset.ownerId),
]);
final additionalProps = <_PropertyItem>[
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
_PropertyItem(label: 'Stack ID', value: asset.stackId),
];
properties.insertAll(4, additionalProps);
final exif = await ref.read(assetServiceProvider).getExif(asset);
if (exif != null) {
_addExifProperties(exif);
} else {
properties.add(const _PropertyItem(label: 'EXIF', value: null));
}
}
void _addExifProperties(ExifInfo exif) {
properties.addAll([
_PropertyItem(
label: 'File Size',
value: exif.fileSize != null ? '${(exif.fileSize! / 1024 / 1024).toStringAsFixed(2)} MB' : null,
),
_PropertyItem(label: 'Description', value: exif.description),
_PropertyItem(label: 'Date Taken', value: exif.dateTimeOriginal?.toString()),
_PropertyItem(label: 'Time Zone', value: exif.timeZone),
_PropertyItem(label: 'Camera Make', value: exif.make),
_PropertyItem(label: 'Camera Model', value: exif.model),
_PropertyItem(label: 'Lens', value: exif.lens),
_PropertyItem(label: 'F-Number', value: exif.f != null ? 'f/${exif.fNumber}' : null),
_PropertyItem(label: 'Focal Length', value: exif.mm != null ? '${exif.focalLength}mm' : null),
_PropertyItem(label: 'ISO', value: exif.iso?.toString()),
_PropertyItem(label: 'Exposure Time', value: exif.exposureTime.isNotEmpty ? exif.exposureTime : null),
_PropertyItem(
label: 'GPS Coordinates',
value: exif.hasCoordinates ? '${exif.latitude}, ${exif.longitude}' : null,
),
_PropertyItem(
label: 'Location',
value: [exif.city, exif.state, exif.country].where((e) => e != null && e.isNotEmpty).join(', '),
),
]);
}
String _getAssetTypeTitle(BaseAsset asset) {
if (asset is LocalAsset) return 'Local Asset';
if (asset is RemoteAsset) return 'Remote Asset';
return 'Base Asset';
}
}
class _LocalAssetsSection extends ConsumerWidget {
final BaseAsset asset;
const _LocalAssetsSection({required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetService = ref.watch(assetServiceProvider);
return FutureBuilder<List<LocalAsset?>>(
future: assetService.getLocalAssetsByChecksum(asset.checksum!),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _PropertySectionCard(
title: 'Local Assets',
properties: [_PropertyItem(label: 'Status', value: 'Loading...')],
);
}
if (snapshot.hasError) {
return _PropertySectionCard(
title: 'Local Assets',
properties: [_PropertyItem(label: 'Error', value: snapshot.error.toString())],
);
}
final localAssets = snapshot.data?.cast<LocalAsset>() ?? [];
if (asset is LocalAsset) {
localAssets.removeWhere((a) => a.id == (asset as LocalAsset).id);
if (localAssets.isEmpty) {
return const SizedBox.shrink();
}
}
if (localAssets.isEmpty) {
return _PropertySectionCard(
title: 'Local Assets',
properties: [_PropertyItem(label: 'Status', value: 'no_local_assets_found'.tr())],
);
}
return Column(
children: [
if (localAssets.length > 1)
_PropertySectionCard(
title: 'Local Assets Summary',
properties: [_PropertyItem(label: 'Total Count', value: localAssets.length.toString())],
),
...localAssets.map((localAsset) {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: _AssetPropertiesSection(asset: localAsset),
);
}),
],
);
},
);
}
}
class _RemoteAssetSection extends ConsumerWidget {
final BaseAsset asset;
const _RemoteAssetSection({required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetService = ref.watch(assetServiceProvider);
if (asset is RemoteAsset) {
return const SizedBox.shrink();
}
return FutureBuilder<RemoteAsset?>(
future: assetService.getRemoteAssetByChecksum(asset.checksum!),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _PropertySectionCard(
title: 'Remote Assets',
properties: [_PropertyItem(label: 'Status', value: 'Loading...')],
);
}
if (snapshot.hasError) {
return _PropertySectionCard(
title: 'Remote Assets',
properties: [_PropertyItem(label: 'Error', value: snapshot.error.toString())],
);
}
final remoteAsset = snapshot.data;
if (remoteAsset == null) {
return _PropertySectionCard(
title: 'Remote Assets',
properties: [_PropertyItem(label: 'Status', value: 'no_remote_assets_found'.tr())],
);
}
return _AssetPropertiesSection(asset: remoteAsset);
},
);
}
}
class _PropertySectionCard extends StatelessWidget {
final String title;
final List<_PropertyItem> properties;
const _PropertySectionCard({required this.title, required this.properties});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
...properties,
],
),
),
);
}
}
class _PropertyItem extends StatelessWidget {
final String label;
final String? value;
const _PropertyItem({required this.label, this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text('$label:', style: const TextStyle(fontWeight: FontWeight.w500)),
),
Expanded(
child: Text(
value ?? 'not_available'.tr(),
style: TextStyle(color: Theme.of(context).colorScheme.secondary),
),
),
],
),
);
}
}

View file

@ -0,0 +1,428 @@
import 'dart:async';
import 'package:auto_route/auto_route.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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
@RoutePage()
class DriftCreateAlbumPage extends ConsumerStatefulWidget {
const DriftCreateAlbumPage({super.key});
@override
ConsumerState<DriftCreateAlbumPage> createState() => _DriftCreateAlbumPageState();
}
class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
TextEditingController albumTitleController = TextEditingController();
TextEditingController albumDescriptionController = TextEditingController();
FocusNode albumTitleTextFieldFocusNode = FocusNode();
FocusNode albumDescriptionTextFieldFocusNode = FocusNode();
bool isAlbumTitleTextFieldFocus = false;
Set<BaseAsset> selectedAssets = {};
@override
void initState() {
super.initState();
albumTitleController.addListener(_onTitleChanged);
}
void _onTitleChanged() {
setState(() {});
}
@override
void dispose() {
albumTitleController.removeListener(_onTitleChanged);
albumTitleController.dispose();
albumDescriptionController.dispose();
albumTitleTextFieldFocusNode.dispose();
albumDescriptionTextFieldFocusNode.dispose();
super.dispose();
}
bool get _canCreateAlbum => albumTitleController.text.isNotEmpty;
String _getEffectiveTitle() {
return albumTitleController.text.isNotEmpty
? albumTitleController.text
: 'create_album_page_untitled'.t(context: context);
}
Widget _buildSliverAppBar() {
return SliverAppBar(
backgroundColor: context.scaffoldBackgroundColor,
elevation: 0,
automaticallyImplyLeading: false,
pinned: true,
snap: false,
floating: false,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(200.0),
child: SizedBox(
height: 200,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
buildTitleInputField(),
buildDescriptionInputField(),
if (selectedAssets.isNotEmpty) buildControlButton(),
],
),
),
),
);
}
Widget _buildContent() {
if (selectedAssets.isEmpty) {
return SliverList(delegate: SliverChildListDelegate([_buildEmptyState(), _buildSelectPhotosButton()]));
} else {
return _buildSelectedImageGrid();
}
}
Widget _buildEmptyState() {
return Padding(
padding: const EdgeInsets.only(top: 0, left: 18),
child: Text('create_shared_album_page_share_add_assets', style: context.textTheme.labelLarge).t(),
);
}
Widget _buildSelectPhotosButton() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: FilledButton.icon(
style: FilledButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 16.0),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))),
backgroundColor: context.colorScheme.surfaceContainerHigh,
),
onPressed: onSelectPhotos,
icon: Icon(Icons.add_rounded, color: context.primaryColor),
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
'create_shared_album_page_share_select_photos',
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
).t(),
),
),
);
}
Widget _buildSelectedImageGrid() {
return SliverPadding(
padding: const EdgeInsets.only(top: 16.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 1.0,
mainAxisSpacing: 1.0,
),
delegate: SliverChildBuilderDelegate((context, index) {
final asset = selectedAssets.elementAt(index);
return GestureDetector(
onTap: onBackgroundTapped,
child: Thumbnail.fromAsset(asset: asset),
);
}, childCount: selectedAssets.length),
),
);
}
void onBackgroundTapped() {
albumTitleTextFieldFocusNode.unfocus();
albumDescriptionTextFieldFocusNode.unfocus();
setState(() {
isAlbumTitleTextFieldFocus = false;
});
if (albumTitleController.text.isEmpty) {
final untitledText = 'create_album_page_untitled'.t();
albumTitleController.text = untitledText;
}
}
Future<void> onSelectPhotos() async {
final assets = await context.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: selectedAssets),
);
if (assets == null || assets.isEmpty) {
return;
}
setState(() {
selectedAssets = selectedAssets.union(assets);
});
}
Future<void> createAlbum() async {
onBackgroundTapped();
final title = _getEffectiveTitle().trim();
if (title.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('create_album_title_required'.t()), backgroundColor: context.colorScheme.error),
);
}
return;
}
final album = await ref
.watch(remoteAlbumProvider.notifier)
.createAlbum(
title: title,
description: albumDescriptionController.text.trim(),
assetIds: selectedAssets.map((asset) {
final remoteAsset = asset as RemoteAsset;
return remoteAsset.id;
}).toList(),
);
if (album != null) {
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
}
}
Widget buildTitleInputField() {
return Padding(
padding: const EdgeInsets.only(right: 10.0, left: 10.0),
child: _AlbumTitleTextField(
focusNode: albumTitleTextFieldFocusNode,
textController: albumTitleController,
isFocus: isAlbumTitleTextFieldFocus,
onFocusChanged: (focus) {
setState(() {
isAlbumTitleTextFieldFocus = focus;
});
},
),
);
}
Widget buildDescriptionInputField() {
return Padding(
padding: const EdgeInsets.only(right: 10.0, left: 10.0, top: 8),
child: _AlbumViewerEditableDescription(
textController: albumDescriptionController,
focusNode: albumDescriptionTextFieldFocusNode,
),
);
}
Widget buildControlButton() {
return Padding(
padding: const EdgeInsets.only(left: 12.0, top: 8.0, bottom: 8.0),
child: SizedBox(
height: 42.0,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
AlbumActionFilledButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: onSelectPhotos,
labelText: "add_photos".t(),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
centerTitle: false,
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.close_rounded)),
title: const Text('create_album').t(),
actions: [
TextButton(
onPressed: _canCreateAlbum ? createAlbum : null,
child: Text(
'create'.t(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: _canCreateAlbum ? context.primaryColor : context.themeData.disabledColor,
),
),
),
],
),
body: GestureDetector(
onTap: onBackgroundTapped,
child: CustomScrollView(slivers: [_buildSliverAppBar(), _buildContent()]),
),
);
}
}
class _AlbumTitleTextField extends StatefulWidget {
const _AlbumTitleTextField({
required this.focusNode,
required this.textController,
required this.isFocus,
required this.onFocusChanged,
});
final FocusNode focusNode;
final TextEditingController textController;
final bool isFocus;
final ValueChanged<bool> onFocusChanged;
@override
State<_AlbumTitleTextField> createState() => _AlbumTitleTextFieldState();
}
class _AlbumTitleTextFieldState extends State<_AlbumTitleTextField> {
@override
void initState() {
super.initState();
widget.focusNode.addListener(_onFocusChange);
}
@override
void dispose() {
widget.focusNode.removeListener(_onFocusChange);
super.dispose();
}
void _onFocusChange() {
widget.onFocusChanged(widget.focusNode.hasFocus);
}
@override
Widget build(BuildContext context) {
return TextField(
focusNode: widget.focusNode,
style: TextStyle(fontSize: 28.0, color: context.colorScheme.onSurface, fontWeight: FontWeight.bold),
controller: widget.textController,
onTap: () {
if (widget.textController.text == 'create_album_page_untitled'.t(context: context)) {
widget.textController.clear();
}
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
suffixIcon: widget.textController.text.isNotEmpty && widget.isFocus
? IconButton(
onPressed: () {
widget.textController.clear();
},
icon: Icon(Icons.cancel_rounded, color: context.primaryColor),
splashRadius: 10.0,
)
: null,
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.all(Radius.circular(16.0)),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: context.primaryColor.withValues(alpha: 0.3)),
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
),
hintText: 'add_a_title'.t(),
hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith(
fontSize: 28.0,
fontWeight: FontWeight.bold,
height: 1.2,
),
focusColor: Colors.grey[300],
fillColor: context.colorScheme.surfaceContainerHigh,
filled: true,
),
);
}
}
class _AlbumViewerEditableDescription extends StatefulWidget {
const _AlbumViewerEditableDescription({required this.textController, required this.focusNode});
final TextEditingController textController;
final FocusNode focusNode;
@override
State<_AlbumViewerEditableDescription> createState() => _AlbumViewerEditableDescriptionState();
}
class _AlbumViewerEditableDescriptionState extends State<_AlbumViewerEditableDescription> {
@override
void initState() {
super.initState();
widget.focusNode.addListener(_onFocusModeChange);
widget.textController.addListener(_onTextChange);
}
@override
void dispose() {
widget.focusNode.removeListener(_onFocusModeChange);
widget.textController.removeListener(_onTextChange);
super.dispose();
}
void _onFocusModeChange() {
setState(() {
if (!widget.focusNode.hasFocus && widget.textController.text.isEmpty) {
widget.textController.clear();
}
});
}
void _onTextChange() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: TextField(
focusNode: widget.focusNode,
style: context.textTheme.bodyLarge,
maxLines: 3,
minLines: 1,
controller: widget.textController,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
suffixIcon: widget.focusNode.hasFocus && widget.textController.text.isNotEmpty
? IconButton(
onPressed: () {
widget.textController.clear();
},
icon: Icon(Icons.cancel_rounded, color: context.primaryColor),
splashRadius: 10.0,
)
: null,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.3)),
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: context.primaryColor.withValues(alpha: 0.3)),
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
),
hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith(
fontSize: 16.0,
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
focusColor: Colors.grey[300],
fillColor: context.scaffoldBackgroundColor,
filled: widget.focusNode.hasFocus,
hintText: 'add_a_description'.t(),
),
),
);
}
}

View file

@ -0,0 +1,39 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftFavoritePage extends StatelessWidget {
const DriftFavoritePage({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access favorite');
}
final timelineService = ref.watch(timelineFactoryProvider).favorite(user.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(
title: 'favorites'.t(context: context),
icon: Icons.favorite_outline,
),
bottomSheet: const FavoriteBottomSheet(),
),
);
}
}

View file

@ -0,0 +1,439 @@
import 'package:auto_route/auto_route.dart';
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/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class DriftLibraryPage extends ConsumerWidget {
const DriftLibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const Scaffold(
body: CustomScrollView(
slivers: [
ImmichSliverAppBar(snap: false, floating: false, pinned: true, showUploadButton: false),
_ActionButtonGrid(),
_CollectionCards(),
_QuickAccessButtonList(),
],
),
);
}
}
class _ActionButtonGrid extends ConsumerWidget {
const _ActionButtonGrid();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
return SliverPadding(
padding: const EdgeInsets.only(left: 16, top: 16, right: 16, bottom: 12),
sliver: SliverToBoxAdapter(
child: Column(
children: [
Row(
children: [
_ActionButton(
icon: Icons.favorite_outline_rounded,
onTap: () => context.pushRoute(const DriftFavoriteRoute()),
label: 'favorites'.t(context: context),
),
const SizedBox(width: 8),
_ActionButton(
icon: Icons.archive_outlined,
onTap: () => context.pushRoute(const DriftArchiveRoute()),
label: 'archived'.t(context: context),
),
],
),
const SizedBox(height: 8),
Row(
children: [
_ActionButton(
icon: Icons.link_outlined,
onTap: () => context.pushRoute(const SharedLinkRoute()),
label: 'shared_links'.t(context: context),
),
isTrashEnable ? const SizedBox(width: 8) : const SizedBox.shrink(),
isTrashEnable
? _ActionButton(
icon: Icons.delete_outline_rounded,
onTap: () => context.pushRoute(const DriftTrashRoute()),
label: 'trash'.t(context: context),
)
: const SizedBox.shrink(),
],
),
],
),
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({required this.icon, required this.onTap, required this.label});
final IconData icon;
final VoidCallback onTap;
final String label;
@override
Widget build(BuildContext context) {
return Expanded(
child: FilledButton.icon(
onPressed: onTap,
label: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(label, style: TextStyle(color: context.colorScheme.onSurface, fontSize: 15)),
),
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
backgroundColor: context.colorScheme.surfaceContainerLow,
alignment: Alignment.centerLeft,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(25)),
side: BorderSide(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
),
),
icon: Icon(icon, color: context.primaryColor),
),
);
}
}
class _CollectionCards extends StatelessWidget {
const _CollectionCards();
@override
Widget build(BuildContext context) {
return const SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()],
),
),
);
}
}
class _PeopleCollectionCard extends ConsumerWidget {
const _PeopleCollectionCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(driftGetAllPeopleProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const DriftPeopleCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: people.widgetWhen(
onLoading: () => const Center(child: CircularProgressIndicator()),
onData: (people) {
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
);
}).toList(),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'people'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class _PlacesCollectionCard extends StatelessWidget {
const _PlacesCollectionCard();
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(DriftPlaceRoute(currentLocation: null)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: size,
width: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(21.44950, -157.91959),
showAttribution: false,
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'places'.t(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class _LocalAlbumsCollectionCard extends ConsumerWidget {
const _LocalAlbumsCollectionCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const DriftLocalAlbumsRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: size,
width: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: albums.when(
data: (data) {
return data.take(4).map((album) {
return LocalAlbumThumbnail(albumId: album.id);
}).toList();
},
error: (error, _) {
return [
Center(child: Text('error_saving_image'.tr(args: [error.toString()]))),
];
},
loading: () {
return [const Center(child: CircularProgressIndicator())];
},
),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'on_this_device'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class _QuickAccessButtonList extends ConsumerWidget {
const _QuickAccessButtonList();
@override
Widget build(BuildContext context, WidgetRef ref) {
final partnerSharedWithAsync = ref.watch(driftSharedWithPartnerProvider);
final partners = partnerSharedWithAsync.valueOrNull ?? [];
return SliverPadding(
padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32),
sliver: SliverToBoxAdapter(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
context.colorScheme.primary.withAlpha(20),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(0),
physics: const NeverScrollableScrollPhysics(),
children: [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20),
topRight: const Radius.circular(20),
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0),
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
),
),
leading: const Icon(Icons.folder_outlined, size: 26),
title: Text(
'folders'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(FolderRoute()),
),
ListTile(
leading: const Icon(Icons.lock_outline_rounded, size: 26),
title: Text(
'locked_folder'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const DriftLockedFolderRoute()),
),
ListTile(
leading: const Icon(Icons.group_outlined, size: 26),
title: Text(
'partners'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const DriftPartnerRoute()),
),
_PartnerList(partners: partners),
],
),
),
),
);
}
}
class _PartnerList extends StatelessWidget {
const _PartnerList({required this.partners});
final List<PartnerUserDto> partners;
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: const EdgeInsets.all(0),
physics: const NeverScrollableScrollPhysics(),
itemCount: partners.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final partner = partners[index];
final isLastItem = index == partners.length - 1;
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(isLastItem ? 20 : 0),
bottomRight: Radius.circular(isLastItem ? 20 : 0),
),
),
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: PartnerUserAvatar(partner: partner),
title: const Text(
"partner_list_user_photos",
style: TextStyle(fontWeight: FontWeight.w500),
).t(context: context, args: {'user': partner.name}),
onTap: () => context.pushRoute(DriftPartnerDetailRoute(partner: partner)),
);
},
);
}
}

View file

@ -0,0 +1,83 @@
import 'package:auto_route/auto_route.dart';
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/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/local_album_sliver_app_bar.dart';
@RoutePage()
class DriftLocalAlbumsPage extends StatelessWidget {
const DriftLocalAlbumsPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(body: CustomScrollView(slivers: [LocalAlbumsSliverAppBar(), _AlbumList()]));
}
}
class _AlbumList extends ConsumerWidget {
const _AlbumList();
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumProvider);
return albums.when(
loading: () => const SliverToBoxAdapter(
child: Center(
child: Padding(padding: EdgeInsets.all(20.0), child: CircularProgressIndicator()),
),
),
error: (error, stack) => SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
'Error loading albums: $error, stack: $stack',
style: TextStyle(color: context.colorScheme.error),
),
),
),
),
data: (albums) {
if (albums.isEmpty) {
return SliverToBoxAdapter(
child: Center(
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('no_albums_yet'.tr())),
),
);
}
return SliverPadding(
padding: const EdgeInsets.all(18.0),
sliver: SliverList.builder(
itemBuilder: (_, index) {
final album = albums[index];
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
leadingPadding: const EdgeInsets.only(right: 16),
leading: SizedBox(width: 80, height: 80, child: LocalAlbumThumbnail(albumId: album.id)),
title: Text(album.name, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
subtitle: Text(
'items_count'.t(context: context, args: {'count': album.assetCount}),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onTap: () => context.pushRoute(LocalTimelineRoute(album: album)),
),
);
},
itemCount: albums.length,
),
);
},
);
}
}

View file

@ -0,0 +1,70 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftLockedFolderPage extends ConsumerStatefulWidget {
const DriftLockedFolderPage({super.key});
@override
ConsumerState<DriftLockedFolderPage> createState() => _DriftLockedFolderPageState();
}
class _DriftLockedFolderPageState extends ConsumerState<DriftLockedFolderPage> with WidgetsBindingObserver {
bool _showOverlay = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (mounted) {
setState(() {
_showOverlay = state != AppLifecycleState.resumed;
});
}
}
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access locked folder');
}
final timelineService = ref.watch(timelineFactoryProvider).lockedFolder(user.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: _showOverlay
? const SizedBox()
: PopScope(
onPopInvokedWithResult: (didPop, _) => didPop ? ref.read(authProvider.notifier).lockPinCode() : null,
child: Timeline(
appBar: MesmerizingSliverAppBar(title: 'locked_folder'.t(context: context)),
bottomSheet: const LockedFolderBottomSheet(),
),
),
);
}
}

View file

@ -0,0 +1,65 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map_settings_sheet.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class DriftMapPage extends StatelessWidget {
final LatLng? initialLocation;
const DriftMapPage({super.key, this.initialLocation});
void onSettingsPressed(BuildContext context) {
showModalBottomSheet(
elevation: 0.0,
showDragHandle: true,
isScrollControlled: true,
context: context,
builder: (_) => const DriftMapSettingsSheet(),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
body: Stack(
children: [
DriftMap(initialLocation: initialLocation),
Positioned(
left: 20,
top: 70,
child: IconButton.filled(
color: Colors.white,
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(8),
backgroundColor: Colors.indigo,
shadowColor: Colors.black26,
elevation: 4,
),
),
),
Positioned(
right: 20,
top: 70,
child: IconButton.filled(
color: Colors.white,
onPressed: () => onSettingsPressed(context),
icon: const Icon(Icons.more_vert_rounded),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(8),
backgroundColor: Colors.indigo,
shadowColor: Colors.black26,
elevation: 4,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,343 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.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/memory.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
/// Expects [currentAssetNotifier] to be set before navigating to this page
@RoutePage()
class DriftMemoryPage extends HookConsumerWidget {
final List<DriftMemory> memories;
final int memoryIndex;
const DriftMemoryPage({required this.memories, required this.memoryIndex, super.key});
static void setMemory(WidgetRef ref, DriftMemory memory) {
if (memory.assets.isNotEmpty) {
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
if (memory.assets.first.isVideo) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentMemory = useState(memories[memoryIndex]);
final currentAssetPage = useState(0);
final currentMemoryIndex = useState(memoryIndex);
final assetProgress = useState("${currentAssetPage.value + 1}|${currentMemory.value.assets.length}");
const bgColor = Colors.black;
final currentAsset = useState<RemoteAsset?>(null);
/// The list of all of the asset page controllers
final memoryAssetPageControllers = List.generate(memories.length, (i) => usePageController());
/// The main vertically scrolling page controller with each list of memories
final memoryPageController = usePageController(initialPage: memoryIndex);
useEffect(() {
// Memories is an immersive activity
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return () {
// Clean up to normal edge to edge when we are done
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
};
});
toNextMemory() {
memoryPageController.nextPage(duration: const Duration(milliseconds: 500), curve: Curves.easeIn);
}
void toPreviousMemory() {
if (currentMemoryIndex.value > 0) {
// Move to the previous memory page
memoryPageController.previousPage(duration: const Duration(milliseconds: 500), curve: Curves.easeIn);
// Wait for the next frame to ensure the page is built
SchedulerBinding.instance.addPostFrameCallback((_) {
final previousIndex = currentMemoryIndex.value - 1;
final previousMemoryController = memoryAssetPageControllers[previousIndex];
// Ensure the controller is attached
if (previousMemoryController.hasClients) {
previousMemoryController.jumpToPage(memories[previousIndex].assets.length - 1);
} else {
// Wait for the next frame until it is attached
SchedulerBinding.instance.addPostFrameCallback((_) {
if (previousMemoryController.hasClients) {
previousMemoryController.jumpToPage(memories[previousIndex].assets.length - 1);
}
});
}
});
}
}
toNextAsset(int currentAssetIndex) {
if (currentAssetIndex + 1 < currentMemory.value.assets.length) {
// Go to the next asset
PageController controller = memoryAssetPageControllers[currentMemoryIndex.value];
controller.nextPage(curve: Curves.easeInOut, duration: const Duration(milliseconds: 500));
} else {
// Go to the next memory since we are at the end of our assets
toNextMemory();
}
}
toPreviousAsset(int currentAssetIndex) {
if (currentAssetIndex > 0) {
// Go to the previous asset
PageController controller = memoryAssetPageControllers[currentMemoryIndex.value];
controller.previousPage(curve: Curves.easeInOut, duration: const Duration(milliseconds: 500));
} else {
// Go to the previous memory since we are at the end of our assets
toPreviousMemory();
}
}
updateProgressText() {
assetProgress.value = "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
}
/// Downloads and caches the image for the asset at this [currentMemory]'s index
precacheAsset(int index) async {
// Guard index out of range
if (index < 0) {
return;
}
// Context might be removed due to popping out of Memory Lane during Scroll handling
if (!context.mounted) {
return;
}
late RemoteAsset asset;
if (index < currentMemory.value.assets.length) {
// Uses the next asset in this current memory
asset = currentMemory.value.assets[index];
} else {
// Precache the first asset in the next memory if available
final currentMemoryIndex = memories.indexOf(currentMemory.value);
// Guard no memory found
if (currentMemoryIndex == -1) {
return;
}
final nextMemoryIndex = currentMemoryIndex + 1;
// Guard no next memory
if (nextMemoryIndex >= memories.length) {
return;
}
// Get the first asset from the next memory
asset = memories[nextMemoryIndex].assets.first;
}
// Precache the asset
final size = MediaQuery.sizeOf(context);
await precacheImage(getFullImageProvider(asset, size: Size(size.width, size.height)), context, size: size);
}
// Precache the next page right away if we are on the first page
if (currentAssetPage.value == 0) {
Future.delayed(const Duration(milliseconds: 200)).then((_) => precacheAsset(1));
}
Future<void> onAssetChanged(int otherIndex) async {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
currentAssetPage.value = otherIndex;
updateProgressText();
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// And then precache the next asset
await precacheAsset(otherIndex + 1);
final asset = currentMemory.value.assets[otherIndex];
currentAsset.value = asset;
ref.read(currentAssetNotifier.notifier).setAsset(asset);
// if (asset.isVideo || asset.isMotionPhoto) {
if (asset.isVideo) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
}
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
* when the page in the **center** of the viewer changes. We want to reset currentAssetPage only when the final
* page during the end of scroll is different than the current page
*/
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
// Calculate OverScroll manually using the number of pixels away from maxScrollExtent
// maxScrollExtend contains the sum of horizontal pixels of all assets for depth = 1
// or sum of vertical pixels of all memories for depth = 0
if (notification is ScrollUpdateNotification) {
final isEpiloguePage = (memoryPageController.page?.floor() ?? 0) >= memories.length;
final offset = notification.metrics.pixels;
if (isEpiloguePage && (offset > notification.metrics.maxScrollExtent + 150)) {
context.maybePop();
return true;
}
}
return false;
},
child: Scaffold(
backgroundColor: bgColor,
body: SafeArea(
child: PageView.builder(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
scrollDirection: Axis.vertical,
controller: memoryPageController,
onPageChanged: (pageNumber) {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
if (pageNumber < memories.length) {
currentMemoryIndex.value = pageNumber;
currentMemory.value = memories[pageNumber];
WidgetsBinding.instance.addPostFrameCallback((_) {
DriftMemoryPage.setMemory(ref, memories[pageNumber]);
});
}
currentAssetPage.value = 0;
updateProgressText();
},
itemCount: memories.length + 1,
itemBuilder: (context, mIndex) {
// Build last page
if (mIndex == memories.length) {
return MemoryEpilogue(
onStartOver: () => memoryPageController.animateToPage(
0,
duration: const Duration(seconds: 1),
curve: Curves.easeInOut,
),
);
}
final yearsAgo = DateTime.now().year - memories[mIndex].data.year;
final title = 'years_ago'.t(context: context, args: {'years': yearsAgo.toString()});
// Build horizontal page
final assetController = memoryAssetPageControllers[mIndex];
return Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 24.0, right: 24.0, top: 8.0, bottom: 2.0),
child: AnimatedBuilder(
animation: assetController,
builder: (context, child) {
double value = 0.0;
if (assetController.hasClients) {
// We can only access [page] if this has clients
value = assetController.page ?? 0;
}
return MemoryProgressIndicator(
ticks: memories[mIndex].assets.length,
value: (value + 1) / memories[mIndex].assets.length,
);
},
),
),
Expanded(
child: Stack(
children: [
PageView.builder(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
controller: assetController,
onPageChanged: onAssetChanged,
scrollDirection: Axis.horizontal,
itemCount: memories[mIndex].assets.length,
itemBuilder: (context, index) {
final asset = memories[mIndex].assets[index];
return Stack(
children: [
Container(
color: Colors.black,
child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0),
),
Positioned.fill(
child: Row(
children: [
// Left side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toPreviousAsset(index);
},
),
),
// Right side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toNextAsset(index);
},
),
),
],
),
),
],
);
},
),
Positioned(
top: 8,
left: 8,
child: MaterialButton(
minWidth: 0,
onPressed: () {
// auto_route doesn't invoke pop scope, so
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
context.maybePop();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
elevation: 0,
child: const Icon(Icons.close_rounded, color: Colors.white),
),
),
if (currentAsset.value != null && currentAsset.value!.isVideo)
Positioned(
bottom: 24,
right: 32,
child: Icon(Icons.videocam_outlined, color: Colors.grey[200]),
),
],
),
),
DriftMemoryBottomInfo(memory: memories[mIndex], title: title),
],
);
},
),
),
),
);
}
}

View file

@ -0,0 +1,119 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@RoutePage()
class DriftPartnerDetailPage extends StatelessWidget {
final PartnerUserDto partner;
const DriftPartnerDetailPage({super.key, required this.partner});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).remoteAssets(partner.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(title: partner.name, icon: Icons.person_outline),
topSliverWidget: _InfoBox(partner: partner),
topSliverWidgetHeight: 110,
bottomSheet: const PartnerDetailBottomSheet(),
),
);
}
}
class _InfoBox extends ConsumerStatefulWidget {
final PartnerUserDto partner;
const _InfoBox({required this.partner});
@override
ConsumerState<_InfoBox> createState() => _InfoBoxState();
}
class _InfoBoxState extends ConsumerState<_InfoBox> {
bool _inTimeline = false;
@override
void initState() {
super.initState();
_inTimeline = widget.partner.inTimeline;
}
_toggleInTimeline() async {
final user = ref.read(currentUserProvider);
if (user == null) {
return;
}
try {
await ref.read(partnerUsersProvider.notifier).toggleShowInTimeline(widget.partner.id, user.id);
setState(() {
_inTimeline = !_inTimeline;
});
} catch (error, stack) {
dPrint(() => "Failed to toggle in timeline: $error $stack");
ImmichToast.show(
context: context,
toastType: ToastType.error,
durationInSecond: 1,
msg: "Failed to toggle the timeline setting",
);
return;
}
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: SizedBox(
height: 110,
child: Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(10), context.colorScheme.primary.withAlpha(15)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
title: Text(
"Show in timeline",
style: context.textTheme.titleSmall?.copyWith(color: context.colorScheme.primary),
),
subtitle: Text(
"Show photos and videos from this user in your timeline",
style: context.textTheme.bodyMedium,
),
trailing: Switch(value: _inTimeline, onChanged: (_) => _toggleInTimeline()),
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,130 @@
import 'package:auto_route/auto_route.dart';
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/infrastructure/people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@RoutePage()
class DriftPeopleCollectionPage extends ConsumerStatefulWidget {
const DriftPeopleCollectionPage({super.key});
@override
ConsumerState<DriftPeopleCollectionPage> createState() => _DriftPeopleCollectionPageState();
}
class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectionPage> {
final FocusNode _formFocus = FocusNode();
String? _search;
@override
void dispose() {
_formFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final people = ref.watch(driftGetAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final isPortrait = context.orientation == Orientation.portrait;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: _search == null,
title: _search != null
? SearchField(
focusNode: _formFocus,
onTapOutside: (_) => _formFocus.unfocus(),
onChanged: (value) => setState(() => _search = value),
filled: true,
hintText: 'filter_people'.tr(),
autofocus: true,
)
: Text('people'.tr()),
actions: [
IconButton(
icon: Icon(_search != null ? Icons.close : Icons.search),
onPressed: () {
setState(() => _search = _search == null ? '' : null);
},
),
],
),
body: SafeArea(
child: people.when(
data: (people) {
if (_search != null) {
people = people.where((person) {
return person.name.toLowerCase().contains(_search!.toLowerCase());
}).toList();
}
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isTablet ? 6 : 3,
childAspectRatio: 0.85,
mainAxisSpacing: isPortrait && isTablet ? 36 : 0,
),
padding: const EdgeInsets.symmetric(vertical: 32),
itemCount: people.length,
itemBuilder: (context, index) {
final person = people[index];
return Column(
children: [
GestureDetector(
onTap: () {
context.pushRoute(DriftPersonRoute(person: person));
},
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
),
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => showNameEditModal(context, person),
child: person.name.isEmpty
? Text(
'add_a_name'.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.primary,
),
)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
person.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
),
),
],
);
},
);
},
error: (error, stack) => const Text("error"),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
},
);
}
}

View file

@ -0,0 +1,98 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_option_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/widgets/common/person_sliver_app_bar.dart';
@RoutePage()
class DriftPersonPage extends ConsumerStatefulWidget {
final DriftPerson person;
const DriftPersonPage({super.key, required this.person});
@override
ConsumerState<DriftPersonPage> createState() => _DriftPersonPageState();
}
class _DriftPersonPageState extends ConsumerState<DriftPersonPage> {
late DriftPerson _person;
@override
initState() {
super.initState();
_person = widget.person;
}
Future<void> handleEditName(BuildContext context) async {
final newName = await showNameEditModal(context, _person);
if (newName != null && newName.isNotEmpty) {
setState(() {
_person = _person.copyWith(name: newName);
});
}
}
Future<void> handleEditBirthday(BuildContext context) async {
final birthday = await showBirthdayEditModal(context, _person);
if (birthday != null) {
setState(() {
_person = _person.copyWith(birthDate: birthday);
});
}
}
void showOptionSheet(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: context.colorScheme.surface,
isScrollControlled: false,
builder: (context) {
return PersonOptionSheet(
onEditName: () async {
await handleEditName(context);
context.pop();
},
onEditBirthday: () async {
await handleEditBirthday(context);
context.pop();
},
birthdayExists: _person.birthDate != null,
);
},
);
}
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to view person timeline');
}
final timelineService = ref.watch(timelineFactoryProvider).person(user.id, _person.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: PersonSliverAppBar(
person: _person,
onNameTap: () => handleEditName(context),
onBirthdayTap: () => handleEditBirthday(context),
onShowOptions: () => showOptionSheet(context),
),
),
);
}
}

View file

@ -0,0 +1,175 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class DriftPlacePage extends StatelessWidget {
const DriftPlacePage({super.key, this.currentLocation});
final LatLng? currentLocation;
@override
Widget build(BuildContext context) {
final ValueNotifier<String?> search = ValueNotifier(null);
return Scaffold(
body: ValueListenableBuilder(
valueListenable: search,
builder: (context, searchValue, child) {
return CustomScrollView(
slivers: [
_PlaceSliverAppBar(search: search),
_Map(search: search, currentLocation: currentLocation),
_PlaceList(search: search),
],
);
},
),
);
}
}
class _PlaceSliverAppBar extends HookWidget {
const _PlaceSliverAppBar({required this.search});
final ValueNotifier<String?> search;
@override
Widget build(BuildContext context) {
final searchFocusNode = useFocusNode();
return SliverAppBar(
floating: true,
pinned: true,
snap: false,
backgroundColor: context.colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
automaticallyImplyLeading: search.value == null,
centerTitle: true,
title: search.value != null
? SearchField(
focusNode: searchFocusNode,
onTapOutside: (_) => searchFocusNode.unfocus(),
onChanged: (value) => search.value = value,
filled: true,
hintText: 'filter_places'.t(context: context),
autofocus: true,
)
: Text('places'.t(context: context)),
actions: [
IconButton(
icon: Icon(search.value != null ? Icons.close : Icons.search),
onPressed: () {
search.value = search.value == null ? '' : null;
},
),
],
);
}
}
class _Map extends StatelessWidget {
const _Map({required this.search, this.currentLocation});
final ValueNotifier<String?> search;
final LatLng? currentLocation;
@override
Widget build(BuildContext context) {
return search.value == null
? SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverToBoxAdapter(
child: SizedBox(
height: 200,
width: context.width,
child: MapThumbnail(
onTap: (_, __) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)),
zoom: 8,
centre: currentLocation ?? const LatLng(21.44950, -157.91959),
showAttribution: false,
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
)
: const SliverToBoxAdapter(child: SizedBox.shrink());
}
}
class _PlaceList extends ConsumerWidget {
const _PlaceList({required this.search});
final ValueNotifier<String?> search;
@override
Widget build(BuildContext context, WidgetRef ref) {
final places = ref.watch(placesProvider);
return places.when(
loading: () => const SliverToBoxAdapter(
child: Center(
child: Padding(padding: EdgeInsets.all(20.0), child: CircularProgressIndicator()),
),
),
error: (error, stack) => SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
'Error loading places: $error, stack: $stack',
style: TextStyle(color: context.colorScheme.error),
),
),
),
),
data: (places) {
if (search.value != null) {
places = places.where((place) {
return place.$1.toLowerCase().contains(search.value!.toLowerCase());
}).toList();
}
return SliverList.builder(
itemCount: places.length,
itemBuilder: (context, index) {
final place = places[index];
return _PlaceTile(place: place);
},
);
},
);
}
}
class _PlaceTile extends StatelessWidget {
const _PlaceTile({required this.place});
final (String, String) place;
@override
Widget build(BuildContext context) {
return LargeLeadingTile(
onTap: () => context.pushRoute(DriftPlaceDetailRoute(place: place.$1)),
title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: SizedBox(
width: 80,
height: 80,
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover, thumbhash: ""),
),
),
);
}
}

View file

@ -0,0 +1,29 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftPlaceDetailPage extends StatelessWidget {
final String place;
const DriftPlaceDetailPage({super.key, required this.place});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).place(place);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(title: place, icon: Icons.location_on),
),
);
}
}

View file

@ -0,0 +1,32 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftRecentlyTakenPage extends StatelessWidget {
const DriftRecentlyTakenPage({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access recently taken');
}
final timelineService = ref.watch(timelineFactoryProvider).remoteAssets(user.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(appBar: MesmerizingSliverAppBar(title: 'recently_taken'.t())),
);
}
}

View file

@ -0,0 +1,437 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
@RoutePage()
class RemoteAlbumPage extends ConsumerStatefulWidget {
final RemoteAlbum album;
const RemoteAlbumPage({super.key, required this.album});
@override
ConsumerState<RemoteAlbumPage> createState() => _RemoteAlbumPageState();
}
class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
late RemoteAlbum _album;
@override
void initState() {
super.initState();
_album = widget.album;
}
Future<void> addAssets(BuildContext context) async {
final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(_album.id);
final newAssets = await context.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()),
);
if (newAssets == null || newAssets.isEmpty) {
return;
}
final added = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(
_album.id,
newAssets.map((asset) {
final remoteAsset = asset as RemoteAsset;
return remoteAsset.id;
}).toList(),
);
if (added > 0) {
ImmichToast.show(
context: context,
msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}),
toastType: ToastType.success,
);
}
}
Future<void> addUsers(BuildContext context) async {
final newUsers = await context.pushRoute<List<String>>(DriftUserSelectionRoute(album: _album));
if (newUsers == null || newUsers.isEmpty) {
return;
}
try {
await ref.read(remoteAlbumProvider.notifier).addUsers(_album.id, newUsers);
if (newUsers.isNotEmpty) {
ImmichToast.show(
context: context,
msg: "users_added_to_album_count".t(context: context, args: {'count': newUsers.length}),
toastType: ToastType.success,
);
}
ref.invalidate(remoteAlbumSharedUsersProvider(_album.id));
} catch (e) {
ImmichToast.show(
context: context,
msg: "Failed to add users to album: ${e.toString()}",
toastType: ToastType.error,
);
}
}
Future<void> toggleAlbumOrder() async {
await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder(_album.id);
ref.invalidate(timelineServiceProvider);
}
Future<void> deleteAlbum(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('delete_album'.t(context: context)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('album_delete_confirmation'.t(context: context, args: {'album': _album.name})),
const SizedBox(height: 8),
Text('album_delete_confirmation_description'.t(context: context)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.t(context: context)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: Text('delete_album'.t(context: context)),
),
],
);
},
);
if (confirmed == true) {
try {
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(_album.id);
ImmichToast.show(
context: context,
msg: 'album_deleted'.t(context: context),
toastType: ToastType.success,
);
unawaited(context.pushRoute(const DriftAlbumsRoute()));
} catch (e) {
ImmichToast.show(
context: context,
msg: 'album_viewer_appbar_share_err_delete'.t(context: context),
toastType: ToastType.error,
);
}
}
}
Future<void> showEditTitleAndDescription(BuildContext context) async {
final result = await showDialog<_EditAlbumData?>(
context: context,
barrierDismissible: true,
builder: (context) => _EditAlbumDialog(album: _album),
);
if (result != null && context.mounted) {
setState(() {
_album = _album.copyWith(name: result.name, description: result.description ?? '');
});
unawaited(HapticFeedback.mediumImpact());
}
}
Future<void> showActivity(BuildContext context) async {
unawaited(context.pushRoute(DriftActivitiesRoute(album: _album)));
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false;
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
currentRemoteAlbumScopedProvider.overrideWithValue(_album),
],
child: Timeline(
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
kebabMenu: _AlbumKebabMenu(
album: _album,
onDeleteAlbum: () => deleteAlbum(context),
onAddUsers: () => addUsers(context),
onAddPhotos: () => addAssets(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditAlbum: () => showEditTitleAndDescription(context),
onCreateSharedLink: () => unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id))),
onShowOptions: () => context.pushRoute(DriftAlbumOptionsRoute(album: _album)),
),
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
onActivity: () => showActivity(context),
),
bottomSheet: RemoteAlbumBottomSheet(album: _album),
),
);
}
}
class _EditAlbumData {
final String name;
final String? description;
const _EditAlbumData({required this.name, this.description});
}
class _EditAlbumDialog extends ConsumerStatefulWidget {
final RemoteAlbum album;
const _EditAlbumDialog({required this.album});
@override
ConsumerState<_EditAlbumDialog> createState() => _EditAlbumDialogState();
}
class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
late final TextEditingController titleController;
late final TextEditingController descriptionController;
final formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
titleController = TextEditingController(text: widget.album.name);
descriptionController = TextEditingController(
text: widget.album.description.isEmpty ? '' : widget.album.description,
);
}
@override
void dispose() {
titleController.dispose();
descriptionController.dispose();
super.dispose();
}
Future<void> _handleSave() async {
if (formKey.currentState?.validate() != true) return;
try {
final newTitle = titleController.text.trim();
final newDescription = descriptionController.text.trim();
await ref
.read(remoteAlbumProvider.notifier)
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
if (mounted) {
Navigator.of(
context,
).pop(_EditAlbumData(name: newTitle, description: newDescription.isEmpty ? null : newDescription));
}
} catch (e) {
if (mounted) {
ImmichToast.show(
context: context,
msg: 'album_update_error'.t(context: context),
toastType: ToastType.error,
);
}
}
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.all(24),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
child: SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxWidth: 550),
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.edit_outlined, color: context.colorScheme.primary, size: 24),
const SizedBox(width: 12),
Text('edit_album'.t(context: context), style: context.textTheme.titleMedium),
],
),
const SizedBox(height: 24),
// Album Name
Text(
'album_name'.t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
TextFormField(
controller: titleController,
maxLines: 1,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
filled: true,
fillColor: context.colorScheme.surface,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'album_name_required'.t(context: context);
}
return null;
},
),
const SizedBox(height: 18),
// Description
Text(
'description'.t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
TextFormField(
controller: descriptionController,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
filled: true,
fillColor: context.colorScheme.surface,
),
),
const SizedBox(height: 24),
// Action Buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: Text('cancel'.t(context: context)),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _handleSave,
child: Text('save'.t(context: context)),
),
],
),
],
),
),
),
),
);
}
}
class _AlbumKebabMenu extends ConsumerWidget {
final RemoteAlbum album;
final VoidCallback? onDeleteAlbum;
final VoidCallback? onAddUsers;
final VoidCallback? onAddPhotos;
final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum;
final VoidCallback? onCreateSharedLink;
final VoidCallback? onShowOptions;
const _AlbumKebabMenu({
required this.album,
this.onDeleteAlbum,
this.onAddUsers,
this.onAddPhotos,
this.onToggleAlbumOrder,
this.onEditAlbum,
this.onCreateSharedLink,
this.onShowOptions,
});
double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) {
if (settings?.maxExtent == null || settings?.minExtent == null) {
return 1.0;
}
final deltaExtent = settings!.maxExtent - settings.minExtent;
if (deltaExtent <= 0.0) {
return 1.0;
}
return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent).clamp(0.0, 1.0);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
final iconColor = Color.lerp(Colors.white, context.primaryColor, scrollProgress);
final iconShadows = [
if (scrollProgress < 0.95)
Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
else
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
final user = ref.watch(currentUserProvider);
final isOwner = user != null && user.id == album.ownerId;
return FutureBuilder<bool>(
future: ref
.read(remoteAlbumServiceProvider)
.getUserRole(album.id, user?.id ?? '')
.then((role) => role == AlbumUserRole.editor),
builder: (context, snapshot) {
final canAddPhotos = snapshot.data ?? false;
return DriftRemoteAlbumOption(
iconColor: iconColor,
iconShadows: iconShadows,
onDeleteAlbum: isOwner ? onDeleteAlbum : null,
onAddUsers: isOwner ? onAddUsers : null,
onAddPhotos: isOwner || canAddPhotos ? onAddPhotos : null,
onToggleAlbumOrder: isOwner ? onToggleAlbumOrder : null,
onEditAlbum: isOwner ? onEditAlbum : null,
onCreateSharedLink: isOwner ? onCreateSharedLink : null,
onShowOptions: onShowOptions,
);
},
);
}
}

View file

@ -0,0 +1,56 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class DriftTrashPage extends StatelessWidget {
const DriftTrashPage({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access trash');
}
final timelineService = ref.watch(timelineFactoryProvider).trash(user.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: SliverAppBar(
title: Text('trash'.t(context: context)),
floating: true,
snap: true,
pinned: true,
centerTitle: true,
elevation: 0,
),
topSliverWidgetHeight: 24,
topSliverWidget: Consumer(
builder: (context, ref, child) {
final trashDays = ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays));
return SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverToBoxAdapter(
child: const Text("trash_page_info").t(context: context, args: {"days": "$trashDays"}),
),
);
},
),
bottomSheet: const TrashBottomBar(),
),
);
}
}

View file

@ -0,0 +1,160 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
// TODO: Refactor this provider when we have user provider/service/repository pattern in place
final driftUsersProvider = FutureProvider.autoDispose<List<UserDto>>((ref) async {
final drift = ref.watch(driftProvider);
final currentUser = ref.watch(currentUserProvider);
final userEntities = await drift.managers.userEntity.get();
final users = userEntities
.map(
(entity) => UserDto(
id: entity.id,
name: entity.name,
email: entity.email,
isPartnerSharedBy: false,
isPartnerSharedWith: false,
avatarColor: entity.avatarColor,
memoryEnabled: true,
inTimeline: true,
profileChangedAt: entity.profileChangedAt,
hasProfileImage: entity.hasProfileImage,
),
)
.toList();
users.removeWhere((u) => currentUser?.id == u.id);
return users;
});
@RoutePage()
class DriftUserSelectionPage extends HookConsumerWidget {
final RemoteAlbum album;
const DriftUserSelectionPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<List<UserDto>> suggestedShareUsers = ref.watch(driftUsersProvider);
final sharedUsersList = useState<Set<UserDto>>({});
addNewUsersHandler() {
context.maybePop(sharedUsersList.value.map((e) => e.id).toList());
}
buildTileIcon(UserDto user) {
if (sharedUsersList.value.contains(user)) {
return CircleAvatar(backgroundColor: context.primaryColor, child: const Icon(Icons.check_rounded, size: 25));
} else {
return UserCircleAvatar(user: user);
}
}
buildUserList(List<UserDto> users) {
List<Widget> usersChip = [];
for (var user in sharedUsersList.value) {
usersChip.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Chip(
backgroundColor: context.primaryColor.withValues(alpha: 0.15),
label: Text(user.name, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
),
),
);
}
return ListView(
children: [
Wrap(children: [...usersChip]),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'suggestions'.tr(),
style: const TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
),
),
ListView.builder(
primary: false,
shrinkWrap: true,
itemBuilder: ((context, index) {
return ListTile(
leading: buildTileIcon(users[index]),
dense: true,
title: Text(users[index].name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
subtitle: Text(users[index].email, style: const TextStyle(fontSize: 12)),
onTap: () {
if (sharedUsersList.value.contains(users[index])) {
sharedUsersList.value = sharedUsersList.value
.where((selectedUser) => selectedUser.id != users[index].id)
.toSet();
} else {
sharedUsersList.value = {...sharedUsersList.value, users[index]};
}
},
);
}),
itemCount: users.length,
),
],
);
}
return Scaffold(
appBar: AppBar(
title: const Text('invite_to_album').tr(),
elevation: 0,
centerTitle: false,
leading: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
context.maybePop(null);
},
),
actions: [
TextButton(
onPressed: sharedUsersList.value.isEmpty ? null : addNewUsersHandler,
child: const Text("add", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
],
),
body: suggestedShareUsers.widgetWhen(
onData: (users) {
// Get shared users for this album from the database
final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id));
return sharedUsers.when(
data: (albumSharedUsers) {
// Filter out users that are already shared with this album and the owner
final filteredUsers = users.where((user) {
return !albumSharedUsers.any((sharedUser) => sharedUser.id == user.id) && user.id != album.ownerId;
}).toList();
return buildUserList(filteredUsers);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) {
// If we can't load shared users, just filter out the owner
final filteredUsers = users.where((user) => user.id != album.ownerId).toList();
return buildUserList(filteredUsers);
},
);
},
),
);
}
}

View file

@ -0,0 +1,32 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftVideoPage extends StatelessWidget {
const DriftVideoPage({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to video');
}
final timelineService = ref.watch(timelineFactoryProvider).video(user.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(appBar: MesmerizingSliverAppBar(title: 'videos'.t())),
);
}
}

View file

@ -0,0 +1,179 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'package:immich_ui/immich_ui.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to crop an image and then navigate to the [EditImagePage] with the
/// cropped image.
@RoutePage()
class DriftCropImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftCropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final cropController = useCropController();
final aspectRatio = useState<double?>(null);
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: const ImmichCloseButton(),
actions: [
ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(controller: cropController, image: image, gridColor: Colors.white),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImmichIconButton(
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: () => cropController.rotateLeft(),
),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: () => cropController.rotateRight(),
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
],
),
),
),
),
],
);
},
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
final ValueNotifier<double?> aspectRatio;
final double? ratio;
final String label;
const _AspectRatioButton({
required this.cropController,
required this.aspectRatio,
required this.ratio,
required this.label,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
onPressed: () {
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}

View file

@ -0,0 +1,171 @@
import 'dart:async';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
/// A stateless widget that provides functionality for editing an image.
///
/// This widget allows users to edit an image provided either as an [Asset] or
/// directly as an [Image]. It ensures that exactly one of these is provided.
///
/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone
/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server.
@immutable
@RoutePage()
class DriftEditImagePage extends ConsumerWidget {
final BaseAsset asset;
final Image image;
final bool isEdited;
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
Future<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> completer = Completer();
image.image
.resolve(const ImageConfiguration())
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
} else {
completer.completeError('Failed to convert image to bytes');
}
});
}, onError: (exception, stackTrace) => completer.completeError(exception)),
);
return completer.future;
}
void _exitEditing(BuildContext context) {
// this assumes that the only way to get to this page is from the AssetViewerRoute
context.navigator.popUntil((route) => route.data?.name == AssetViewerRoute.name);
}
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
LocalAsset? localAsset;
try {
localAsset = await ref
.read(fileMediaRepositoryProvider)
.saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg");
} on PlatformException catch (e) {
// OS might not return the saved image back, so we handle that gracefully
// This can happen if app does not have full library access
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
}
unawaited(ref.read(backgroundSyncProvider).syncLocal(full: true));
_exitEditing(context);
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
if (localAsset == null) {
return;
}
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken());
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text("edit".tr()),
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
onPressed: () => _exitEditing(context),
),
actions: <Widget>[
TextButton(
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(7)),
child: Image(image: image.image, fit: BoxFit.contain),
),
),
),
),
bottomNavigationBar: Container(
height: 70,
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(30)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftCropImageRoute(asset: asset, image: image));
},
),
Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftFilterImageRoute(asset: asset, image: image));
},
),
Text("filter".tr(), style: context.textTheme.displayMedium),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/constants/filters.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
/// A widget for filtering an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to add filters to an image and then navigate to the [EditImagePage] with the
/// final composition.'
@RoutePage()
class DriftFilterImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftFilterImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
final completer = Completer<ui.Image>();
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..colorFilter = filter;
canvas.drawImage(inputImage, Offset.zero, paint);
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
completer.complete(image);
});
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
completer.complete(info.image);
}),
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
return Image.memory(pngBytes, fit: BoxFit.contain);
}
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("filter".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final filteredImage = await applyFilterAndConvert(colorFilter.value);
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true)));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Column(
children: [
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
),
);
},
),
),
],
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.image,
required this.label,
required this.filter,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: context.themeData.textTheme.bodyMedium),
],
);
}
}

View file

@ -0,0 +1,33 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class LocalTimelinePage extends StatelessWidget {
final LocalAlbum album;
const LocalTimelinePage({super.key, required this.album});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).localAlbum(albumId: album.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(title: album.name),
bottomSheet: const LocalAlbumBottomSheet(),
showStorageIndicator: true,
),
);
}
}

View file

@ -0,0 +1,854 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/feature_check.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
import 'package:immich_mobile/widgets/search/search_filter/star_rating_picker.dart';
@RoutePage()
class DriftSearchPage extends HookConsumerWidget {
const DriftSearchPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final textSearchType = useState<TextSearchType>(TextSearchType.context);
final searchHintText = useState<String>('sunrise_on_the_beach'.t(context: context));
final textSearchController = useTextEditingController();
final preFilter = ref.watch(searchPreFilterProvider);
final filter = useState<SearchFilter>(
SearchFilter(
people: preFilter?.people ?? {},
location: preFilter?.location ?? SearchLocationFilter(),
camera: preFilter?.camera ?? SearchCameraFilter(),
date: preFilter?.date ?? SearchDateFilter(),
display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: preFilter?.rating ?? SearchRatingFilter(),
mediaType: preFilter?.mediaType ?? AssetType.other,
language: "${context.locale.languageCode}-${context.locale.countryCode}",
assetId: preFilter?.assetId,
),
);
final previousFilter = useState<SearchFilter?>(null);
final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(message, style: context.textTheme.labelLarge),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: context.colorScheme.onSurface,
);
}
searchFilter(SearchFilter filter) async {
if (filter.isEmpty) {
return;
}
if (preFilter == null && filter == previousFilter.value) {
return;
}
isSearching.value = true;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter);
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context)));
}
previousFilter.value = filter;
isSearching.value = false;
}
search() => searchFilter(filter.value);
loadMoreSearchResult() async {
isSearching.value = true;
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context)));
}
isSearching.value = false;
}
searchPreFilter() {
if (preFilter != null) {
Future.delayed(Duration.zero, () {
searchFilter(preFilter);
if (preFilter.location.city != null) {
locationCurrentFilterWidget.value = Text(preFilter.location.city!, style: context.textTheme.labelLarge);
}
});
}
}
useEffect(() {
Future.microtask(() => ref.invalidate(paginatedSearchProvider));
searchPreFilter();
return null;
}, [preFilter]);
showPeoplePicker() {
handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(people: value);
peopleCurrentFilterWidget.value = Text(
value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(people: {});
peopleCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FractionallySizedBox(
heightFactor: 0.8,
child: FilterBottomSheetScaffold(
title: 'search_filter_people_title'.t(context: context),
expanded: true,
onSearch: search,
onClear: handleClear,
child: PeoplePicker(onSelect: handleOnSelect, filter: filter.value.people),
),
),
);
}
showLocationPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(country: value['country'], city: value['city'], state: value['state']),
);
final locationText = <String>[];
if (value['country'] != null) {
locationText.add(value['country']!);
}
if (value['state'] != null) {
locationText.add(value['state']!);
}
if (value['city'] != null) {
locationText.add(value['city']!);
}
locationCurrentFilterWidget.value = Text(locationText.join(', '), style: context.textTheme.labelLarge);
}
handleClear() {
filter.value = filter.value.copyWith(location: SearchLocationFilter());
locationCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.t(context: context),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
padding: EdgeInsets.only(bottom: context.viewInsets.bottom),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: LocationPicker(onSelected: handleOnSelect, filter: filter.value.location),
),
),
),
),
);
}
showCameraPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(make: value['make'], model: value['model']),
);
cameraCurrentFilterWidget.value = Text(
'${value['make'] ?? ''} ${value['model'] ?? ''}',
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(camera: SearchCameraFilter());
cameraCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.t(context: context),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CameraPicker(onSelect: handleOnSelect, filter: filter.value.camera),
),
),
);
}
datePicked(DateFilterInputModel? selectedDate) {
dateInputFilter.value = selectedDate;
if (selectedDate == null) {
filter.value = filter.value.copyWith(date: SearchDateFilter());
dateRangeCurrentFilterWidget.value = null;
unawaited(search());
return;
}
final date = selectedDate.asDateTimeRange();
filter.value = filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
),
);
dateRangeCurrentFilterWidget.value = Text(
selectedDate.asHumanReadable(context),
style: context.textTheme.labelLarge,
);
unawaited(search());
}
showDatePicker() async {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
var dateRange = DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
);
// datePicked() may increase the date, this will make the date picker fail an assertion
// Fixup the end date to be at most now.
if (dateRange.end.isAfter(lastDate)) {
dateRange = DateTimeRange(start: dateRange.start, end: lastDate);
}
final date = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
currentDate: DateTime.now(),
initialDateRange: dateRange,
helpText: 'search_filter_date_title'.t(context: context),
cancelText: 'cancel'.t(context: context),
confirmText: 'select'.t(context: context),
saveText: 'save'.t(context: context),
errorFormatText: 'invalid_date_format'.t(context: context),
errorInvalidText: 'invalid_date'.t(context: context),
fieldStartHintText: 'start_date'.t(context: context),
fieldEndHintText: 'end_date'.t(context: context),
initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text,
);
if (date == null) {
datePicked(null);
} else {
datePicked(CustomDateFilter.fromRange(date));
}
}
showQuickDatePicker() {
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: "pick_date_range".tr(),
expanded: true,
onClear: () => datePicked(null),
child: QuickDatePicker(
currentInput: dateInputFilter.value,
onRequestPicker: () {
context.pop();
showDatePicker();
},
onSelect: (date) {
context.pop();
datePicked(date);
},
),
),
);
}
// MEDIA PICKER
showMediaTypePicker() {
handleOnSelected(AssetType assetType) {
filter.value = filter.value.copyWith(mediaType: assetType);
mediaTypeCurrentFilterWidget.value = Text(
assetType == AssetType.image
? 'image'.t(context: context)
: assetType == AssetType.video
? 'video'.t(context: context)
: 'all'.t(context: context),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(mediaType: AssetType.other);
mediaTypeCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_media_type_title'.t(context: context),
onSearch: search,
onClear: handleClear,
child: MediaTypePicker(onSelect: handleOnSelected, filter: filter.value.mediaType),
),
);
}
// STAR RATING PICKER
showStarRatingPicker() {
handleOnSelected(SearchRatingFilter rating) {
filter.value = filter.value.copyWith(rating: rating);
ratingCurrentFilterWidget.value = Text(
'rating_count'.t(args: {'count': rating.rating!}),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(rating: SearchRatingFilter(rating: null));
ratingCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FilterBottomSheetScaffold(
title: 'rating'.t(context: context),
onSearch: search,
onClear: handleClear,
child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating),
),
);
}
// DISPLAY OPTION
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
final filterText = <String>[];
value.forEach((key, value) {
switch (key) {
case DisplayOption.notInAlbum:
filter.value = filter.value.copyWith(display: filter.value.display.copyWith(isNotInAlbum: value));
if (value) {
filterText.add('search_filter_display_option_not_in_album'.t(context: context));
}
break;
case DisplayOption.archive:
filter.value = filter.value.copyWith(display: filter.value.display.copyWith(isArchive: value));
if (value) {
filterText.add('archive'.t(context: context));
}
break;
case DisplayOption.favorite:
filter.value = filter.value.copyWith(display: filter.value.display.copyWith(isFavorite: value));
if (value) {
filterText.add('favorite'.t(context: context));
}
break;
}
});
if (filterText.isEmpty) {
displayOptionCurrentFilterWidget.value = null;
return;
}
displayOptionCurrentFilterWidget.value = Text(filterText.join(', '), style: context.textTheme.labelLarge);
}
handleClear() {
filter.value = filter.value.copyWith(
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
);
displayOptionCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'display_options'.t(context: context),
onSearch: search,
onClear: handleClear,
child: DisplayOptionPicker(onSelect: handleOnSelect, filter: filter.value.display),
),
);
}
handleTextSubmitted(String value) {
switch (textSearchType.value) {
case TextSearchType.context:
filter.value = filter.value.copyWith(filename: '', context: value, description: '', ocr: '');
break;
case TextSearchType.filename:
filter.value = filter.value.copyWith(filename: value, context: '', description: '', ocr: '');
break;
case TextSearchType.description:
filter.value = filter.value.copyWith(filename: '', context: '', description: value, ocr: '');
break;
case TextSearchType.ocr:
filter.value = filter.value.copyWith(filename: '', context: '', description: '', ocr: value);
break;
}
search();
}
IconData getSearchPrefixIcon() => switch (textSearchType.value) {
TextSearchType.context => Icons.image_search_rounded,
TextSearchType.filename => Icons.abc_rounded,
TextSearchType.description => Icons.text_snippet_outlined,
TextSearchType.ocr => Icons.document_scanner_outlined,
};
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
automaticallyImplyLeading: true,
actions: [
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: MenuAnchor(
style: MenuStyle(
elevation: const WidgetStatePropertyAll(1),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
),
builder: (BuildContext context, MenuController controller, Widget? child) {
return IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert_rounded),
tooltip: 'show_text_search_menu'.tr(),
);
},
menuChildren: [
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.image_search_rounded),
title: Text(
'search_by_context'.t(context: context),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.context,
),
onPressed: () {
textSearchType.value = TextSearchType.context;
searchHintText.value = 'sunrise_on_the_beach'.t(context: context);
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.abc_rounded),
title: Text(
'search_filter_filename'.t(context: context),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.filename ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.filename,
),
onPressed: () {
textSearchType.value = TextSearchType.filename;
searchHintText.value = 'file_name_or_extension'.t(context: context);
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.text_snippet_outlined),
title: Text(
'search_by_description'.t(context: context),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.description ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.description,
),
onPressed: () {
textSearchType.value = TextSearchType.description;
searchHintText.value = 'search_by_description_example'.t(context: context);
},
),
FeatureCheck(
feature: (features) => features.ocr,
child: MenuItemButton(
child: ListTile(
leading: const Icon(Icons.document_scanner_outlined),
title: Text(
'search_by_ocr'.t(context: context),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.ocr,
),
onPressed: () {
textSearchType.value = TextSearchType.ocr;
searchHintText.value = 'search_by_ocr_example'.t(context: context);
},
),
),
],
),
),
],
title: Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(0), width: 0),
borderRadius: const BorderRadius.all(Radius.circular(24)),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withValues(alpha: 0.075),
context.colorScheme.primary.withValues(alpha: 0.09),
context.colorScheme.primary.withValues(alpha: 0.075),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SearchField(
hintText: searchHintText.value,
key: const Key('search_text_field'),
controller: textSearchController,
contentPadding: preFilter != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8),
prefixIcon: preFilter != null ? null : Icon(getSearchPrefixIcon(), color: context.colorScheme.primary),
onSubmitted: handleTextSubmitted,
focusNode: ref.watch(searchInputFocusProvider),
),
),
),
body: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.only(top: 12.0, bottom: 4.0),
sliver: SliverToBoxAdapter(
child: SizedBox(
height: 50,
child: ListView(
key: const Key('search_filter_chip_list'),
shrinkWrap: true,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SearchFilterChip(
icon: Icons.people_alt_outlined,
onTap: showPeoplePicker,
label: 'people'.t(context: context),
currentFilter: peopleCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.location_on_outlined,
onTap: showLocationPicker,
label: 'search_filter_location'.t(context: context),
currentFilter: locationCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.camera_alt_outlined,
onTap: showCameraPicker,
label: 'camera'.t(context: context),
currentFilter: cameraCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.date_range_outlined,
onTap: showQuickDatePicker,
label: 'search_filter_date'.t(context: context),
currentFilter: dateRangeCurrentFilterWidget.value,
),
SearchFilterChip(
key: const Key('media_type_chip'),
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
if (isRatingEnabled) ...[
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value,
),
],
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
label: 'search_filter_display_options'.t(context: context),
currentFilter: displayOptionCurrentFilterWidget.value,
),
],
),
),
),
),
if (isSearching.value)
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
else
_SearchResultGrid(onScrollEnd: loadMoreSearchResult),
],
),
);
}
}
class _SearchResultGrid extends ConsumerWidget {
final VoidCallback onScrollEnd;
const _SearchResultGrid({required this.onScrollEnd});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets));
if (assets.isEmpty) {
return const _SearchEmptyContent();
}
return NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical;
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
onScrollEnd();
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent);
}
return true;
},
child: SliverFillRemaining(
child: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
key: ValueKey(assets.length),
groupBy: GroupAssetsBy.none,
appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false,
initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)),
),
),
),
);
}
}
class _SearchEmptyContent extends StatelessWidget {
const _SearchEmptyContent();
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: ListView(
shrinkWrap: true,
children: [
const SizedBox(height: 40),
Center(
child: Image.asset(
context.isDarkTheme ? 'assets/polaroid-dark.png' : 'assets/polaroid-light.png',
height: 125,
),
),
const SizedBox(height: 16),
Center(
child: Text('search_page_search_photos_videos'.t(context: context), style: context.textTheme.labelLarge),
),
const SizedBox(height: 32),
const Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: _QuickLinkList()),
],
),
);
}
}
class _QuickLinkList extends StatelessWidget {
const _QuickLinkList();
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
border: Border.all(color: context.colorScheme.outline.withAlpha(10), width: 1),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
context.colorScheme.primary.withAlpha(20),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_QuickLink(
title: 'recently_taken'.t(context: context),
icon: Icons.schedule_outlined,
isTop: true,
onTap: () => context.pushRoute(const DriftRecentlyTakenRoute()),
),
_QuickLink(
title: 'videos'.t(context: context),
icon: Icons.play_circle_outline_rounded,
onTap: () => context.pushRoute(const DriftVideoRoute()),
),
_QuickLink(
title: 'favorites'.t(context: context),
icon: Icons.favorite_border_rounded,
isBottom: true,
onTap: () => context.pushRoute(const DriftFavoriteRoute()),
),
],
),
);
}
}
class _QuickLink extends StatelessWidget {
final String title;
final IconData icon;
final VoidCallback onTap;
final bool isTop;
final bool isBottom;
const _QuickLink({
required this.title,
required this.icon,
required this.onTap,
this.isTop = false,
this.isBottom = false,
});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.only(
topLeft: Radius.circular(isTop ? 20 : 0),
topRight: Radius.circular(isTop ? 20 : 0),
bottomLeft: Radius.circular(isBottom ? 20 : 0),
bottomRight: Radius.circular(isBottom ? 20 : 0),
);
return ListTile(
shape: RoundedRectangleBorder(borderRadius: borderRadius),
leading: Icon(icon, size: 26),
title: Text(title, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
onTap: onTap,
);
}
}

View file

@ -0,0 +1,60 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
final searchPreFilterProvider = NotifierProvider<SearchFilterProvider, SearchFilter?>(SearchFilterProvider.new);
class SearchFilterProvider extends Notifier<SearchFilter?> {
@override
SearchFilter? build() {
return null;
}
void setFilter(SearchFilter? filter) {
state = filter;
}
void clear() {
state = null;
}
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
final SearchService _searchService;
PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1));
Future<bool> search(SearchFilter filter) async {
if (state.nextPage == null) {
return false;
}
final result = await _searchService.search(filter, state.nextPage!);
if (result == null) {
return false;
}
state = SearchResult(
assets: [...state.assets, ...result.assets],
nextPage: result.nextPage,
scrollOffset: state.scrollOffset,
);
return true;
}
void setScrollOffset(double offset) {
state = state.copyWith(scrollOffset: offset);
}
clear() {
state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0);
}
}

View file

@ -0,0 +1,207 @@
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/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/providers/user.provider.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/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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/bottom_sheet/base_bottom_sheet.widget.dart';
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key, this.originalTheme});
final ThemeData? originalTheme;
@override
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
}
class _AddActionButtonState extends ConsumerState<AddActionButton> {
void _handleMenuSelection(AddToMenuItem selected) {
switch (selected) {
case AddToMenuItem.album:
_openAlbumSelector();
break;
case AddToMenuItem.archive:
performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
List<Widget> _buildMenuChildren() {
final asset = ref.read(currentAssetNotifier);
if (asset == null) return [];
final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final hasRemote = asset is RemoteAsset;
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium),
),
BaseActionButton(
iconData: Icons.photo_album_outlined,
label: "album".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.album),
),
if (isOwner) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
if (showArchive)
BaseActionButton(
iconData: Icons.archive_outlined,
label: "archive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
),
if (showUnarchive)
BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
),
BaseActionButton(
iconData: Icons.lock_outline,
label: "locked_folder".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
),
],
];
}
void _openAlbumSelector() {
final currentAsset = ref.read(currentAssetNotifier);
if (currentAsset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
}
final List<Widget> slivers = [
const CreateAlbumButton(),
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album)),
];
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) {
return BaseBottomSheet(
actions: const [],
slivers: slivers,
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.95,
expand: false,
backgroundColor: context.isDarkTheme ? Colors.black : Colors.white,
);
},
);
}
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
final latest = ref.read(currentAssetNotifier);
if (latest == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
}
final addedCount = await ref.read(remoteAlbumProvider.notifier).addAssets(album.id, [latest.remoteId!]);
if (!context.mounted) {
return;
}
if (addedCount == 0) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
}
if (!context.mounted) {
return;
}
await Navigator.of(context).maybePop();
}
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final themeData = widget.originalTheme ?? context.themeData;
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: widget.originalTheme != null
? [
Theme(
data: widget.originalTheme!,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _buildMenuChildren()),
),
]
: _buildMenuChildren(),
builder: (context, controller, child) {
return BaseActionButton(
iconData: Icons.add,
label: "add_to_bottom_bar".tr(),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}

View file

@ -0,0 +1,36 @@
import 'dart:async';
import 'package:flutter/material.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';
class AdvancedInfoActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const AdvancedInfoActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
unawaited(ref.read(actionProvider.notifier).troubleshoot(source, context));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 115.0,
iconData: Icons.help_outline_rounded,
label: "troubleshoot".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,57 @@
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/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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';
// used to allow performing archive action from different sources (without duplicating code)
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'archive_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,
);
}
}
class ArchiveActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performArchiveAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.archive_outlined,
label: "to_archive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class BaseActionButton extends ConsumerWidget {
const BaseActionButton({
super.key,
required this.label,
required this.iconData,
this.iconColor,
this.onPressed,
this.onLongPressed,
this.maxWidth = 90.0,
this.minWidth,
this.iconOnly = false,
this.menuItem = false,
});
final String label;
final IconData iconData;
final Color? iconColor;
final double maxWidth;
final double? minWidth;
/// When true, renders only an IconButton without text label
final bool iconOnly;
/// When true, renders as a MenuItemButton for use in MenuAnchor menus
final bool menuItem;
final void Function()? onPressed;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (iconOnly) {
return IconButton(
onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
);
}
if (menuItem) {
final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton(
padding: const EdgeInsets.all(10),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20))),
textColor: textColor,
onPressed: onPressed,
onLongPress: onLongPressed,
minWidth: miniWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(iconData, size: iconSize, color: iconColor),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400),
maxLines: 3,
textAlign: TextAlign.center,
softWrap: true,
),
],
),
),
);
}
}

View file

@ -0,0 +1,30 @@
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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastActionButton extends ConsumerWidget {
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
return BaseActionButton(
iconData: isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
iconColor: isCasting ? context.primaryColor : null, // null = default color
label: "cast".t(context: context),
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
iconOnly: iconOnly,
menuItem: menuItem,
);
}
}

View file

@ -0,0 +1,90 @@
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/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/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';
/// This delete action has the following behavior:
/// - Set the deletedAt information, put the asset in the trash in the server
/// which will be permanently deleted after the number of days configure by the admin
/// - Prompt to delete the asset locally
class DeleteActionButton extends ConsumerWidget {
final ActionSource source;
final bool showConfirmation;
final bool iconOnly;
final bool menuItem;
const DeleteActionButton({
super.key,
required this.source,
this.showConfirmation = false,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
if (showConfirmation) {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('delete'.t(context: context)),
content: Text('delete_action_confirmation_message'.t(context: context)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.t(context: context)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
'confirm'.t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
),
);
if (confirm != true) return;
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'delete_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(
maxWidth: 110.0,
iconData: Icons.delete_sweep_outlined,
label: "delete".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,65 @@
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/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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';
/// This delete action has the following behavior:
/// - Prompt to delete the asset locally
class DeleteLocalActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DeleteLocalActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).deleteLocal(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
if (result.count == 0) {
return;
}
final successMessage = 'delete_local_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(
maxWidth: 95.0,
iconData: Icons.no_cell_outlined,
label: "control_bottom_app_bar_delete_from_local".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,61 @@
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/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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';
/// This delete action has the following behavior:
/// - Delete permanently on the server
/// - Prompt to delete the asset locally
class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'delete_permanently_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(
maxWidth: 110.0,
iconData: Icons.delete_forever,
label: "delete_permanently".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,54 @@
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/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
/// - Delete permanently on the server
/// - Prompt to delete the asset locally
///
/// This action is used when the asset is selected in multi-selection mode in the trash page
class DeleteTrashActionButton extends ConsumerWidget {
final ActionSource source;
const DeleteTrashActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'assets_permanently_deleted_count'.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 TextButton.icon(
icon: Icon(Icons.delete_forever, color: Colors.red[400]),
label: Text(
"delete".t(context: context),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:immich_mobile/constants/enums.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.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/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class DownloadActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
if (!context.mounted) {
return;
}
try {
await ref.read(actionProvider.notifier).downloadAll(source);
Future.delayed(const Duration(seconds: 1), () async {
await backgroundSyncManager.syncLocal();
await backgroundSyncManager.hashAssets();
});
} finally {
ref.read(multiSelectProvider.notifier).reset();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final backgroundManager = ref.watch(backgroundSyncProvider);
return BaseActionButton(
iconData: Icons.download,
maxWidth: 95,
label: "download".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager),
);
}
}

View file

@ -0,0 +1,64 @@
import 'package:auto_route/auto_route.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/asset_viewer/download.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class DownloadStatusFloatingButton extends ConsumerWidget {
const DownloadStatusFloatingButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final shouldShow = ref.watch(downloadStateProvider.select((state) => state.showProgress));
final itemCount = ref.watch(downloadStateProvider.select((state) => state.taskProgress.length));
final isDownloading = ref
.watch(downloadStateProvider.select((state) => state.taskProgress))
.values
.where((element) => element.progress != 1)
.isNotEmpty;
return shouldShow
? Badge.count(
count: itemCount,
textColor: context.colorScheme.onPrimary,
backgroundColor: context.colorScheme.primary,
child: FloatingActionButton(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
),
backgroundColor: context.isDarkTheme
? context.colorScheme.surfaceContainer
: context.colorScheme.surfaceBright,
elevation: 2,
onPressed: () {
context.pushRoute(const DownloadInfoRoute());
},
child: Stack(
alignment: AlignmentDirectional.center,
children: [
isDownloading
? Icon(Icons.downloading_rounded, color: context.colorScheme.primary, size: 28)
: Icon(
Icons.download_done,
color: context.isDarkTheme ? Colors.green[200] : Colors.green[400],
size: 28,
),
if (isDownloading)
const SizedBox(
height: 31,
width: 31,
child: CircularProgressIndicator(
strokeWidth: 2,
backgroundColor: Colors.transparent,
value: null, // Indeterminate progress
),
),
],
),
),
)
: const SizedBox.shrink();
}
}

View file

@ -0,0 +1,52 @@
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 EditDateTimeActionButton extends ConsumerWidget {
final ActionSource source;
const EditDateTimeActionButton({super.key, required this.source});
_onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).editDateTime(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'edit_date_and_time_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(
maxWidth: 95.0,
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,32 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget {
const EditImageActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(currentAssetNotifier);
onPress() {
if (currentAsset == null) {
return;
}
final image = Image(image: getFullImageProvider(currentAsset));
context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, isEdited: false));
}
return BaseActionButton(
iconData: Icons.tune,
label: "edit".t(context: context),
onPressed: onPress,
);
}
}

View file

@ -0,0 +1,48 @@
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 EditLocationActionButton extends ConsumerWidget {
final ActionSource source;
const EditLocationActionButton({super.key, required this.source});
_onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).editLocation(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'edit_location_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.edit_location_alt_outlined,
label: "control_bottom_app_bar_edit_location".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,53 @@
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 FavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).favorite(source);
if (source == ActionSource.viewer) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'favorite_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.favorite_border_rounded,
label: "favorite".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,68 @@
import 'package:collection/collection.dart';
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/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.iconOnly = false, this.menuItem = false});
final bool iconOnly;
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.thumb_up : Icons.thumb_up_off_alt,
label: "like".t(context: context),
onPressed: () => onTap(liked),
iconOnly: iconOnly,
menuItem: menuItem,
);
},
// default to empty heart during loading
loading: () => BaseActionButton(
iconData: Icons.thumb_up_off_alt,
label: "like".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
),
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),
);
}
}

View file

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoActionButton extends ConsumerWidget {
const MotionPhotoActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPlaying = ref.watch(isPlayingMotionVideoProvider);
return BaseActionButton(
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
label: "play_motion_photo".t(context: context),
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
iconOnly: iconOnly,
menuItem: menuItem,
);
}
}

View file

@ -0,0 +1,61 @@
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/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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';
// Reusable helper: move to locked folder from any source (e.g called from menu)
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'move_to_lock_folder_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,
);
}
}
class MoveToLockFolderActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const MoveToLockFolderActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performMoveToLockFolderAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 115.0,
iconData: Icons.lock_outline_rounded,
label: "move_to_locked_folder".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,65 @@
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/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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 RemoveFromAlbumActionButton extends ConsumerWidget {
final String albumId;
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromAlbumActionButton({
super.key,
required this.albumId,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'remove_from_album_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.remove_circle_outline,
label: "remove_from_album".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}

View file

@ -0,0 +1,57 @@
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 RemoveFromLockFolderActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromLockFolderActionButton({
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).removeFromLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_lock_folder_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(
maxWidth: 100.0,
iconData: Icons.lock_open_rounded,
label: "remove_from_locked_folder".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,43 @@
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/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RestoreTrashActionButton extends ConsumerWidget {
final ActionSource source;
const RestoreTrashActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'assets_restored_count'.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 TextButton.icon(
icon: const Icon(Icons.history_rounded),
label: Text('restore'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,84 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
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/build_context_extensions.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 _SharePreparingDialog extends StatelessWidget {
const _SharePreparingDialog();
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()),
],
),
);
}
}
class ShareActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
await showDialog(
context: context,
builder: (BuildContext buildContext) {
ref.read(actionProvider.notifier).shareAssets(source, context).then((ActionResult result) {
ref.read(multiSelectProvider.notifier).reset();
if (!context.mounted) {
return;
}
if (!result.success) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
buildContext.pop();
});
// show a loading spinner with a "Preparing" message
return const _SharePreparingDialog();
},
barrierDismissible: false,
useRootNavigator: false,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
label: 'share'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,33 @@
import 'package:flutter/material.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';
class ShareLinkActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ShareLinkActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
_onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
await ref.read(actionProvider.notifier).shareLink(source, context);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.link_rounded,
label: "share_link".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,56 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/routing/router.dart';
class SimilarPhotosActionButton extends ConsumerWidget {
final String assetId;
final bool iconOnly;
final bool menuItem;
const SimilarPhotosActionButton({super.key, required this.assetId, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
ref.invalidate(assetViewerProvider);
ref
.read(searchPreFilterProvider.notifier)
.setFilter(
SearchFilter(
assetId: assetId,
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);
unawaited(context.navigateTo(const DriftSearchRoute()));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.compare,
label: "view_similar_photos".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}

View file

@ -0,0 +1,50 @@
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 {
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),
);
}
}

View file

@ -0,0 +1,58 @@
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/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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';
/// This delete action has the following behavior:
/// - Set the deletedAt information, put the asset in the trash in the server
/// which will be permanently deleted after the number of days configure by the admin
class TrashActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const TrashActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'trash_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(
maxWidth: 85.0,
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_trash_from_immich".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,59 @@
// dart
// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart`
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';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
// used to allow performing unarchive action from different sources (without duplicating code)
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'unarchive_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,
);
}
}
class UnArchiveActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performUnArchiveAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,53 @@
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 UnFavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).unFavorite(source);
if (source == ActionSource.viewer) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unfavorite_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.favorite_rounded,
label: "unfavorite".t(context: context),
onPressed: () => _onTap(context, ref),
iconOnly: iconOnly,
menuItem: menuItem,
);
}
}

View file

@ -0,0 +1,48 @@
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;
final bool iconOnly;
final bool menuItem;
const UnStackActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
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.layers_clear_outlined,
label: "unstack".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,112 @@
import 'dart:async';
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/domain/models/asset/base_asset.model.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/backup/asset_upload_progress.provider.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';
import 'package:immich_ui/immich_ui.dart';
class UploadActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UploadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final isTimeline = source == ActionSource.timeline;
List<LocalAsset>? assets;
if (source == ActionSource.timeline) {
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
if (assets.isEmpty) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
} else {
unawaited(
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => const _UploadProgressDialog(),
),
);
}
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
if (!isTimeline && context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
if (context.mounted && !result.success) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.backup_outlined,
label: "upload".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
class _UploadProgressDialog extends ConsumerWidget {
const _UploadProgressDialog();
@override
Widget build(BuildContext context, WidgetRef ref) {
final progressMap = ref.watch(assetUploadProgressProvider);
// Calculate overall progress from all assets
final values = progressMap.values.where((v) => v >= 0).toList();
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
final hasError = progressMap.values.any((v) => v < 0);
final percentage = (progress * 100).toInt();
return AlertDialog(
title: Text('uploading'.t(context: context)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasError)
const Icon(Icons.error_outline, color: Colors.red, size: 48)
else
CircularProgressIndicator(value: progress > 0 ? progress : null),
const SizedBox(height: 16),
Text(hasError ? 'Error' : '$percentage%'),
],
),
actions: [
ImmichTextButton(
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.cancel();
Navigator.of(context).pop();
},
labelText: 'cancel'.t(context: context),
),
],
);
}
}

View file

@ -0,0 +1,860 @@
import 'dart:async';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
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/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
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/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:sliver_tools/sliver_tools.dart';
typedef AlbumSelectorCallback = void Function(RemoteAlbum album);
class AlbumSelector extends ConsumerStatefulWidget {
final AlbumSelectorCallback onAlbumSelected;
final Function? onKeyboardExpanded;
const AlbumSelector({super.key, required this.onAlbumSelected, this.onKeyboardExpanded});
@override
ConsumerState<AlbumSelector> createState() => _AlbumSelectorState();
}
class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
bool isGrid = false;
final searchController = TextEditingController();
final menuController = MenuController();
final searchFocusNode = FocusNode();
List<RemoteAlbum> sortedAlbums = [];
List<RemoteAlbum> shownAlbums = [];
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final appSettings = ref.read(appSettingsServiceProvider);
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
final albumSortMode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == savedSortMode,
orElse: () => AlbumSortMode.lastModified,
);
setState(() {
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
isGrid = savedIsGrid;
});
ref.read(remoteAlbumProvider.notifier).refresh();
});
searchController.addListener(() {
onSearch(searchController.text, filter.mode);
});
searchFocusNode.addListener(() {
if (searchFocusNode.hasFocus) {
widget.onKeyboardExpanded?.call();
}
});
}
void onSearch(String searchTerm, QuickFilterMode filterMode) {
final userId = ref.watch(currentUserProvider)?.id;
filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode);
filterAlbums();
}
Future<void> onRefresh() async {
await ref.read(remoteAlbumProvider.notifier).refresh();
}
void toggleViewMode() {
setState(() {
isGrid = !isGrid;
});
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
}
void changeFilter(QuickFilterMode mode) {
setState(() {
filter = filter.copyWith(mode: mode);
});
filterAlbums();
}
Future<void> changeSort(AlbumSort sort) async {
setState(() {
this.sort = sort;
});
final appSettings = ref.read(appSettingsServiceProvider);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
await sortAlbums();
}
void clearSearch() {
setState(() {
filter = filter.copyWith(mode: QuickFilterMode.all, query: null);
searchController.clear();
});
filterAlbums();
}
Future<void> sortAlbums() async {
final sorted = await ref
.read(remoteAlbumProvider.notifier)
.sortAlbums(ref.read(remoteAlbumProvider).albums, sort.mode, isReverse: sort.isReverse);
if (!mounted) {
return;
}
setState(() {
sortedAlbums = sorted;
});
// we need to re-filter the albums after sorting
// so shownAlbums gets updated
unawaited(filterAlbums());
}
Future<void> filterAlbums() async {
if (filter.query == null) {
if (!mounted) {
return;
}
setState(() {
shownAlbums = sortedAlbums;
});
return;
}
final filteredAlbums = ref
.read(remoteAlbumProvider.notifier)
.searchAlbums(sortedAlbums, filter.query!, filter.userId, filter.mode);
if (!mounted) {
return;
}
setState(() {
shownAlbums = filteredAlbums;
});
}
@override
void dispose() {
searchController.dispose();
searchFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final userId = ref.watch(currentUserProvider)?.id;
// refilter and sort when albums change
ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async {
await sortAlbums();
});
return PopScope(
onPopInvokedWithResult: (didPop, _) {
menuController.close();
},
child: MultiSliver(
children: [
_SearchBar(
searchController: searchController,
searchFocusNode: searchFocusNode,
onSearch: onSearch,
filterMode: filter.mode,
onClearSearch: clearSearch,
),
_QuickFilterButtonRow(
filterMode: filter.mode,
onChangeFilter: changeFilter,
onSearch: onSearch,
searchController: searchController,
),
_QuickSortAndViewMode(
isGrid: isGrid,
onToggleViewMode: toggleViewMode,
onSortChanged: changeSort,
controller: menuController,
currentSortMode: sort.mode,
currentIsReverse: sort.isReverse,
),
isGrid
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
: _AlbumList(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected),
],
),
);
}
}
class _SortButton extends ConsumerStatefulWidget {
const _SortButton(
this.onSortChanged, {
required this.initialSortMode,
required this.initialIsReverse,
this.controller,
});
final Future<void> Function(AlbumSort) onSortChanged;
final MenuController? controller;
final AlbumSortMode initialSortMode;
final bool initialIsReverse;
@override
ConsumerState<_SortButton> createState() => _SortButtonState();
}
class _SortButtonState extends ConsumerState<_SortButton> {
late AlbumSortMode albumSortOption;
late bool albumSortIsReverse;
bool isSorting = false;
@override
void initState() {
super.initState();
albumSortOption = widget.initialSortMode;
albumSortIsReverse = widget.initialIsReverse;
}
@override
void didUpdateWidget(_SortButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialSortMode != widget.initialSortMode || oldWidget.initialIsReverse != widget.initialIsReverse) {
setState(() {
albumSortOption = widget.initialSortMode;
albumSortIsReverse = widget.initialIsReverse;
});
}
}
Future<void> onMenuTapped(AlbumSortMode sortMode) async {
final selected = albumSortOption == sortMode;
// Switch direction
if (selected) {
setState(() {
albumSortIsReverse = !albumSortIsReverse;
isSorting = true;
});
} else {
setState(() {
albumSortOption = sortMode;
isSorting = true;
});
}
await widget.onSortChanged.call(AlbumSort(mode: albumSortOption, isReverse: albumSortIsReverse));
setState(() {
isSorting = false;
});
}
@override
Widget build(BuildContext context) {
return MenuAnchor(
controller: widget.controller,
style: MenuStyle(
elevation: const WidgetStatePropertyAll(1),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
),
consumeOutsideTap: true,
menuChildren: AlbumSortMode.values
.map(
(sortMode) => MenuItemButton(
leadingIcon: albumSortOption == sortMode
? albumSortIsReverse
? Icon(
Icons.keyboard_arrow_down,
color: albumSortOption == sortMode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
)
: Icon(
Icons.keyboard_arrow_up_rounded,
color: albumSortOption == sortMode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
)
: const Icon(Icons.abc, color: Colors.transparent),
onPressed: () => onMenuTapped(sortMode),
style: ButtonStyle(
padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(12, 12, 24, 12)),
backgroundColor: WidgetStateProperty.all(
albumSortOption == sortMode ? context.colorScheme.primary : Colors.transparent,
),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
),
child: Text(
sortMode.label.t(context: context),
style: context.textTheme.labelLarge?.copyWith(
color: albumSortOption == sortMode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface.withAlpha(185),
),
),
),
)
.toList(),
builder: (context, controller, child) {
return GestureDetector(
onTap: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: albumSortIsReverse
? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface)
: Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface),
),
Text(
albumSortOption.label.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(225)),
),
isSorting
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colorScheme.onSurface.withAlpha(225),
),
)
: const SizedBox.shrink(),
],
),
);
},
);
}
}
class _SearchBar extends StatelessWidget {
const _SearchBar({
required this.searchController,
required this.searchFocusNode,
required this.onSearch,
required this.filterMode,
required this.onClearSearch,
});
final TextEditingController searchController;
final FocusNode searchFocusNode;
final void Function(String, QuickFilterMode) onSearch;
final QuickFilterMode filterMode;
final VoidCallback onClearSearch;
@override
Widget build(BuildContext context) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
sliver: SliverToBoxAdapter(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(0), width: 0),
borderRadius: const BorderRadius.all(Radius.circular(24)),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withValues(alpha: 0.075),
context.colorScheme.primary.withValues(alpha: 0.09),
context.colorScheme.primary.withValues(alpha: 0.075),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
transform: const GradientRotation(0.5 * pi),
),
),
child: SearchField(
autofocus: false,
contentPadding: const EdgeInsets.all(16),
hintText: 'search_albums'.tr(),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: searchController.text.isNotEmpty
? IconButton(icon: const Icon(Icons.clear_rounded), onPressed: onClearSearch)
: null,
controller: searchController,
onChanged: (_) => onSearch(searchController.text, filterMode),
focusNode: searchFocusNode,
onTapOutside: (_) => searchFocusNode.unfocus(),
),
),
),
);
}
}
class _QuickFilterButtonRow extends StatelessWidget {
const _QuickFilterButtonRow({
required this.filterMode,
required this.onChangeFilter,
required this.onSearch,
required this.searchController,
});
final QuickFilterMode filterMode;
final void Function(QuickFilterMode) onChangeFilter;
final void Function(String, QuickFilterMode) onSearch;
final TextEditingController searchController;
@override
Widget build(BuildContext context) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
_QuickFilterButton(
label: 'all'.tr(),
isSelected: filterMode == QuickFilterMode.all,
onTap: () {
onChangeFilter(QuickFilterMode.all);
onSearch(searchController.text, QuickFilterMode.all);
},
),
_QuickFilterButton(
label: 'shared_with_me'.tr(),
isSelected: filterMode == QuickFilterMode.sharedWithMe,
onTap: () {
onChangeFilter(QuickFilterMode.sharedWithMe);
onSearch(searchController.text, QuickFilterMode.sharedWithMe);
},
),
_QuickFilterButton(
label: 'my_albums'.tr(),
isSelected: filterMode == QuickFilterMode.myAlbums,
onTap: () {
onChangeFilter(QuickFilterMode.myAlbums);
onSearch(searchController.text, QuickFilterMode.myAlbums);
},
),
],
),
),
);
}
}
class _QuickFilterButton extends StatelessWidget {
const _QuickFilterButton({required this.isSelected, required this.onTap, required this.label});
final bool isSelected;
final VoidCallback onTap;
final String label;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onTap,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(isSelected ? context.colorScheme.primary : Colors.transparent),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(color: context.colorScheme.onSurface.withAlpha(25), width: 1),
),
),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
fontSize: 14,
),
),
);
}
}
class _QuickSortAndViewMode extends StatelessWidget {
const _QuickSortAndViewMode({
required this.isGrid,
required this.onToggleViewMode,
required this.onSortChanged,
required this.currentSortMode,
required this.currentIsReverse,
this.controller,
});
final bool isGrid;
final VoidCallback onToggleViewMode;
final MenuController? controller;
final Future<void> Function(AlbumSort) onSortChanged;
final AlbumSortMode currentSortMode;
final bool currentIsReverse;
@override
Widget build(BuildContext context) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_SortButton(
onSortChanged,
controller: controller,
initialSortMode: currentSortMode,
initialIsReverse: currentIsReverse,
),
IconButton(
icon: Icon(
isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined,
size: 24,
color: context.colorScheme.onSurface,
),
onPressed: onToggleViewMode,
),
],
),
),
);
}
}
class _AlbumList extends ConsumerWidget {
const _AlbumList({required this.albums, required this.userId, required this.onAlbumSelected});
final List<RemoteAlbum> albums;
final String? userId;
final AlbumSelectorCallback onAlbumSelected;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (albums.isEmpty) {
return SliverToBoxAdapter(
child: Center(
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('album_search_not_found'.tr())),
),
);
}
return SliverPadding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 64),
sliver: SliverList.builder(
itemBuilder: (_, index) {
final album = albums[index];
final isOwner = album.ownerId == userId;
if (isOwner) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Dismissible(
key: ValueKey(album.id),
background: Container(
color: context.colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: Icon(Icons.delete, color: context.colorScheme.onError),
),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) {
return showDialog<bool>(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => true,
title: "delete_album".t(context: context),
content: "album_delete_confirmation".t(context: context, args: {'album': album.name}),
ok: "delete".t(context: context),
),
);
},
onDismissed: (direction) async {
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id);
},
child: AlbumTile(album: album, isOwner: isOwner, onAlbumSelected: onAlbumSelected),
),
);
} else {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: AlbumTile(album: album, isOwner: isOwner, onAlbumSelected: onAlbumSelected),
);
}
},
itemCount: albums.length,
),
);
}
}
class _AlbumGrid extends StatelessWidget {
const _AlbumGrid({required this.albums, required this.userId, required this.onAlbumSelected});
final List<RemoteAlbum> albums;
final String? userId;
final AlbumSelectorCallback onAlbumSelected;
@override
Widget build(BuildContext context) {
if (albums.isEmpty) {
return SliverToBoxAdapter(
child: Center(
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('album_search_not_found'.tr())),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate((context, index) {
final album = albums[index];
return _GridAlbumCard(album: album, userId: userId, onAlbumSelected: onAlbumSelected);
}, childCount: albums.length),
),
);
}
}
class _GridAlbumCard extends ConsumerWidget {
const _GridAlbumCard({required this.album, required this.userId, required this.onAlbumSelected});
final RemoteAlbum album;
final String? userId;
final AlbumSelectorCallback onAlbumSelected;
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return GestureDetector(
onTap: () => onAlbumSelected(album),
child: Card(
elevation: 0,
color: context.colorScheme.surfaceBright,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
side: BorderSide(color: context.colorScheme.onSurface.withAlpha(25), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
child: SizedBox(
width: double.infinity,
child: FutureBuilder(
future: albumThumbnailAsset,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Thumbnail.remote(
remoteId: album.thumbnailAssetId!,
thumbhash: snapshot.data!.thumbHash ?? "",
);
}
return Container(
color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
);
},
),
),
),
),
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
album.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
Text(
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
),
),
),
],
),
),
);
}
}
class AddToAlbumHeader extends ConsumerWidget {
const AddToAlbumHeader({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> onCreateAlbum() async {
final newAlbum = await ref
.read(remoteAlbumProvider.notifier)
.createAlbum(
title: "Untitled Album",
assetIds: ref.read(multiSelectProvider).selectedAssets.map((e) => (e as RemoteAsset).id).toList(),
);
if (newAlbum == null) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());
return;
}
ref.read(multiSelectProvider.notifier).reset();
unawaited(context.pushRoute(RemoteAlbumRoute(album: newAlbum)));
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("add_to_album", style: context.textTheme.titleSmall).tr(),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), // remove internal padding
minimumSize: const Size(0, 0), // allow shrinking
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // remove extra height
),
onPressed: onCreateAlbum,
icon: Icon(Icons.add, color: context.primaryColor),
label: Text(
"common_create_new_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
],
),
),
);
}
}
class CreateAlbumButton extends ConsumerWidget {
const CreateAlbumButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> onCreateAlbum() async {
var albumName = await showDialog<String?>(context: context, builder: (context) => const NewAlbumNameModal());
if (albumName == null) {
return;
}
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
}
final album = await ref
.read(remoteAlbumProvider.notifier)
.createAlbum(title: albumName, assetIds: [asset.remoteId!]);
if (album == null) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());
return;
}
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(asset.remoteId!));
context.pop();
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("add_to_album", style: context.textTheme.titleSmall).tr(),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onCreateAlbum,
icon: Icon(Icons.add, color: context.primaryColor),
label: Text(
"common_create_new_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
],
),
),
);
}
}

View file

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
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/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
class AlbumTile extends ConsumerWidget {
const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected});
final RemoteAlbum album;
final bool isOwner;
final Function(RemoteAlbum)? onAlbumSelected;
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return LargeLeadingTile(
title: Text(
album.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${isOwner ? 'owned'.t(context: context) : 'shared_by_user'.t(context: context, args: {'user': album.ownerName})}',
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onTap: () => onAlbumSelected?.call(album),
leadingPadding: const EdgeInsets.only(right: 16),
leading: FutureBuilder(
future: albumThumbnailAsset,
builder: (context, snapshot) {
return snapshot.hasData && snapshot.data != null
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(
width: 80,
height: 80,
child: Thumbnail.remote(
remoteId: album.thumbnailAssetId!,
thumbhash: snapshot.data!.thumbHash ?? "",
),
),
)
: SizedBox(
width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
);
},
),
);
}
}

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 bool isBottomSheet;
final String? likeId;
final Function(String) onSubmit;
final Function()? onKeyboardFocus;
const DriftActivityTextField({
required this.onSubmit,
this.isEnabled = true,
this.likeId,
this.onKeyboardFocus,
this.isBottomSheet = false,
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.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: EdgeInsets.symmetric(vertical: widget.isBottomSheet ? 0 : 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

@ -0,0 +1,53 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class NewAlbumNameModal extends StatefulWidget {
const NewAlbumNameModal({super.key});
@override
State<NewAlbumNameModal> createState() => _NewAlbumNameModalState();
}
class _NewAlbumNameModalState extends State<NewAlbumNameModal> {
TextEditingController nameController = TextEditingController();
@override
void dispose() {
nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("album_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SingleChildScrollView(
child: TextFormField(
controller: nameController,
textCapitalization: TextCapitalization.words,
autofocus: true,
decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()),
),
),
actions: [
TextButton(
onPressed: () => context.pop(null),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () {
context.pop(nameController.text.trim());
},
child: Text(
"create_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View file

@ -0,0 +1,85 @@
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/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/activities/comment_bubble.dart';
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.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';
class ActivitiesBottomSheet extends HookConsumerWidget {
final DraggableScrollableController controller;
final double initialChildSize;
final bool scrollToBottomInitially;
const ActivitiesBottomSheet({
required this.controller,
this.initialChildSize = 0.35,
this.scrollToBottomInitially = true,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider)!;
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
}
Widget buildActivitiesSliver() {
return activities.widgetWhen(
onLoading: () => const SliverToBoxAdapter(child: SizedBox.shrink()),
onData: (data) {
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == data.length) {
return const SizedBox.shrink();
}
final activity = data[data.length - 1 - index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: CommentBubble(activity: activity, isAssetActivity: true),
);
}, childCount: data.length + 1),
);
},
);
}
return BaseBottomSheet(
actions: [],
slivers: [buildActivitiesSliver()],
footer: Padding(
// TODO: avoid fixed padding, use context.padding.bottom
padding: const EdgeInsets.only(bottom: 32),
child: Column(
children: [
const Divider(indent: 16, endIndent: 16),
DriftActivityTextField(
isEnabled: album.isActivityEnabled,
isBottomSheet: true,
// likeId: likedId,
onSubmit: onAddComment,
),
],
),
),
controller: controller,
initialChildSize: initialChildSize,
minChildSize: 0.1,
maxChildSize: 0.88,
expand: false,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
);
}
}

View file

@ -0,0 +1,17 @@
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<List<RemoteAsset>, BaseAsset> {
@override
Future<List<RemoteAsset>> build(BaseAsset asset) {
if (asset is! RemoteAsset || asset.stackId == null) {
return Future.value(const []);
}
return ref.watch(assetServiceProvider).getStack(asset);
}
}
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset>(StackChildrenNotifier.new);

View file

@ -0,0 +1,118 @@
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/thumbnail.widget.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) {
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren == null || stackChildren.isEmpty) {
return const SizedBox.shrink();
}
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0;
return IgnorePointer(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: _StackList(stack: stackChildren),
),
);
}
}
class _StackList extends ConsumerWidget {
final List<RemoteAsset> stack;
const _StackList({required this.stack});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 5.0,
children: List.generate(stack.length, (i) {
final asset = stack[i];
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
}),
),
),
),
);
}
}
class _StackItem extends ConsumerStatefulWidget {
final RemoteAsset asset;
final int index;
const _StackItem({super.key, required this.asset, required this.index});
@override
ConsumerState<_StackItem> createState() => _StackItemState();
}
class _StackItemState extends ConsumerState<_StackItem> {
void _onTap() {
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
}
@override
Widget build(BuildContext context) {
const playIcon = Center(
child: 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))],
),
);
const selectedDecoration = BoxDecoration(
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
borderRadius: BorderRadius.all(Radius.circular(10)),
);
const unselectedDecoration = BoxDecoration(
border: Border.fromBorderSide(BorderSide(color: Colors.grey, width: 0.5)),
borderRadius: BorderRadius.all(Radius.circular(10)),
);
Widget thumbnail = Thumbnail.fromAsset(asset: widget.asset, size: const Size(60, 40));
if (widget.asset.isVideo) {
thumbnail = Stack(children: [thumbnail, playIcon]);
}
thumbnail = ClipRRect(borderRadius: const BorderRadius.all(Radius.circular(10)), child: thumbnail);
final isSelected = ref.watch(assetViewerProvider.select((s) => s.stackIndex == widget.index));
return SizedBox(
width: 60,
height: 40,
child: GestureDetector(
onTap: _onTap,
child: DecoratedBox(
decoration: isSelected ? selectedDecoration : unselectedDecoration,
position: DecorationPosition.foreground,
child: thumbnail,
),
),
);
}
}

View file

@ -0,0 +1,727 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/domain/models/events.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/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.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';
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.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/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
@RoutePage()
class AssetViewerPage extends StatelessWidget {
final int initialIndex;
final TimelineService timelineService;
final int? heroOffset;
final RemoteAlbum? currentAlbum;
const AssetViewerPage({
super.key,
required this.initialIndex,
required this.timelineService,
this.heroOffset,
this.currentAlbum,
});
@override
Widget build(BuildContext context) {
// This is necessary to ensure that the timeline service is available
// since the Timeline and AssetViewer are on different routes / Widget subtrees.
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWithValue(timelineService),
currentRemoteAlbumScopedProvider.overrideWithValue(currentAlbum),
],
child: AssetViewer(initialIndex: initialIndex, heroOffset: heroOffset),
);
}
}
class AssetViewer extends ConsumerStatefulWidget {
final int initialIndex;
final int? heroOffset;
const AssetViewer({super.key, required this.initialIndex, this.heroOffset});
@override
ConsumerState createState() => _AssetViewerState();
static void setAsset(WidgetRef ref, BaseAsset asset) {
ref.read(assetViewerProvider.notifier).reset();
_setAsset(ref, asset);
}
void changeAsset(WidgetRef ref, BaseAsset asset) {
_setAsset(ref, asset);
}
static void _setAsset(WidgetRef ref, BaseAsset asset) {
// 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();
ref.read(videoPlayerControlsProvider.notifier).pause();
}
// Hide controls by default for videos
if (asset.isVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
}
const double _kBottomSheetMinimumExtent = 0.4;
const double _kBottomSheetSnapExtent = 0.67;
class _AssetViewerState extends ConsumerState<AssetViewer> {
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
late PageController pageController;
late DraggableScrollableController bottomSheetController;
PersistentBottomSheetController? sheetCloseController;
// PhotoViewGallery takes care of disposing it's controllers
PhotoViewControllerBase? viewController;
StreamSubscription? reloadSubscription;
late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState;
bool? hasDraggedDown;
bool isSnapping = false;
bool blockGestures = false;
bool dragInProgress = false;
bool shouldPopOnDrag = false;
bool assetReloadRequested = false;
double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero;
int totalAssets = 0;
int stackIndex = 0;
BuildContext? scaffoldContext;
Map<String, GlobalKey> videoPlayerKeys = {};
// Delayed operations that should be cancelled on disposal
final List<Timer> _delayedOperations = [];
ImageStream? _prevPreCacheStream;
ImageStream? _nextPreCacheStream;
KeepAliveLink? _stackChildrenKeepAlive;
@override
void initState() {
super.initState();
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
pageController = PageController(initialPage: widget.initialIndex);
totalAssets = ref.read(timelineServiceProvider).totalAssets;
bottomSheetController = DraggableScrollableController();
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
reloadSubscription = EventStream.shared.listen(_onEvent);
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final asset = ref.read(currentAssetNotifier);
if (asset != null) {
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
if (ref.read(assetViewerProvider).showingControls) {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
} else {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
}
}
@override
void dispose() {
pageController.dispose();
bottomSheetController.dispose();
_cancelTimers();
reloadSubscription?.cancel();
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_stackChildrenKeepAlive?.close();
super.dispose();
}
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
Color get backgroundColor {
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
return Colors.black.withAlpha(opacity);
}
void _cancelTimers() {
for (final timer in _delayedOperations) {
timer.cancel();
}
_delayedOperations.clear();
}
double _getVerticalOffsetForBottomSheet(double extent) =>
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
ImageStream _precacheImage(BaseAsset asset) {
final provider = getFullImageProvider(asset, size: context.sizeData);
return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener);
}
void _precacheAssets(int index) {
final timelineService = ref.read(timelineServiceProvider);
unawaited(timelineService.preCacheAssets(index));
_cancelTimers();
// This will trigger the pre-caching of adjacent assets ensuring
// that they are ready when the user navigates to them.
final timer = Timer(Durations.medium4, () async {
// Check if widget is still mounted before proceeding
if (!mounted) return;
final (prevAsset, nextAsset) = await (
timelineService.getAssetAsync(index - 1),
timelineService.getAssetAsync(index + 1),
).wait;
if (!mounted) return;
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
_prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null;
_nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null;
});
_delayedOperations.add(timer);
}
void _onAssetInit(Duration _) {
_precacheAssets(widget.initialIndex);
_handleCasting();
}
void _onAssetChanged(int index) async {
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
if (asset == null) {
return;
}
widget.changeAsset(ref, asset);
_precacheAssets(index);
_handleCasting();
_stackChildrenKeepAlive?.close();
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
void _handleCasting() {
if (!ref.read(castProvider).isCasting) return;
final asset = ref.read(currentAssetNotifier);
if (asset == null) return;
// hide any casting snackbars if they exist
context.scaffoldMessenger.hideCurrentSnackBar();
// send image to casting if the server has it
if (asset is RemoteAsset) {
ref.read(castProvider.notifier).loadMedia(asset, false);
} else {
// casting cannot show local assets
context.scaffoldMessenger.clearSnackBars();
if (ref.read(castProvider).isCasting) {
ref.read(castProvider.notifier).stop();
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
"local_asset_cast_failed".tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
}
}
}
void _onPageBuild(PhotoViewControllerBase controller) {
viewController ??= controller;
if (showingBottomSheet && bottomSheetController.isAttached) {
final verticalOffset =
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing
controller.scale = (controller.scale ?? 1.0) + 0.01;
}
}
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index);
viewController = controller;
}
void _onDragStart(
_,
DragStartDetails details,
PhotoViewControllerBase controller,
PhotoViewScaleStateController scaleStateController,
) {
viewController = controller;
dragDownPosition = details.localPosition;
initialPhotoViewState = controller.value;
final isZoomed =
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
scaleStateController.scaleState == PhotoViewScaleState.covering;
if (!showingBottomSheet && isZoomed) {
blockGestures = true;
}
}
void _onDragEnd(BuildContext ctx, _, __) {
dragInProgress = false;
if (shouldPopOnDrag) {
// Dismiss immediately without state updates to avoid rebuilds
ctx.maybePop();
return;
}
// Do not reset the state if the bottom sheet is showing
if (showingBottomSheet) {
_snapBottomSheet();
return;
}
// If the gestures are blocked, do not reset the state
if (blockGestures) {
blockGestures = false;
return;
}
shouldPopOnDrag = false;
hasDraggedDown = null;
viewController?.animateMultiple(
position: initialPhotoViewState.position,
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
rotation: initialPhotoViewState.rotation,
);
ref.read(assetViewerProvider.notifier).setOpacity(255);
}
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
if (blockGestures) {
return;
}
dragInProgress = true;
final delta = details.localPosition - dragDownPosition;
hasDraggedDown ??= delta.dy > 0;
if (!hasDraggedDown! || showingBottomSheet) {
_handleDragUp(ctx, delta);
return;
}
_handleDragDown(ctx, delta);
}
void _handleDragUp(BuildContext ctx, Offset delta) {
const double openThreshold = 50;
final position = initialPhotoViewState.position + Offset(0, delta.dy);
final distanceToOrigin = position.distance;
viewController?.updateMultiple(position: position);
// Moves the bottom sheet when the asset is being dragged up
if (showingBottomSheet && bottomSheetController.isAttached) {
final centre = (ctx.height * _kBottomSheetMinimumExtent);
bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height);
}
if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) {
_openBottomSheet(ctx);
}
}
void _handleDragDown(BuildContext ctx, Offset delta) {
const double dragRatio = 0.2;
const double popThreshold = 75;
final distance = delta.distance;
shouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
final maxScaleDistance = ctx.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
double? updatedScale;
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
if (initialScale != null) {
updatedScale = initialScale * (1.0 - scaleReduction);
}
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
viewController?.updateMultiple(position: initialPhotoViewState.position + delta, scale: updatedScale);
ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity);
}
void _onTapDown(_, __, ___) {
if (!showingBottomSheet) {
ref.read(assetViewerProvider.notifier).toggleControls();
}
}
bool _onNotification(Notification delta) {
if (delta is DraggableScrollableNotification) {
_handleDraggableNotification(delta);
}
// Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after
// the isSnapping guard is to prevent the notification from recursively handling the
// notification, eventually resulting in a heap overflow
if (!isSnapping && delta is ScrollEndNotification) {
_snapBottomSheet();
}
return false;
}
void _handleDraggableNotification(DraggableScrollableNotification delta) {
final currentExtent = delta.extent;
final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down
if (isDraggingDown && delta.extent < 0.67) {
if (dragInProgress) {
blockGestures = true;
}
// Jump to a lower position before starting close animation to prevent glitch
if (bottomSheetController.isAttached) {
bottomSheetController.jumpTo(0.67);
}
sheetCloseController?.close();
}
// If the asset is being dragged down, we do not want to update the asset position again
if (dragInProgress) {
return;
}
final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent);
// Moves the asset when the bottom sheet is being dragged
if (verticalOffset > 0) {
viewController?.position = Offset(0, -verticalOffset);
}
}
void _onEvent(Event event) {
if (event is TimelineReloadEvent) {
_onTimelineReloadEvent();
return;
}
if (event is ViewerReloadAssetEvent) {
assetReloadRequested = true;
return;
}
if (event is ViewerOpenBottomSheetEvent) {
final extent = _kBottomSheetMinimumExtent + 0.3;
_openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode);
final offset = _getVerticalOffsetForBottomSheet(extent);
viewController?.position = Offset(0, -offset);
return;
}
}
void _onTimelineReloadEvent() {
totalAssets = ref.read(timelineServiceProvider).totalAssets;
if (totalAssets == 0) {
context.maybePop();
return;
}
if (assetReloadRequested) {
assetReloadRequested = false;
_onAssetReloadEvent();
return;
}
}
void _onAssetReloadEvent() async {
final index = pageController.page?.round() ?? 0;
final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) {
return;
}
final currentAsset = ref.read(currentAssetNotifier);
// Do not reload / close the bottom sheet if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) {
return;
}
setState(() {
_onAssetChanged(pageController.page!.round());
sheetCloseController?.close();
});
}
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2),
constraints: const BoxConstraints(maxWidth: double.infinity),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))),
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
builder: (_) {
return NotificationListener<Notification>(
onNotification: _onNotification,
child: activitiesMode
? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent)
: AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent),
);
},
);
sheetCloseController?.closed.then((_) => _handleSheetClose());
}
void _handleSheetClose() {
viewController?.animateMultiple(position: Offset.zero);
viewController?.updateMultiple(scale: viewController?.initialScale);
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
sheetCloseController = null;
shouldPopOnDrag = false;
hasDraggedDown = null;
}
void _snapBottomSheet() {
if (!bottomSheetController.isAttached ||
bottomSheetController.size > _kBottomSheetSnapExtent ||
bottomSheetController.size < 0.4) {
return;
}
isSnapping = true;
bottomSheetController.animateTo(_kBottomSheetSnapExtent, duration: Durations.short3, curve: Curves.easeOut);
}
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
return const Center(child: ImmichLoadingIndicator());
}
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
if (scaleState != PhotoViewScaleState.initial) {
if (!dragInProgress) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
if (!showingBottomSheet) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
}
void _onLongPress(_, __, ___) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
}
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx;
final timelineService = ref.read(timelineServiceProvider);
final asset = timelineService.getAssetSafe(index);
// If asset is not available in buffer, return a placeholder
if (asset == null) {
return PhotoViewGalleryPageOptions.customChild(
heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'),
child: Container(
width: ctx.width,
height: ctx.height,
color: backgroundColor,
child: const Center(child: CircularProgressIndicator()),
),
);
}
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
}
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
if (displayAsset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, displayAsset);
}
return _videoBuilder(ctx, displayAsset);
}
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
final size = ctx.sizeData;
return PhotoViewGalleryPageOptions(
key: ValueKey(asset.heroTag),
imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
tightMode: true,
disableScaleGestures: showingBottomSheet,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onTapDown: _onTapDown,
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => Container(
width: size.width,
height: size.height,
color: backgroundColor,
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
),
);
}
GlobalKey _getVideoPlayerKey(String id) {
videoPlayerKeys.putIfAbsent(id, () => GlobalKey());
return videoPlayerKeys[id]!;
}
PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onTapDown: _onTapDown,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center,
disableScaleGestures: true,
child: SizedBox(
width: ctx.width,
height: ctx.height,
child: NativeVideoViewer(
key: _getVideoPlayerKey(asset.heroTag),
asset: asset,
image: Image(
key: ValueKey(asset),
image: getFullImageProvider(asset, size: ctx.sizeData),
fit: BoxFit.contain,
height: ctx.height,
width: ctx.width,
alignment: Alignment.center,
),
),
),
);
}
void _onPop<T>(bool didPop, T? result) {
ref.read(currentAssetNotifier.notifier).dispose();
}
@override
Widget build(BuildContext context) {
// Rebuild the widget when the asset viewer state changes
// 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);
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
// Listen for casting changes and send initial asset to the cast provider
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async {
if (!isCasting) return;
final asset = ref.read(currentAssetNotifier);
if (asset == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleCasting();
});
});
// Listen for control visibility changes and change system UI mode accordingly
ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async {
if (showingControls) {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
} else {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
}
});
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
// Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable
return PopScope(
onPopInvokedWithResult: _onPop,
child: Scaffold(
backgroundColor: backgroundColor,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
),
body: Stack(
children: [
PhotoViewGallery.builder(
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: CurrentPlatform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,
onPageChanged: _onPageChanged,
onPageBuild: _onPageBuild,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
if (!showingBottomSheet)
const Positioned(
bottom: 0,
left: 0,
right: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [AssetStackRow(), ViewerBottomBar()],
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,115 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
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,
);
}
@override
String toString() {
return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other.runtimeType != runtimeType) return false;
return other is AssetViewerState &&
other.backgroundOpacity == backgroundOpacity &&
other.showingBottomSheet == showingBottomSheet &&
other.showingControls == showingControls &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex;
}
@override
int get hashCode =>
backgroundOpacity.hashCode ^
showingBottomSheet.hashCode ^
showingControls.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode;
}
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@override
AssetViewerState build() {
return const AssetViewerState();
}
void reset() {
state = const AssetViewerState();
}
void setAsset(BaseAsset? asset) {
if (asset == state.currentAsset) {
return;
}
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
void setOpacity(int opacity) {
if (opacity == state.backgroundOpacity) {
return;
}
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls);
}
void setBottomSheet(bool showing) {
if (showing == state.showingBottomSheet) {
return;
}
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls);
if (showing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
}
}
void setControls(bool isShowing) {
if (isShowing == state.showingControls) {
return;
}
state = state.copyWith(showingControls: isShowing);
}
void toggleControls() {
state = state.copyWith(showingControls: !state.showingControls);
}
void setStackIndex(int index) {
if (index == state.stackIndex) {
return;
}
state = state.copyWith(stackIndex: index);
}
}
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);

View file

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/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/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_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';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
class ViewerBottomBar extends ConsumerWidget {
const ViewerBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final isInLockedView = ref.watch(inLockedViewProvider);
if (!showControls) {
opacity = 0;
}
final originalTheme = context.themeData;
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
];
return IgnorePointer(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: AnimatedSwitcher(
duration: Durations.short4,
child: isSheetOpen
? const SizedBox.shrink()
: Theme(
data: context.themeData.copyWith(
iconTheme: const IconThemeData(size: 22, color: Colors.white),
textTheme: context.themeData.textTheme.copyWith(
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
),
),
child: Container(
color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (asset.isVideo) const VideoControls(),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,409 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = '';
class AssetDetailBottomSheet extends ConsumerWidget {
final DraggableScrollableController? controller;
final double initialChildSize;
const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
return BaseBottomSheet(
actions: [],
slivers: const [_AssetDetailBottomSheet()],
controller: controller,
initialChildSize: initialChildSize,
minChildSize: 0.1,
maxChildSize: 0.88,
expand: false,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
);
}
}
class _AssetDetailBottomSheet extends ConsumerWidget {
const _AssetDetailBottomSheet();
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
DateTime dateTime = asset.createdAt.toLocal();
Duration timeZoneOffset = dateTime.timeZoneOffset;
// Use EXIF timezone information if available (matching web app behavior)
if (exifInfo?.dateTimeOriginal != null) {
(dateTime, timeZoneOffset) = applyTimezoneOffset(
dateTime: exifInfo!.dateTimeOriginal!,
timeZone: exifInfo.timeZone,
);
}
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
return '$date$_kSeparator$time $timezone';
}
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
final height = asset.height;
final width = asset.width;
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null;
final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
return switch ((fileSize, resolution)) {
(null, null) => '',
(String fileSize, null) => fileSize,
(null, String resolution) => resolution,
(String fileSize, String resolution) => '$fileSize$_kSeparator$resolution',
};
}
String? _getCameraInfoTitle(ExifInfo? exifInfo) {
if (exifInfo == null) {
return null;
}
return switch ((exifInfo.make, exifInfo.model)) {
(null, null) => null,
(String make, null) => make,
(null, String model) => model,
(String make, String model) => '$make $model',
};
}
String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
if (exifInfo == null) {
return null;
}
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
}
String? _getLensInfoSubtitle(ExifInfo? exifInfo) {
if (exifInfo == null) {
return null;
}
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
}
Future<void> _editDateTime(BuildContext context, WidgetRef ref) async {
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
}
Widget _buildAppearsInList(WidgetRef ref, BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
if (!asset.hasRemote) {
return const SizedBox.shrink();
}
String? remoteAssetId;
if (asset is RemoteAsset) {
remoteAssetId = asset.id;
} else if (asset is LocalAsset) {
remoteAssetId = asset.remoteAssetId;
}
if (remoteAssetId == null) {
return const SizedBox.shrink();
}
final userId = ref.watch(currentUserProvider)?.id;
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId));
return assetAlbums.when(
data: (albums) {
if (albums.isEmpty) {
return const SizedBox.shrink();
}
albums.sortBy((a) => a.name);
return Column(
spacing: 12,
children: [
if (albums.isNotEmpty)
SheetTile(
title: 'appears_in'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
Padding(
padding: const EdgeInsets.only(left: 24),
child: Column(
spacing: 12,
children: albums.map((album) {
final isOwner = album.ownerId == userId;
return AlbumTile(
album: album,
isOwner: isOwner,
onAlbumSelected: (album) async {
ref.invalidate(assetViewerProvider);
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
},
);
}).toList(),
),
),
],
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
// Build file info tile based on asset type
Widget buildFileInfoTile() {
if (asset is LocalAsset) {
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
return FutureBuilder<String?>(
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {
final displayName = snapshot.data ?? asset.name;
return SheetTile(
title: displayName,
titleStyle: context.textTheme.labelLarge,
leading: Icon(
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
size: 24,
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
},
);
} else {
// For remote assets, use the name directly
return SheetTile(
title: asset.name,
titleStyle: context.textTheme.labelLarge,
leading: Icon(
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
size: 24,
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
}
}
return SliverList.list(
children: [
// Asset Date and Time
SheetTile(
title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.labelLarge,
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
const SheetPeopleDetails(),
const SheetLocationDetails(),
// Details header
SheetTile(
title: 'details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
// File info
buildFileInfoTile(),
// Camera info
if (cameraTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Lens info
if (lensTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: lensTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Rating bar
if (isRatingEnabled) ...[
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'rating'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,
filledColor: context.themeData.colorScheme.primary,
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
itemSize: 40,
onRatingUpdate: (rating) async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
},
onClearRating: () async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
},
),
],
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off
const SizedBox(height: 60),
],
);
}
}
class _SheetAssetDescription extends ConsumerStatefulWidget {
final ExifInfo exif;
final bool isEditable;
const _SheetAssetDescription({required this.exif, this.isEditable = true});
@override
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
}
class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> {
late TextEditingController _controller;
final _descriptionFocus = FocusNode();
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.exif.description ?? '');
}
Future<void> saveDescription(String? previousDescription) async {
final newDescription = _controller.text.trim();
if (newDescription == previousDescription) {
_descriptionFocus.unfocus();
return;
}
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
if (!editAction.success) {
_controller.text = previousDescription ?? '';
ImmichToast.show(
context: context,
msg: 'exif_bottom_sheet_description_error'.t(context: context),
toastType: ToastType.error,
);
}
_descriptionFocus.unfocus();
}
@override
Widget build(BuildContext context) {
// Watch the current asset EXIF provider to get updates
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
// Update controller text when EXIF data changes
final currentDescription = currentExifInfo?.description ?? '';
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
context: context,
);
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
_controller.text = currentDescription;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: IgnorePointer(
ignoring: !widget.isEditable,
child: TextField(
controller: _controller,
keyboardType: TextInputType.multiline,
focusNode: _descriptionFocus,
maxLines: null, // makes it grow as text is added
decoration: InputDecoration(
hintText: hintText,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
),
),
);
}
}

View file

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/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/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class SheetLocationDetails extends ConsumerStatefulWidget {
const SheetLocationDetails({super.key});
@override
ConsumerState createState() => _SheetLocationDetailsState();
}
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
MapLibreMapController? _mapController;
String? _getLocationName(ExifInfo? exifInfo) {
if (exifInfo == null) {
return null;
}
final cityName = exifInfo.city;
final stateName = exifInfo.state;
if (cityName != null && stateName != null) {
return "$cityName, $stateName";
}
return null;
}
void _onMapCreated(MapLibreMapController controller) {
_mapController = controller;
}
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
final currentExif = current.valueOrNull;
if (currentExif != null && currentExif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
}
}
@override
void initState() {
super.initState();
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
}
void editLocation() async {
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
}
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
// Guard local assets
if (asset is! RemoteAsset) {
return const SizedBox.shrink();
}
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetTile(
title: 'location'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation,
),
if (hasCoordinates)
Padding(
padding: EdgeInsets.symmetric(horizontal: context.isMobile ? 16.0 : 56.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExifMap(
exifInfo: exifInfo!,
markerId: asset.id,
markerAssetThumbhash: asset.thumbHash,
onMapCreated: _onMapCreated,
),
const SizedBox(height: 16),
if (locationName != null)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(locationName, style: context.textTheme.labelLarge),
),
Text(
coordinates,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
),
),
if (!hasCoordinates)
SheetTile(
title: "add_a_location".t(context: context),
titleStyle: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
leading: const Icon(Icons.location_off),
onTap: editLocation,
),
],
),
);
}
}

View file

@ -0,0 +1,176 @@
import 'package:auto_route/auto_route.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/person.model.dart';
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/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
class SheetPeopleDetails extends ConsumerStatefulWidget {
const SheetPeopleDetails({super.key});
@override
ConsumerState createState() => _SheetPeopleDetailsState();
}
class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
if (asset is! RemoteAsset) {
return const SizedBox.shrink();
}
final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id));
Future<void> showNameEditModal(DriftPerson person) async {
await showDialog(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return DriftPersonNameEditForm(person: person);
},
);
ref.invalidate(driftPeopleAssetProvider(asset.id));
}
return peopleFuture.when(
data: (people) {
return AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16),
child: Text(
"people".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
),
SizedBox(
height: 160,
child: ListView(
padding: const EdgeInsets.only(left: 16.0),
scrollDirection: Axis.horizontal,
children: [
for (final person in people)
_PeopleAvatar(
person: person,
assetFileCreatedAt: asset.createdAt,
onTap: () {
final previousRouteData = ref.read(previousRouteDataProvider);
final previousRouteArgs = previousRouteData?.arguments;
// Prevent circular navigation
if (previousRouteArgs is DriftPersonRouteArgs && previousRouteArgs.person.id == person.id) {
context.back();
return;
}
context.pop();
context.pushRoute(DriftPersonRoute(person: person));
},
onNameTap: () => showNameEditModal(person),
),
],
),
),
],
),
crossFadeState: people.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: Durations.short4,
);
},
error: (error, stack) => Text("error_loading_people".t(context: context), style: context.textTheme.bodyMedium),
loading: () => const SizedBox.shrink(),
);
}
}
class _PeopleAvatar extends StatelessWidget {
final DriftPerson person;
final DateTime assetFileCreatedAt;
final VoidCallback? onTap;
final VoidCallback? onNameTap;
final double imageSize = 96;
const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
@override
Widget build(BuildContext context) {
final headers = ApiService.getRequestHeaders();
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 96),
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: onTap,
child: SizedBox(
height: imageSize,
child: Material(
shape: CircleBorder(side: BorderSide(color: context.primaryColor.withAlpha(50), width: 1.0)),
shadowColor: context.colorScheme.shadow,
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
),
),
),
),
const SizedBox(height: 4),
if (person.name.isEmpty)
GestureDetector(
onTap: () => onNameTap?.call(),
child: Text(
"add_a_name".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
)
else
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
person.name,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
maxLines: 1,
),
if (person.birthDate != null)
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
formatAge(person.birthDate!, assetFileCreatedAt),
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(175),
),
),
),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class RatingBar extends StatefulWidget {
final double initialRating;
final int itemCount;
final double itemSize;
final Color filledColor;
final Color unfilledColor;
final ValueChanged<int>? onRatingUpdate;
final VoidCallback? onClearRating;
final Widget? itemBuilder;
final double starPadding;
const RatingBar({
super.key,
this.initialRating = 0.0,
this.itemCount = 5,
this.itemSize = 40.0,
this.filledColor = Colors.amber,
this.unfilledColor = Colors.grey,
this.onRatingUpdate,
this.onClearRating,
this.itemBuilder,
this.starPadding = 4.0,
});
@override
State<RatingBar> createState() => _RatingBarState();
}
class _RatingBarState extends State<RatingBar> {
late double _currentRating;
@override
void initState() {
super.initState();
_currentRating = widget.initialRating;
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx;
if (isRTL) dx = totalWidth - dx;
double newRating;
if (dx <= 0) {
newRating = 0;
} else if (dx >= totalWidth) {
newRating = widget.itemCount.toDouble();
} else {
double starWithPadding = widget.itemSize + widget.starPadding;
int tappedIndex = (dx / starWithPadding).floor().clamp(0, widget.itemCount - 1);
newRating = tappedIndex + 1.0;
if (isTap && newRating == _currentRating && _currentRating != 0) {
newRating = 0;
}
}
if (_currentRating != newRating) {
setState(() {
_currentRating = newRating;
});
widget.onRatingUpdate?.call(newRating.round());
}
}
@override
Widget build(BuildContext context) {
final isRTL = Directionality.of(context) == TextDirection.rtl;
final double visualAlignmentOffset = 5.0;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Transform.translate(
offset: Offset(isRTL ? visualAlignmentOffset : -visualAlignmentOffset, 0),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => _updateRating(details.localPosition, isRTL, isTap: true),
onPanUpdate: (details) => _updateRating(details.localPosition, isRTL, isTap: false),
child: Row(
mainAxisSize: MainAxisSize.min,
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
children: List.generate(widget.itemCount * 2 - 1, (i) {
if (i.isOdd) {
return SizedBox(width: widget.starPadding);
}
int index = i ~/ 2;
bool filled = _currentRating > index;
return widget.itemBuilder ??
Icon(
Icons.star_rounded,
size: widget.itemSize,
color: filled ? widget.filledColor : widget.unfilledColor,
);
}),
),
),
),
if (_currentRating > 0)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: GestureDetector(
onTap: () {
setState(() {
_currentRating = 0;
});
widget.onClearRating?.call();
},
child: Text(
'rating_clear'.t(context: context),
style: TextStyle(color: context.themeData.colorScheme.primary),
),
),
),
],
);
}
}

View file

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class SheetTile extends ConsumerWidget {
final String title;
final Widget? leading;
final Widget? trailing;
final String? subtitle;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
final VoidCallback? onTap;
const SheetTile({
super.key,
required this.title,
this.titleStyle,
this.leading,
this.subtitle,
this.subtitleStyle,
this.trailing,
this.onTap,
});
void copyTitle(BuildContext context, WidgetRef ref) {
Clipboard.setData(ClipboardData(text: title));
ImmichToast.show(
context: context,
msg: 'copied_to_clipboard'.t(context: context),
toastType: ToastType.info,
);
ref.read(hapticFeedbackProvider.notifier).selectionClick();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final Widget titleWidget;
if (leading == null) {
titleWidget = LimitedBox(
maxWidth: double.infinity,
child: Text(title, style: titleStyle),
);
} else {
titleWidget = Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 15, right: 15),
child: Text(title, style: titleStyle),
);
}
final Widget? subtitleWidget;
if (leading == null && subtitle != null) {
subtitleWidget = Text(subtitle!, style: subtitleStyle);
} else if (leading != null && subtitle != null) {
subtitleWidget = Padding(
padding: const EdgeInsets.only(left: 15),
child: Text(subtitle!, style: subtitleStyle),
);
} else {
subtitleWidget = null;
}
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
);
}
}

View file

@ -0,0 +1,122 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.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/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
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 isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
ref.watch(albumActivityProvider(album.id, asset.id));
}
if (!showControls) {
opacity = 0;
}
final originalTheme = context.themeData;
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
icon: const Icon(Icons.chat_outlined),
onPressed: () {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
},
),
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),
];
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
return IgnorePointer(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: AppBar(
backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125),
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: isShowingSheet || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions
: actions,
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(60.0);
}
class _AppBarBackButton extends ConsumerWidget {
const _AppBarBackButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black;
final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white;
return Padding(
padding: const EdgeInsets.only(left: 12.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
shape: const CircleBorder(),
iconSize: 22,
iconColor: foregroundColor,
padding: EdgeInsets.zero,
elevation: isShowingSheet ? 4 : 0,
),
onPressed: context.maybePop,
child: const Icon(Icons.arrow_back_rounded),
),
);
}
}

View file

@ -0,0 +1,445 @@
import 'dart:async';
import 'dart:io';
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/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
if (asset is RemoteAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.id == asset.id,
LocalAsset localAsset => localAsset.remoteId == asset.id,
_ => false,
};
} else if (asset is LocalAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.localId == asset.id,
LocalAsset localAsset => localAsset.id == asset.id,
_ => false,
};
}
return false;
}
class NativeVideoViewer extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final BaseAsset asset;
final bool showControls;
final int playbackDelayFactor;
final Widget image;
const NativeVideoViewer({
super.key,
required this.asset,
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
// Used to track whether the video should play when the app
// is brought back to the foreground
final shouldPlayOnForeground = useRef(true);
// When a video is opened through the timeline, `isCurrent` will immediately be true.
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
final currentAsset = useState(ref.read(currentAssetNotifier));
final isCurrent = _isCurrentAsset(asset, currentAsset.value);
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.hasLocal);
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
Future<VideoSource?> createSource() async {
if (!context.mounted) {
return null;
}
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
if (!context.mounted) {
return null;
}
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
if (!context.mounted) {
return null;
}
if (file == null) {
throw Exception('No file found for the video');
}
// Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
final source = await VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
return source;
}
final remoteId = (videoAsset as RemoteAsset).id;
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
return source;
} catch (error) {
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
return null;
}
}
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(null);
useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
}
try {
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset);
} catch (error) {
log.severe('Error getting aspect ratio for asset ${asset.name}: $error');
}
}, [asset.heroTag]);
void checkIfBuffering() {
if (!context.mounted) {
return;
}
final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
state: VideoPlaybackState.buffering,
);
}
}
// Timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the position changes, seek to the position
// Debounce the seek to avoid seeking too often
// But also don't delay the seek too much to maintain visual feedback
final seekDebouncer = useDebouncer(
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position.inMilliseconds;
if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek));
}
if (oldControls?.pause != newControls.pause || newControls.restarted) {
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
}
});
void onPlaybackReady() async {
final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) {
return;
}
try {
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
if (autoPlayVideo) {
await videoController.play();
}
await videoController.setVolume(0.9);
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
} else {
// Sync with the controls pause
WakelockPlus.disable();
}
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
}
void onPlaybackPositionChanged() {
// When seeking, these events sometimes move the slider to an older position
if (seekDebouncer.isActive) {
return;
}
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
}
}
void onPlaybackEnded() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
if (videoController.playbackInfo?.status == PlaybackStatus.stopped) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
}
void removeListeners(NativeVideoPlayerController controller) {
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged);
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged);
controller.onPlaybackReady.removeListener(onPlaybackReady);
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
}
void initController(NativeVideoPlayerController nc) async {
if (controller.value != null || !context.mounted) {
return;
}
ref.read(videoPlayerControlsProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).reset();
final source = await videoSource;
if (source == null || !context.mounted) {
return;
}
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady);
nc.onPlaybackEnded.addListener(onPlaybackEnded);
unawaited(
nc.loadVideoSource(source).catchError((error) {
log.severe('Error loading video source: $error');
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo));
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
ref.listen(currentAssetNotifier, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
removeListeners(playerController);
}
if (value != null) {
isVisible.value = _isCurrentAsset(value, asset);
}
final curAsset = currentAsset.value;
if (curAsset == asset) {
return;
}
final imageToVideo = curAsset != null && !curAsset.isVideo;
// No need to delay video playback when swiping from an image to a video
if (imageToVideo && Platform.isIOS) {
currentAsset.value = value;
onPlaybackReady();
return;
}
// Delay the video playback to avoid a stutter in the swipe animation
// Note, in some circumstances a longer delay is needed (eg: memories),
// the playbackDelayFactor can be used for this
// This delay seems like a hacky way to resolve underlying bugs in video
// playback, but other resolutions failed thus far
Timer(
Platform.isIOS
? Duration(milliseconds: 300 * playbackDelayFactor)
: imageToVideo
? Duration(milliseconds: 200 * playbackDelayFactor)
: Duration(milliseconds: 400 * playbackDelayFactor),
() {
if (!context.mounted) {
return;
}
currentAsset.value = value;
if (currentAsset.value == asset) {
onPlaybackReady();
}
},
);
});
useEffect(() {
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true);
return () {
timer?.cancel();
final playerController = controller.value;
if (playerController == null) {
return;
}
removeListeners(playerController);
playerController.stop().catchError((error) {
log.fine('Error stopping video: $error');
});
WakelockPlus.disable();
};
}, const []);
useOnAppLifecycleStateChange((_, state) async {
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
await controller.value?.play();
} else if (state == AppLifecycleState.paused) {
final videoPlaying = await controller.value?.isPlaying();
if (videoPlaying ?? true) {
shouldPlayOnForeground.value = true;
await controller.value?.pause();
} else {
shouldPlayOnForeground.value = false;
}
}
});
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: Center(
key: ValueKey(asset),
child: AspectRatio(
key: ValueKey(asset),
aspectRatio: aspectRatio.value!,
child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null,
),
),
),
if (showControls) const Center(child: VideoViewerControls()),
],
);
}
Future<void> _onPauseChange(
BuildContext context,
NativeVideoPlayerController controller,
Debouncer seekDebouncer,
bool isPaused,
) async {
if (!context.mounted) {
return;
}
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
try {
if (isPaused) {
await controller.pause();
} else {
await controller.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
}

View file

@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.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/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class VideoViewerControls extends HookConsumerWidget {
final Duration hideTimerDuration;
const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
if (showBottomSheet) {
showControls = false;
}
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final cast = ref.watch(castProvider);
// A timer to hide the controls
final hideTimer = useTimer(hideTimerDuration, () {
if (!context.mounted) {
return;
}
final state = ref.read(videoPlaybackValueProvider).state;
// Do not hide on paused
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(assetViewerProvider.notifier).setControls(true);
}
// When we change position, show or hide timer
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
showControlsAndStartHideTimer();
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
if (cast.isCasting) {
if (cast.castState == CastState.playing) {
ref.read(castProvider.notifier).pause();
} else if (cast.castState == CastState.paused) {
ref.read(castProvider.notifier).play();
} else if (cast.castState == CastState.idle) {
// resend the play command since its finished
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
return;
}
// ref.read(castProvider.notifier).loadMedia(asset, true);
}
return;
}
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
ref.read(videoPlayerControlsProvider.notifier).restart();
} else {
ref.read(videoPlayerControlsProvider.notifier).play();
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showControlsAndStartHideTimer,
child: AbsorbPointer(
absorbing: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else
GestureDetector(
onTap: () => ref.read(assetViewerProvider.notifier).setControls(false),
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.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';
class ViewerKebabMenu extends ConsumerWidget {
const ViewerKebabMenu({super.key, this.originalTheme});
final ThemeData? originalTheme;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final actionContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isStacked: asset is RemoteAsset && asset.stackId != null,
isInLockedView: isInLockedView,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
isCasting: isCasting,
timelineOrigin: timelineOrigin,
originalTheme: originalTheme,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) {
return IconButton(
icon: const Icon(Icons.more_vert_rounded),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}

View file

@ -0,0 +1,176 @@
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/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
class BackupToggleButton extends ConsumerStatefulWidget {
final VoidCallback onStart;
final VoidCallback onStop;
const BackupToggleButton({super.key, required this.onStart, required this.onStop});
@override
ConsumerState<BackupToggleButton> createState() => BackupToggleButtonState();
}
class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _gradientAnimation;
bool _isEnabled = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(duration: const Duration(seconds: 8), vsync: this);
_gradientAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
_isEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
Future<void> _onToggle(bool value) async {
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.enableBackup, value);
setState(() {
_isEnabled = value;
});
if (value) {
widget.onStart.call();
} else {
widget.onStop.call();
}
}
@override
Widget build(BuildContext context) {
final uploadTasks = ref.watch(driftBackupProvider.select((state) => state.uploadItems));
final isSyncing = ref.watch(driftBackupProvider.select((state) => state.isSyncing));
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
final errorCount = ref.watch(driftBackupProvider.select((state) => state.errorCount));
final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty;
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
final gradientColors = [
Color.lerp(
context.primaryColor.withValues(alpha: 0.5),
context.primaryColor.withValues(alpha: 0.3),
_gradientAnimation.value,
)!,
Color.lerp(
context.primaryColor.withValues(alpha: 0.2),
context.primaryColor.withValues(alpha: 0.4),
_gradientAnimation.value,
)!,
Color.lerp(
context.primaryColor.withValues(alpha: 0.3),
context.primaryColor.withValues(alpha: 0.5),
_gradientAnimation.value,
)!,
];
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: gradientColors,
stops: const [0.0, 0.5, 1.0],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(color: context.primaryColor.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 2)),
],
),
child: Container(
margin: const EdgeInsets.all(1.5),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(18.5)),
color: context.colorScheme.surfaceContainerLow,
),
child: Material(
color: context.colorScheme.surfaceContainerLow,
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
onTap: () => _onToggle(!_isEnabled),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
context.primaryColor.withValues(alpha: 0.2),
context.primaryColor.withValues(alpha: 0.1),
],
),
),
child: isProcessing
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: Icon(Icons.cloud_upload_outlined, color: context.primaryColor, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
"enable_backup".t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
),
),
],
),
if (errorCount > 0)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
),
),
],
),
),
Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)),
],
),
),
),
),
),
);
},
);
}
}

View file

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_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/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/stack_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/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class ArchiveBottomSheet extends ConsumerWidget {
const ArchiveBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
);
}
}

View file

@ -0,0 +1,132 @@
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/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
class BaseBottomSheet extends ConsumerStatefulWidget {
final List<Widget> actions;
final DraggableScrollableController? controller;
final List<Widget>? slivers;
final Widget? footer;
final double initialChildSize;
final double minChildSize;
final double maxChildSize;
final bool expand;
final bool shouldCloseOnMinExtent;
final bool resizeOnScroll;
final Color? backgroundColor;
const BaseBottomSheet({
super.key,
required this.actions,
this.slivers,
this.footer,
this.controller,
this.initialChildSize = 0.35,
double? minChildSize,
this.maxChildSize = 0.65,
this.expand = true,
this.shouldCloseOnMinExtent = true,
this.resizeOnScroll = true,
this.backgroundColor,
}) : minChildSize = minChildSize ?? 0.15;
@override
ConsumerState<BaseBottomSheet> createState() => _BaseDraggableScrollableSheetState();
}
class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet> {
late DraggableScrollableController _controller;
@override
void initState() {
super.initState();
_controller = widget.controller ?? DraggableScrollableController();
}
@override
Widget build(BuildContext context) {
ref.listen(timelineStateProvider, (previous, next) {
if (!widget.resizeOnScroll) {
return;
}
if (previous?.isInteracting != true && next.isInteracting) {
_controller.animateTo(
widget.minChildSize,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
}
});
return DraggableScrollableSheet(
controller: _controller,
initialChildSize: widget.initialChildSize,
minChildSize: widget.minChildSize,
maxChildSize: widget.maxChildSize,
snap: false,
expand: widget.expand,
shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent,
builder: (BuildContext context, ScrollController scrollController) {
return Card(
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: Column(
children: [
Expanded(
child: CustomScrollView(
controller: scrollController,
slivers: [
const SliverToBoxAdapter(child: _DragHandle()),
if (widget.actions.isNotEmpty)
SliverToBoxAdapter(
child: Column(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: widget.actions),
),
const Divider(indent: 16, endIndent: 16),
const SizedBox(height: 16),
],
),
),
if (widget.slivers != null) ...widget.slivers!,
],
),
),
if (widget.footer != null) widget.footer!,
],
),
);
},
);
}
}
class _DragHandle extends StatelessWidget {
const _DragHandle();
@override
Widget build(BuildContext context) {
return SizedBox(
height: 38,
child: Center(
child: SizedBox(
width: 32,
height: 6,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
color: context.themeData.dividerColor.lighten(amount: 0.6),
),
),
),
),
);
}
}

View file

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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_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/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_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/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/stack_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/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteBottomSheet extends ConsumerWidget {
const FavoriteBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
if (selectedAssets.isEmpty) {
return;
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
if (selectedAssets.length != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context),
);
}
if (addedCount != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}),
);
}
ref.read(multiSelectProvider.notifier).reset();
}
return BaseBottomSheet(
initialChildSize: 0.4,
maxChildSize: 0.7,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnFavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
slivers: multiselect.hasRemote
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
: [],
);
}
}

View file

@ -0,0 +1,135 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.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/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_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/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/stack_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/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class GeneralBottomSheet extends ConsumerStatefulWidget {
final double? minChildSize;
const GeneralBottomSheet({super.key, this.minChildSize});
@override
ConsumerState<GeneralBottomSheet> createState() => _GeneralBottomSheetState();
}
class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
late DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
if (selectedAssets.isEmpty) {
return;
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
if (selectedAssets.length != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context),
);
}
if (addedCount != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {"album": album.name}),
);
}
ref.read(multiSelectProvider.notifier).reset();
}
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet(
controller: sheetController,
initialChildSize: widget.minChildSize ?? 0.15,
minChildSize: widget.minChildSize,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
if (multiselect.selectedAssets.length == 1 && advancedTroubleshooting) ...[
const AdvancedInfoActionButton(source: ActionSource.timeline),
],
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: multiselect.hasRemote
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: [],
);
}
}

View file

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_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/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
class LocalAlbumBottomSheet extends ConsumerWidget {
const LocalAlbumBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: [
ShareActionButton(source: ActionSource.timeline),
DeleteLocalActionButton(source: ActionSource.timeline),
UploadActionButton(source: ActionSource.timeline),
],
);
}
}

View file

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/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/bottom_sheet/base_bottom_sheet.widget.dart';
class LockedFolderBottomSheet extends ConsumerWidget {
const LockedFolderBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: [
ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(source: ActionSource.timeline),
DeletePermanentActionButton(source: ActionSource.timeline),
RemoveFromLockFolderActionButton(source: ActionSource.timeline),
],
);
}
}

View file

@ -0,0 +1,55 @@
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/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class MapBottomSheet extends StatelessWidget {
const MapBottomSheet({super.key});
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.75,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
actions: [],
backgroundColor: context.themeData.colorScheme.surface,
slivers: [const SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
);
}
}
class _ScopedMapTimeline extends StatelessWidget {
const _ScopedMapTimeline();
@override
Widget build(BuildContext context) {
// TODO: this causes the timeline to switch to flicker to "loading" state and back. This is both janky and inefficient.
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access archive');
}
final users = ref.watch(mapStateProvider).withPartners
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final timelineService = ref
.watch(timelineFactoryProvider)
.map(users, ref.watch(mapStateProvider).toOptions());
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, withScrubber: false),
);
}
}

View file

@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
class PartnerDetailBottomSheet extends ConsumerWidget {
const PartnerDetailBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: [
ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(source: ActionSource.timeline),
],
);
}
}

View file

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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_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/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_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/stack_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/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/server_info.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 RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
final RemoteAlbum album;
const RemoteAlbumBottomSheet({super.key, required this.album});
@override
ConsumerState<RemoteAlbumBottomSheet> createState() => _RemoteAlbumBottomSheetState();
}
class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet> {
late DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
if (selectedAssets.isEmpty) {
return;
}
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
if (addedCount != selectedAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name}),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}),
);
}
ref.read(multiSelectProvider.notifier).reset();
}
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.22,
minChildSize: 0.22,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
],
slivers: ownsAlbum
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: null,
);
}
}

View file

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_trash_action_button.widget.dart';
class TrashBottomBar extends ConsumerWidget {
const TrashBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: const Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:octo_image/octo_image.dart';
class FullImage extends StatelessWidget {
const FullImage(
this.asset, {
required this.size,
this.fit = BoxFit.cover,
this.placeholder = const ThumbnailPlaceholder(),
super.key,
});
final BaseAsset asset;
final Size size;
final Widget? placeholder;
final BoxFit fit;
@override
Widget build(BuildContext context) {
final provider = getFullImageProvider(asset, size: size);
return OctoImage(
fadeInDuration: const Duration(milliseconds: 0),
fadeOutDuration: const Duration(milliseconds: 100),
placeholderBuilder: placeholder != null ? (_) => placeholder! : null,
image: provider,
width: size.width,
height: size.height,
fit: fit,
errorBuilder: (context, error, stackTrace) {
provider.evict();
return const Icon(Icons.image_not_supported_outlined, size: 32);
},
);
}
}

View file

@ -0,0 +1,141 @@
import 'package:async/async.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:logging/logging.dart';
abstract class CancellableImageProvider<T extends Object> extends ImageProvider<T> {
void cancel();
}
mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvider<T> {
static final _log = Logger('CancellableImageProviderMixin');
bool isCancelled = false;
ImageRequest? request;
CancelableOperation<ImageInfo?>? cachedOperation;
ImageInfo? getInitialImage(CancellableImageProvider provider) {
final completer = CancelableCompleter<ImageInfo?>(onCancel: provider.cancel);
final cachedStream = provider.resolve(const ImageConfiguration());
ImageInfo? cachedImage;
final listener = ImageStreamListener((image, synchronousCall) {
if (synchronousCall) {
cachedImage = image;
}
if (!completer.isCompleted) {
completer.complete(image);
}
}, onError: completer.completeError);
cachedStream.addListener(listener);
if (cachedImage != null) {
cachedStream.removeListener(listener);
return cachedImage;
}
completer.operation.valueOrCancellation().whenComplete(() {
cachedStream.removeListener(listener);
cachedOperation = null;
});
cachedOperation = completer.operation;
return null;
}
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
if (isCancelled) {
this.request = null;
PaintingBinding.instance.imageCache.evict(this);
return;
}
try {
final image = await request.load(decode);
if (image == null || isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
yield image;
} finally {
this.request = null;
}
}
Stream<ImageInfo> initialImageStream() async* {
final cachedOperation = this.cachedOperation;
if (cachedOperation == null) {
return;
}
try {
final cachedImage = await cachedOperation.valueOrCancellation();
if (cachedImage != null && !isCancelled) {
yield cachedImage;
}
} catch (e, stack) {
_log.severe('Error loading initial image', e, stack);
} finally {
this.cachedOperation = null;
}
}
@override
void cancel() {
isCancelled = true;
final request = this.request;
if (request != null) {
this.request = null;
request.cancel();
}
final operation = cachedOperation;
if (operation != null) {
cachedOperation = null;
operation.cancel();
}
}
}
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
// Create new provider and cache it
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
} else {
final String assetId;
final String thumbhash;
if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!;
thumbhash = "";
} else if (asset is RemoteAsset) {
assetId = asset.id;
thumbhash = asset.thumbHash ?? "";
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type);
}
return provider;
}
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
}
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited;

View file

@ -0,0 +1,38 @@
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/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
class LocalAlbumThumbnail extends ConsumerWidget {
const LocalAlbumThumbnail({super.key, required this.albumId});
final String albumId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final localAlbumThumbnail = ref.watch(localAlbumThumbnailProvider(albumId));
return localAlbumThumbnail.when(
data: (data) {
if (data == null) {
return Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: Icon(Icons.collections, size: 24, color: context.primaryColor),
);
}
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Thumbnail.fromAsset(asset: data),
);
},
error: (error, stack) {
return const Icon(Icons.error, size: 24);
},
loading: () => const SizedBox(width: 24, height: 24, child: Center(child: CircularProgressIndicator())),
);
}
}

View file

@ -0,0 +1,125 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
with CancellableImageProviderMixin<LocalThumbProvider> {
final String id;
final Size size;
final AssetType assetType;
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
@override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
onDispose: cancel,
);
}
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType);
return loadRequest(request, decode);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is LocalThumbProvider) {
return id == other.id;
}
return false;
}
@override
int get hashCode => id.hashCode;
}
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
with CancellableImageProviderMixin<LocalFullImageProvider> {
final String id;
final Size size;
final AssetType assetType;
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
onDispose: cancel,
);
}
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
var request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,
);
yield* loadRequest(request, decode);
if (!Store.get(StoreKey.loadOriginal, false)) {
return;
}
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
request = this.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
yield* loadRequest(request, decode);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is LocalFullImageProvider) {
return id == other.id && size == other.size;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode;
}

View file

@ -0,0 +1,51 @@
// The below code is adapted from cached_network_image package's
// MultiImageStreamCompleter to better suit one-frame image loading.
// In particular, it allows providing an initial image to emit synchronously.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
/// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
void Function()? _onDispose;
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
/// should be the primary images to display (typically asynchronously as they load).
/// The [initialImage] is an optional image that will be emitted synchronously
/// until the first stream image is completed, useful as a thumbnail or placeholder.
OneFramePlaceholderImageStreamCompleter(
Stream<ImageInfo> images, {
ImageInfo? initialImage,
InformationCollector? informationCollector,
void Function()? onDispose,
}) {
if (initialImage != null) {
setImage(initialImage);
}
_onDispose = onDispose;
images.listen(
setImage,
onError: (Object error, StackTrace stack) {
reportError(
context: ErrorDescription('resolving a single-frame image stream'),
exception: error,
stack: stack,
informationCollector: informationCollector,
silent: true,
);
},
);
}
@override
void onDisposed() {
final onDispose = _onDispose;
if (onDispose != null) {
_onDispose = null;
onDispose();
}
super.onDisposed();
}
}

View file

@ -0,0 +1,125 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
final String assetId;
final String thumbhash;
RemoteThumbProvider({required this.assetId, required this.thumbhash});
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
onDispose: cancel,
);
}
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
headers: ApiService.getRequestHeaders(),
);
return loadRequest(request, decode);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> {
final String assetId;
final String thumbhash;
final AssetType assetType;
RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
onDispose: cancel,
);
}
Stream<ImageInfo> _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final headers = ApiService.getRequestHeaders();
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
);
yield* loadRequest(previewRequest, decode);
if (assetType != AssetType.image || !AppSetting.get(Setting.loadOriginal)) {
return;
}
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
yield* loadRequest(originalRequest, decode);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
}

Some files were not shown because too many files have changed in this diff Show more