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,77 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class AdvancedBottomSheet extends HookConsumerWidget {
final Asset assetDetail;
final ScrollController? scrollController;
const AdvancedBottomSheet({super.key, required this.assetDetail, this.scrollController});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SingleChildScrollView(
controller: scrollController,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: LayoutBuilder(
builder: (context, constraints) {
// One column
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Align(child: Text("ADVANCED INFO", style: TextStyle(fontSize: 12.0))),
const SizedBox(height: 32.0),
Container(
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[200],
borderRadius: const BorderRadius.all(Radius.circular(15.0)),
),
child: Padding(
padding: const EdgeInsets.only(right: 16.0, left: 16, top: 8, bottom: 16),
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: assetDetail.toString())).then((_) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
"Copied to clipboard",
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
});
},
icon: Icon(Icons.copy, size: 16.0, color: context.primaryColor),
),
),
SelectableText(
assetDetail.toString(),
style: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
fontFamily: "GoogleSansCode",
),
showCursor: true,
),
],
),
),
),
const SizedBox(height: 32.0),
],
);
},
),
),
);
}
}

View file

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
/// A widget that animates implicitly between a play and a pause icon.
class AnimatedPlayPause extends StatefulWidget {
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color});
final double? size;
final bool playing;
final Color? color;
@override
State<StatefulWidget> createState() => AnimatedPlayPauseState();
}
class AnimatedPlayPauseState extends State<AnimatedPlayPause> with SingleTickerProviderStateMixin {
late final animationController = AnimationController(
vsync: this,
value: widget.playing ? 1 : 0,
duration: const Duration(milliseconds: 300),
);
@override
void didUpdateWidget(AnimatedPlayPause oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.playing != oldWidget.playing) {
if (widget.playing) {
animationController.forward();
} else {
animationController.reverse();
}
}
}
@override
void dispose() {
animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedIcon(
color: widget.color,
size: widget.size,
icon: AnimatedIcons.play_pause,
progress: animationController,
),
);
}
}

View file

@ -0,0 +1,362 @@
import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
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/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/editing/edit.page.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.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/routing/router.dart';
import 'package:immich_mobile/services/stack.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class BottomGalleryBar extends ConsumerWidget {
final ValueNotifier<int> assetIndex;
final bool showStack;
final ValueNotifier<int> stackIndex;
final ValueNotifier<int> totalAssets;
final PageController controller;
final RenderList renderList;
const BottomGalleryBar({
super.key,
required this.showStack,
required this.stackIndex,
required this.assetIndex,
required this.controller,
required this.totalAssets,
required this.renderList,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isInLockedView = ref.watch(inLockedViewProvider);
final asset = ref.watch(currentAssetProvider);
if (asset == null) {
return const SizedBox();
}
final isOwner = asset.ownerId == fastHash(ref.watch(currentUserProvider)?.id ?? '');
final showControls = ref.watch(showControlsProvider);
final stackId = asset.stackId;
final stackItems = showStack && stackId != null ? ref.watch(assetStackStateProvider(stackId)) : <Asset>[];
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
final navStack = AutoRouter.of(context).stackData;
final isTrashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final isFromTrash =
isTrashEnabled && navStack.length > 2 && navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
void removeAssetFromStack() {
if (stackIndex.value > 0 && showStack && stackId != null) {
ref.read(assetStackStateProvider(stackId).notifier).removeChild(stackIndex.value - 1);
}
}
void handleDelete() async {
Future<bool> onDelete(bool force) async {
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets({asset}, force: force);
if (isDeleted && isStackPrimaryAsset) {
// Workaround for asset remaining in the gallery
renderList.deleteAsset(asset);
// `assetIndex == totalAssets.value - 1` handle the case of removing the last asset
// to not throw the error when the next preCache index is called
if (totalAssets.value == 1 || assetIndex.value == totalAssets.value - 1) {
// Handle only one asset
await context.maybePop();
}
totalAssets.value -= 1;
}
if (isDeleted) {
ref.read(currentAssetProvider.notifier).set(renderList.loadAsset(assetIndex.value));
}
return isDeleted;
}
// Asset is trashed
if (isTrashEnabled && !isFromTrash) {
final isDeleted = await onDelete(false);
if (isDeleted) {
// Can only trash assets stored in server. Local assets are always permanently removed for now
if (context.mounted && asset.isRemote && isStackPrimaryAsset) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'asset_trashed'.tr(),
gravity: ToastGravity.BOTTOM,
);
}
removeAssetFromStack();
}
return;
}
// Asset is permanently removed
unawaited(
showDialog(
context: context,
builder: (BuildContext _) {
return DeleteDialog(
onDelete: () async {
final isDeleted = await onDelete(true);
if (isDeleted) {
removeAssetFromStack();
}
},
);
},
),
);
}
unStack() async {
if (asset.stackId == null) {
return;
}
await ref.read(stackServiceProvider).deleteStack(asset.stackId!, stackItems);
}
void showStackActionItems() {
showModalBottomSheet<void>(
context: context,
enableDrag: false,
builder: (BuildContext ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.filter_none_outlined, size: 18),
onTap: () async {
await unStack();
ctx.pop();
await context.maybePop();
},
title: const Text("viewer_unstack", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
),
],
),
),
);
},
);
}
shareAsset() {
if (asset.isOffline) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'asset_action_share_err_offline'.tr(),
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.read(downloadStateProvider.notifier).shareAsset(asset, context);
}
void handleEdit() async {
final image = Image(image: ImmichImage.imageProvider(asset: asset));
unawaited(
context.navigator.push(
MaterialPageRoute(
builder: (context) => EditImagePage(asset: asset, image: image, isEdited: false),
),
),
);
}
handleArchive() {
ref.read(assetProvider.notifier).toggleArchive([asset]);
if (isStackPrimaryAsset) {
context.maybePop();
return;
}
removeAssetFromStack();
}
handleDownload() {
if (asset.isLocal) {
return;
}
if (asset.isOffline) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'asset_action_share_err_offline'.tr(),
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.read(downloadStateProvider.notifier).downloadAsset(asset);
}
handleRemoveFromAlbum() async {
final album = ref.read(currentAlbumProvider);
final bool isSuccess = album != null && await ref.read(albumProvider.notifier).removeAsset(album, [asset]);
if (isSuccess) {
// Workaround for asset remaining in the gallery
renderList.deleteAsset(asset);
if (totalAssets.value == 1) {
// Handle empty viewer
await context.maybePop();
} else {
// changing this also for the last asset causes the parent to rebuild with an error
totalAssets.value -= 1;
}
if (assetIndex.value == totalAssets.value && assetIndex.value > 0) {
// handle the case of removing the last asset in the list
assetIndex.value -= 1;
}
} else {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_remove".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
}
final List<Map<BottomNavigationBarItem, Function(int)>> albumActions = [
{
BottomNavigationBarItem(
icon: Icon(Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded),
label: 'share'.tr(),
tooltip: 'share'.tr(),
): (_) =>
shareAsset(),
},
if (asset.isImage && !isInLockedView)
{
BottomNavigationBarItem(
icon: const Icon(Icons.tune_outlined),
label: 'edit'.tr(),
tooltip: 'edit'.tr(),
): (_) =>
handleEdit(),
},
if (isOwner && !isInLockedView)
{
asset.isArchived
? BottomNavigationBarItem(
icon: const Icon(Icons.unarchive_rounded),
label: 'unarchive'.tr(),
tooltip: 'unarchive'.tr(),
)
: BottomNavigationBarItem(
icon: const Icon(Icons.archive_outlined),
label: 'archive'.tr(),
tooltip: 'archive'.tr(),
): (_) =>
handleArchive(),
},
if (isOwner && asset.stackCount > 0 && !isInLockedView)
{
BottomNavigationBarItem(
icon: const Icon(Icons.burst_mode_outlined),
label: 'stack'.tr(),
tooltip: 'stack'.tr(),
): (_) =>
showStackActionItems(),
},
if (isOwner && !isInAlbum)
{
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'delete'.tr(),
tooltip: 'delete'.tr(),
): (_) =>
handleDelete(),
},
if (!isOwner)
{
BottomNavigationBarItem(
icon: const Icon(Icons.download_outlined),
label: 'download'.tr(),
tooltip: 'download'.tr(),
): (_) =>
handleDownload(),
},
if (isInAlbum)
{
BottomNavigationBarItem(
icon: const Icon(Icons.remove_circle_outline),
label: 'remove_from_album'.tr(),
tooltip: 'remove_from_album'.tr(),
): (_) =>
handleRemoveFromAlbum(),
},
];
return IgnorePointer(
ignoring: !showControls,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: showControls ? 1.0 : 0.0,
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black, Colors.transparent],
),
),
position: DecorationPosition.background,
child: Padding(
padding: const EdgeInsets.only(top: 40.0),
child: Column(
children: [
if (asset.isVideo) const VideoControls(),
BottomNavigationBar(
elevation: 0.0,
backgroundColor: Colors.transparent,
unselectedIconTheme: const IconThemeData(color: Colors.white),
selectedIconTheme: const IconThemeData(color: Colors.white),
unselectedLabelStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500, height: 2.3),
selectedLabelStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500, height: 2.3),
unselectedFontSize: 14,
selectedFontSize: 14,
selectedItemColor: Colors.white,
unselectedItemColor: Colors.white,
showSelectedLabels: true,
showUnselectedLabels: true,
items: albumActions.map((e) => e.keys.first).toList(growable: false),
onTap: (index) {
albumActions[index].values.first.call(index);
},
),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,127 @@
import 'dart:async';
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/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
class CastDialog extends ConsumerWidget {
const CastDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final castManager = ref.watch(castProvider);
bool isCurrentDevice(String deviceName) {
return castManager.receiverName == deviceName && castManager.isCasting;
}
bool isDeviceConnecting(String deviceName) {
return castManager.receiverName == deviceName && !castManager.isCasting;
}
return AlertDialog(
title: const Text("cast", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SizedBox(
width: 250,
height: 250,
child: FutureBuilder<List<(String, CastDestinationType, dynamic)>>(
future: ref.read(castProvider.notifier).getDevices(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('error_saving_image'.tr(args: [snapshot.error.toString()]));
} else if (!snapshot.hasData) {
return const SizedBox(height: 48, child: Center(child: CircularProgressIndicator()));
}
if (snapshot.data!.isEmpty) {
return const Text('no_cast_devices_found').tr();
}
final devices = snapshot.data!;
final connected = devices.where((d) => isCurrentDevice(d.$1)).toList();
final others = devices.where((d) => !isCurrentDevice(d.$1)).toList();
final List<dynamic> sectionedList = [];
if (connected.isNotEmpty) {
sectionedList.add("connected_device");
sectionedList.addAll(connected);
}
if (others.isNotEmpty) {
sectionedList.add("discovered_devices");
sectionedList.addAll(others);
}
return ListView.builder(
shrinkWrap: true,
itemCount: sectionedList.length,
itemBuilder: (context, index) {
final item = sectionedList[index];
if (item is String) {
// It's a section header
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(item, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)).tr(),
);
} else {
final (deviceName, type, deviceObj) = item as (String, CastDestinationType, dynamic);
return ListTile(
title: Text(
deviceName,
style: TextStyle(color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null),
),
leading: Icon(
type == CastDestinationType.googleCast ? Icons.cast : Icons.cast_connected,
color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null,
),
trailing: isCurrentDevice(deviceName)
? Icon(Icons.check, color: context.colorScheme.primary)
: isDeviceConnecting(deviceName)
? const CircularProgressIndicator()
: null,
onTap: () async {
if (isDeviceConnecting(deviceName)) {
return;
}
if (castManager.isCasting) {
await ref.read(castProvider.notifier).disconnect();
}
if (!isCurrentDevice(deviceName)) {
unawaited(ref.read(castProvider.notifier).connect(type, deviceObj));
}
},
);
}
},
);
},
),
),
actions: [
if (castManager.isCasting)
TextButton(
onPressed: () => ref.read(castProvider.notifier).disconnect(),
child: Text(
"stop_casting",
style: TextStyle(color: context.colorScheme.secondary, fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () => context.pop(),
child: Text(
"close",
style: TextStyle(color: context.colorScheme.primary, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
class CenterPlayButton extends StatelessWidget {
const CenterPlayButton({
super.key,
required this.backgroundColor,
this.iconColor,
required this.show,
required this.isPlaying,
required this.isFinished,
this.onPressed,
});
final Color backgroundColor;
final Color? iconColor;
final bool show;
final bool isPlaying;
final bool isFinished;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.transparent,
child: Center(
child: UnconstrainedBox(
child: AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 100),
child: DecoratedBox(
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
child: IconButton(
iconSize: 32,
padding: const EdgeInsets.all(12.0),
icon: isFinished
? Icon(Icons.replay, color: iconColor)
: AnimatedPlayPause(color: iconColor, playing: isPlaying),
onPressed: onPressed,
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,107 @@
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/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.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/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 CustomVideoPlayerControls extends HookConsumerWidget {
final Duration hideTimerDuration;
const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
final showControls = ref.watch(showControlsProvider);
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(showControlsProvider.notifier).show = false;
}
});
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(showControlsProvider.notifier).show = 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(currentAssetProvider);
if (asset == null) {
return;
}
ref.read(castProvider.notifier).loadMediaOld(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(showControlsProvider.notifier).show = 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,106 @@
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/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
class DescriptionInput extends HookConsumerWidget {
DescriptionInput({super.key, required this.asset, this.exifInfo});
final Asset asset;
final ExifInfo? exifInfo;
final Logger _log = Logger('DescriptionInput');
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
final focusNode = useFocusNode();
final isFocus = useState(false);
final isTextEmpty = useState(controller.text.isEmpty);
final assetService = ref.watch(assetServiceProvider);
final owner = ref.watch(currentUserProvider);
final hasError = useState(false);
final assetWithExif = ref.watch(assetDetailProvider(asset));
final hasDescription = useState(false);
final isOwner = fastHash(owner?.id ?? '') == asset.ownerId;
useEffect(() {
assetService.getDescription(asset).then((value) {
controller.text = value;
hasDescription.value = value.isNotEmpty;
});
return null;
}, [assetWithExif.value]);
if (!isOwner && !hasDescription.value) {
return const SizedBox.shrink();
}
submitDescription(String description) async {
hasError.value = false;
try {
await assetService.setDescription(asset, description);
controller.text = description;
} catch (error, stack) {
hasError.value = true;
_log.severe("Error updating description", error, stack);
ImmichToast.show(context: context, msg: "description_input_submit_error".tr(), toastType: ToastType.error);
}
}
Widget? suffixIcon;
if (hasError.value) {
suffixIcon = const Icon(Icons.warning_outlined);
} else if (!isTextEmpty.value && isFocus.value) {
suffixIcon = IconButton(
onPressed: () {
controller.clear();
isTextEmpty.value = true;
},
icon: Icon(Icons.cancel_rounded, color: context.colorScheme.onSurfaceSecondary),
splashRadius: 10,
);
}
return TextField(
enabled: isOwner,
focusNode: focusNode,
onTap: () => isFocus.value = true,
onChanged: (value) {
isTextEmpty.value = false;
},
onTapOutside: (a) async {
isFocus.value = false;
focusNode.unfocus();
if (exifInfo?.description != controller.text) {
await submitDescription(controller.text);
}
},
autofocus: false,
maxLines: null,
keyboardType: TextInputType.multiline,
controller: controller,
style: context.textTheme.labelLarge,
decoration: InputDecoration(
hintText: 'description_input_hint_text'.tr(),
border: InputBorder.none,
suffixIcon: suffixIcon,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
);
}
}

View file

@ -0,0 +1,44 @@
import 'package:easy_localization/easy_localization.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/asset_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class AssetDateTime extends ConsumerWidget {
final Asset asset;
const AssetDateTime({super.key, required this.asset});
String getDateTimeString(Asset a) {
final (deltaTime, timeZone) = a.getTZAdjustedTimeAndOffset();
final date = DateFormat.yMMMEd().format(deltaTime);
final time = DateFormat.jm().format(deltaTime);
return '$date$time GMT${timeZone.formatAsOffset()}';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final watchedAsset = ref.watch(assetDetailProvider(asset));
String formattedDateTime = getDateTimeString(asset);
void editDateTime() async {
await handleEditDateTime(ref, context, [asset]);
if (watchedAsset.value != null) {
formattedDateTime = getDateTimeString(watchedAsset.value!);
}
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formattedDateTime, style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
if (asset.isRemote) IconButton(onPressed: editDateTime, icon: const Icon(Icons.edit_outlined), iconSize: 20),
],
);
}
}

View file

@ -0,0 +1,40 @@
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/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/camera_info.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/file_info.dart';
class AssetDetails extends ConsumerWidget {
final Asset asset;
final ExifInfo? exifInfo;
const AssetDetails({super.key, required this.asset, this.exifInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetWithExif = ref.watch(assetDetailProvider(asset));
final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo;
return Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"exif_bottom_sheet_details",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
FileInfo(asset: asset),
if (exifInfo?.make != null) CameraInfo(exifInfo: exifInfo!),
],
),
);
}
}

View file

@ -0,0 +1,88 @@
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/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
class AssetLocation extends HookConsumerWidget {
final Asset asset;
const AssetLocation({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetWithExif = ref.watch(assetDetailProvider(asset));
final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo;
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
void editLocation() {
handleEditLocation(ref, context, [assetWithExif.value ?? asset]);
}
// Guard no lat/lng
if (!hasCoordinates) {
return asset.isRemote
? ListTile(
minLeadingWidth: 0,
contentPadding: const EdgeInsets.all(0),
leading: const Icon(Icons.location_on),
title: Text(
"add_a_location",
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
).tr(),
onTap: editLocation,
)
: const SizedBox.shrink();
}
Widget getLocationName() {
if (exifInfo == null) {
return const SizedBox.shrink();
}
final cityName = exifInfo.city;
final stateName = exifInfo.state;
bool hasLocationName = (cityName != null && stateName != null);
return hasLocationName
? Text("$cityName, $stateName", style: context.textTheme.labelLarge)
: const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"exif_bottom_sheet_location",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
if (asset.isRemote)
IconButton(onPressed: editLocation, icon: const Icon(Icons.edit_outlined), iconSize: 20),
],
),
asset.isRemote ? const SizedBox.shrink() : const SizedBox(height: 16),
ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId, markerAssetThumbhash: asset.thumbhash),
const SizedBox(height: 16),
getLocationName(),
Text(
"${exifInfo.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
style: context.textTheme.labelMedium?.copyWith(color: context.textTheme.labelMedium?.color?.withAlpha(150)),
),
],
),
);
}
}

View file

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class CameraInfo extends StatelessWidget {
final ExifInfo exifInfo;
const CameraInfo({super.key, required this.exifInfo});
@override
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
return ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: Icon(Icons.camera, color: textColor.withAlpha(200)),
title: Text("${exifInfo.make} ${exifInfo.model}", style: context.textTheme.labelLarge),
subtitle: exifInfo.f != null || exifInfo.exposureSeconds != null || exifInfo.mm != null || exifInfo.iso != null
? Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ",
style: context.textTheme.bodySmall,
)
: null,
);
}
}

View file

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/widgets/asset_viewer/description_input.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/asset_date_time.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/asset_details.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/asset_location.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/people_info.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class DetailPanel extends HookConsumerWidget {
final Asset asset;
final ScrollController? scrollController;
const DetailPanel({super.key, required this.asset, this.scrollController});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
controller: scrollController,
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
AssetDateTime(asset: asset),
asset.isRemote ? DescriptionInput(asset: asset) : const SizedBox.shrink(),
PeopleInfo(asset: asset),
AssetLocation(asset: asset),
AssetDetails(asset: asset),
],
),
),
],
);
}
}

View file

@ -0,0 +1,90 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:url_launcher/url_launcher.dart';
class ExifMap extends StatelessWidget {
final ExifInfo exifInfo;
// TODO: Pass in a BaseAsset instead of the ID and thumbhash when removing old timeline
// This is currently structured this way because of the old timeline implementation
// reusing this component
final String? markerId;
final String? markerAssetThumbhash;
final MapCreatedCallback? onMapCreated;
const ExifMap({
super.key,
required this.exifInfo,
this.markerAssetThumbhash,
this.markerId = 'marker',
this.onMapCreated,
});
@override
Widget build(BuildContext context) {
final hasCoordinates = exifInfo.hasCoordinates;
Future<Uri?> createCoordinatesUri() async {
if (!hasCoordinates) {
return null;
}
final double latitude = exifInfo.latitude!;
final double longitude = exifInfo.longitude!;
const zoomLevel = 16;
if (Platform.isAndroid) {
Uri uri = Uri(
scheme: 'geo',
host: '$latitude,$longitude',
queryParameters: {'z': '$zoomLevel', 'q': '$latitude,$longitude'},
);
if (await canLaunchUrl(uri)) {
return uri;
}
} else if (Platform.isIOS) {
var params = {'ll': '$latitude,$longitude', 'q': '$latitude,$longitude', 'z': '$zoomLevel'};
Uri uri = Uri.https('maps.apple.com', '/', params);
if (await canLaunchUrl(uri)) {
return uri;
}
}
return Uri(
scheme: 'https',
host: 'openstreetmap.org',
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
fragment: 'map=$zoomLevel/$latitude/$longitude',
);
}
return LayoutBuilder(
builder: (context, constraints) {
return MapThumbnail(
centre: LatLng(exifInfo.latitude ?? 0, exifInfo.longitude ?? 0),
height: 150,
width: constraints.maxWidth,
zoom: 12.0,
assetMarkerRemoteId: markerId,
assetThumbhash: markerAssetThumbhash,
onTap: (tapPosition, latLong) async {
Uri? uri = await createCoordinatesUri();
if (uri == null) {
return;
}
dPrint(() => 'Opening Map Uri: $uri');
unawaited(launchUrl(uri));
},
onCreated: onMapCreated,
);
},
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
class FileInfo extends StatelessWidget {
final Asset asset;
const FileInfo({super.key, required this.asset});
@override
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
final height = asset.orientatedHeight ?? asset.height;
final width = asset.orientatedWidth ?? asset.width;
String resolution = height != null && width != null ? "$width x $height " : "";
String fileSize = asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo!.fileSize!) : "";
String text = resolution + fileSize;
final imgSizeString = text.isNotEmpty ? text : null;
String? title;
String? subtitle;
if (imgSizeString == null && asset.fileName.isNotEmpty) {
// There is only filename
title = asset.fileName;
} else if (imgSizeString != null && asset.fileName.isNotEmpty) {
// There is both filename and size information
title = asset.fileName;
subtitle = imgSizeString;
} else if (imgSizeString != null && asset.fileName.isEmpty) {
title = imgSizeString;
} else {
return const SizedBox.shrink();
}
return ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: Icon(Icons.image, color: textColor.withAlpha(200)),
titleAlignment: ListTileTitleAlignment.center,
title: Text(title, style: context.textTheme.labelLarge),
subtitle: subtitle == null ? null : Text(subtitle),
);
}
}

View file

@ -0,0 +1,91 @@
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/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/widgets/search/curated_people_row.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
class PeopleInfo extends ConsumerWidget {
final Asset asset;
final EdgeInsets? padding;
const PeopleInfo({super.key, required this.asset, this.padding});
@override
Widget build(BuildContext context, WidgetRef ref) {
final peopleProvider = ref.watch(assetPeopleNotifierProvider(asset).notifier);
final people = ref.watch(assetPeopleNotifierProvider(asset)).value?.where((p) => !p.isHidden);
showPersonNameEditModel(String personId, String personName) {
return showDialog(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
).then((_) {
// ensure the people list is up-to-date.
peopleProvider.refresh();
});
}
final curatedPeople =
people
?.map(
(p) => SearchCuratedContent(
id: p.id,
label: p.name,
subtitle: p.birthDate != null && p.birthDate!.isBefore(asset.fileCreatedAt)
? formatAge(p.birthDate!, asset.fileCreatedAt)
: null,
),
)
.toList() ??
[];
return AnimatedCrossFade(
crossFadeState: (people?.isEmpty ?? true) ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: Container(),
secondChild: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
children: [
Padding(
padding: padding ?? EdgeInsets.zero,
child: Align(
alignment: Alignment.topLeft,
child: Text(
"exif_bottom_sheet_people",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: CuratedPeopleRow(
padding: padding,
content: curatedPeople,
onTap: (content, index) {
context
.pushRoute(PersonResultRoute(personId: content.id, personName: content.label))
.then((_) => peopleProvider.refresh());
},
onNameTap: (person, index) => {showPersonNameEditModel(person.id, person.label)},
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
class FormattedDuration extends StatelessWidget {
final Duration data;
const FormattedDuration(this.data, {super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
child: Text(
data.format(),
style: const TextStyle(fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
);
}
}

View file

@ -0,0 +1,119 @@
import 'package:auto_route/auto_route.dart';
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/entities/asset.entity.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/trash.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/widgets/asset_grid/upload_dialog.dart';
import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class GalleryAppBar extends ConsumerWidget {
final void Function() showInfo;
const GalleryAppBar({super.key, required this.showInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetProvider);
if (asset == null) {
return const SizedBox();
}
final album = ref.watch(currentAlbumProvider);
final isOwner = asset.ownerId == fastHash(ref.watch(currentUserProvider)?.id ?? '');
final showControls = ref.watch(showControlsProvider);
final isPartner = ref.watch(partnerSharedWithProvider).map((e) => fastHash(e.id)).contains(asset.ownerId);
toggleFavorite(Asset asset) => ref.read(assetProvider.notifier).toggleFavorite([asset]);
handleActivities() {
if (album != null && album.shared && album.remoteId != null) {
context.pushRoute(const ActivitiesRoute());
}
}
handleRestore(Asset asset) async {
final result = await ref.read(trashProvider.notifier).restoreAssets([asset]);
if (result && context.mounted) {
ImmichToast.show(context: context, msg: 'asset_restored_successfully'.tr(), gravity: ToastGravity.BOTTOM);
}
}
handleUpload(Asset asset) {
showDialog(
context: context,
builder: (BuildContext _) {
return UploadDialog(
onUpload: () {
ref.read(manualUploadProvider.notifier).uploadAssets(context, [asset]);
},
);
},
);
}
addToAlbum(Asset addToAlbumAsset) {
showModalBottomSheet(
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))),
context: context,
builder: (BuildContext _) {
return AddToAlbumBottomSheet(assets: [addToAlbumAsset]);
},
);
}
handleDownloadAsset() {
ref.read(downloadStateProvider.notifier).downloadAsset(asset);
}
handleLocateAsset() async {
// Go back to the gallery
await context.maybePop();
await context.navigateTo(const TabControllerRoute(children: [PhotosRoute()]));
ref.read(tabProvider.notifier).update((state) => state = TabEnum.home);
// Scroll to the asset's date
scrollToDateNotifierProvider.scrollToDate(asset.fileCreatedAt);
}
return IgnorePointer(
ignoring: !showControls,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: showControls ? 1.0 : 0.0,
child: Container(
color: Colors.black.withValues(alpha: 0.4),
child: TopControlAppBar(
isOwner: isOwner,
isPartner: isPartner,
asset: asset,
onMoreInfoPressed: showInfo,
onLocatePressed: handleLocateAsset,
onFavorite: toggleFavorite,
onRestorePressed: () => handleRestore(asset),
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
onAddToAlbumPressed: () => addToAlbum(asset),
onActivitiesPressed: handleActivities,
),
),
),
);
}
}

View file

@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoButton extends ConsumerWidget {
const MotionPhotoButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPlaying = ref.watch(isPlayingMotionVideoProvider);
return IconButton(
onPressed: () {
ref.read(isPlayingMotionVideoProvider.notifier).toggle();
},
icon: isPlaying
? const Icon(Icons.motion_photos_pause_outlined, color: grey200)
: const Icon(Icons.play_circle_outline_rounded, color: grey200),
);
}
}

View file

@ -0,0 +1,182 @@
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/activity_statistics.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
class TopControlAppBar extends HookConsumerWidget {
const TopControlAppBar({
super.key,
required this.asset,
required this.onMoreInfoPressed,
required this.onDownloadPressed,
required this.onLocatePressed,
required this.onAddToAlbumPressed,
required this.onRestorePressed,
required this.onFavorite,
required this.onUploadPressed,
required this.isOwner,
required this.onActivitiesPressed,
required this.isPartner,
});
final Asset asset;
final Function onMoreInfoPressed;
final VoidCallback? onUploadPressed;
final VoidCallback? onDownloadPressed;
final VoidCallback onLocatePressed;
final VoidCallback onAddToAlbumPressed;
final VoidCallback onRestorePressed;
final VoidCallback onActivitiesPressed;
final Function(Asset) onFavorite;
final bool isOwner;
final bool isPartner;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isInLockedView = ref.watch(inLockedViewProvider);
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
final album = ref.watch(currentAlbumProvider);
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final websocketConnected = ref.watch(websocketProvider.select((c) => c.isConnected));
final comments = album != null && album.remoteId != null && asset.remoteId != null
? ref.watch(activityStatisticsProvider(album.remoteId!, asset.remoteId))
: 0;
Widget buildFavoriteButton(a) {
return IconButton(
onPressed: () => onFavorite(a),
icon: Icon(a.isFavorite ? Icons.favorite : Icons.favorite_border, color: Colors.grey[200]),
);
}
Widget buildLocateButton() {
return IconButton(
onPressed: () {
onLocatePressed();
},
icon: Icon(Icons.image_search, color: Colors.grey[200]),
);
}
Widget buildMoreInfoButton() {
return IconButton(
onPressed: () {
onMoreInfoPressed();
},
icon: Icon(Icons.info_outline_rounded, color: Colors.grey[200]),
);
}
Widget buildDownloadButton() {
return IconButton(
onPressed: onDownloadPressed,
icon: Icon(Icons.cloud_download_outlined, color: Colors.grey[200]),
);
}
Widget buildAddToAlbumButton() {
return IconButton(
onPressed: () {
onAddToAlbumPressed();
},
icon: Icon(Icons.add, color: Colors.grey[200]),
);
}
Widget buildRestoreButton() {
return IconButton(
onPressed: () {
onRestorePressed();
},
icon: Icon(Icons.history_rounded, color: Colors.grey[200]),
);
}
Widget buildActivitiesButton() {
return IconButton(
onPressed: () {
onActivitiesPressed();
},
icon: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.mode_comment_outlined, color: Colors.grey[200]),
if (comments != 0)
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text(
comments.toString(),
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey[200]),
),
),
],
),
);
}
Widget buildUploadButton() {
return IconButton(
onPressed: onUploadPressed,
icon: Icon(Icons.backup_outlined, color: Colors.grey[200]),
);
}
Widget buildBackButton() {
return IconButton(
onPressed: () {
context.maybePop();
},
icon: Icon(Icons.arrow_back_ios_new_rounded, size: 20.0, color: Colors.grey[200]),
);
}
Widget buildCastButton() {
return IconButton(
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
icon: Icon(
isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
size: 20.0,
color: isCasting ? context.primaryColor : Colors.grey[200],
),
);
}
bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home;
bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed;
return AppBar(
foregroundColor: Colors.grey[100],
backgroundColor: Colors.transparent,
leading: buildBackButton(),
actionsIconTheme: const IconThemeData(size: iconSize),
shape: const Border(),
actions: [
if (asset.isRemote && isOwner) buildFavoriteButton(a),
if (isOwner && !isInHomePage && !(isInTrash ?? false) && !isInLockedView) buildLocateButton(),
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed && !isInLockedView) buildAddToAlbumButton(),
if (isCasting || (asset.isRemote && websocketConnected)) buildCastButton(),
if (asset.isTrashed) buildRestoreButton(),
if (album != null && album.shared && !isInLockedView) buildActivitiesButton(),
buildMoreInfoButton(),
],
);
}
}

View file

@ -0,0 +1,17 @@
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/widgets/asset_viewer/video_position.dart';
/// The video controls for the [videoPlayerControlsProvider]
class VideoControls extends ConsumerWidget {
const VideoControls({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPortrait = context.orientation == Orientation.portrait;
return isPortrait
? const VideoPosition()
: const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition());
}
}

View file

@ -0,0 +1,112 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.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/widgets/asset_viewer/formatted_duration.dart';
class VideoPosition extends HookConsumerWidget {
const VideoPosition({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCasting = ref.watch(castProvider).isCasting;
final (position, duration) = isCasting
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
: ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration)));
final wasPlaying = useRef<bool>(true);
return duration == Duration.zero
? const _VideoPositionPlaceholder()
: Column(
children: [
Padding(
// align with slider's inherent padding
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [FormattedDuration(position), FormattedDuration(duration)],
),
),
Row(
children: [
Expanded(
child: Slider(
value: min(position.inMicroseconds / duration.inMicroseconds * 100, 100),
min: 0,
max: 100,
thumbColor: Colors.white,
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
onChangeStart: (value) {
final state = ref.read(videoPlaybackValueProvider).state;
wasPlaying.value = state != VideoPlaybackState.paused;
ref.read(videoPlayerControlsProvider.notifier).pause();
},
onChangeEnd: (value) {
if (wasPlaying.value) {
ref.read(videoPlayerControlsProvider.notifier).play();
}
},
onChanged: (value) {
final seekToDuration = (duration * (value / 100.0));
if (isCasting) {
ref.read(castProvider.notifier).seekTo(seekToDuration);
return;
}
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration;
// This immediately updates the slider position without waiting for the video to update
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;
},
),
),
],
),
],
);
}
}
class _VideoPositionPlaceholder extends StatelessWidget {
const _VideoPositionPlaceholder();
static void _onChangedDummy(_) {}
@override
Widget build(BuildContext context) {
return const Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [FormattedDuration(Duration.zero), FormattedDuration(Duration.zero)],
),
),
Row(
children: [
Expanded(
child: Slider(
value: 0.0,
min: 0,
max: 100,
thumbColor: Colors.white,
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
onChanged: _onChangedDummy,
),
),
],
),
],
);
}
}