Merge branch 'main' into feat/custom-date-range

This commit is contained in:
Yaros
2026-02-26 13:10:18 +01:00
659 changed files with 23627 additions and 16227 deletions
@@ -63,7 +63,7 @@ class ActivityTextField extends HookConsumerWidget {
prefixIcon: user != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: UserCircleAvatar(user: user, size: 30, radius: 15),
child: UserCircleAvatar(user: user, size: 30),
)
: null,
suffixIcon: Padding(
@@ -40,7 +40,7 @@ class ActivityTile extends HookConsumerWidget {
child: Icon(Icons.thumb_up, color: context.primaryColor),
)
: isBottomSheet
? UserCircleAvatar(user: activity.user, size: 30, radius: 15)
? UserCircleAvatar(user: activity.user, size: 30)
: UserCircleAvatar(user: activity.user),
title: _ActivityTitle(
userName: activity.user.name,
@@ -41,7 +41,7 @@ class CommentBubble extends ConsumerWidget {
// avatar (hidden for own messages)
Widget avatar = const SizedBox.shrink();
if (!isOwn) {
avatar = UserCircleAvatar(user: activity.user, size: 28, radius: 14);
avatar = UserCircleAvatar(user: activity.user, size: 28);
}
// Thumbnail with tappable behavior and optional heart overlay
@@ -33,7 +33,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget {
itemBuilder: ((context, index) {
return Padding(
padding: const EdgeInsets.only(right: 4.0),
child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true),
child: UserCircleAvatar(user: sharedUsers[index], size: 36, hasBorder: true),
);
}),
itemCount: sharedUsers.length,
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_ui/immich_ui.dart';
class TrashDeleteDialog extends StatelessWidget {
const TrashDeleteDialog({super.key, required this.count});
final int count;
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
title: Text(context.t.permanently_delete),
content: ImmichFormattedText(context.t.permanently_delete_assets_prompt(count: count)),
actions: [
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: () => context.pop(false),
style: FilledButton.styleFrom(
backgroundColor: context.colorScheme.surfaceDim,
foregroundColor: context.primaryColor,
),
child: Text(context.t.cancel, style: const TextStyle(fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: () => context.pop(true),
style: FilledButton.styleFrom(
backgroundColor: context.colorScheme.errorContainer,
foregroundColor: context.colorScheme.onErrorContainer,
),
child: Text(context.t.delete, style: const TextStyle(fontWeight: FontWeight.bold)),
),
),
],
);
}
}
@@ -21,23 +21,20 @@ class CenterPlayButton extends StatelessWidget {
@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,
),
return 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,
),
),
),
@@ -52,7 +52,10 @@ class ImmichAppBarDialog extends HookConsumerWidget {
child: Stack(
alignment: Alignment.centerLeft,
children: [
IconButton(onPressed: () => context.pop(), icon: const Icon(Icons.close, size: 20)),
IconButton(
onPressed: () => context.pop(),
icon: Icon(Icons.close, size: 20, color: context.colorScheme.onSurfaceVariant),
),
Align(
alignment: Alignment.center,
child: Padding(
@@ -154,15 +157,12 @@ class ImmichAppBarDialog extends HookConsumerWidget {
}
return Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
Text(
"backup_controller_page_server_storage",
style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500),
).tr(),
Text("backup_controller_page_server_storage".tr(), style: context.textTheme.labelLarge),
LinearProgressIndicator(
minHeight: 10.0,
value: percentage,
@@ -264,13 +264,13 @@ class ImmichAppBarDialog extends HookConsumerWidget {
color: context.colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
margin: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
margin: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
child: Column(
children: [
const AppBarProfileInfoBox(),
const Divider(height: 3),
Divider(thickness: 4, color: context.colorScheme.surfaceContainer),
buildStorageInformation(),
const Divider(height: 3),
Divider(thickness: 4, color: context.colorScheme.surfaceContainer),
const AppBarServerInfo(),
],
),
@@ -34,7 +34,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
);
}
final userImage = UserCircleAvatar(radius: 22, size: 44, user: user);
final userImage = UserCircleAvatar(size: 44, user: user, hasBorder: true);
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20));
@@ -38,7 +38,7 @@ class AppBarServerInfo extends HookConsumerWidget {
const divider = Divider(thickness: 1);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -109,7 +109,7 @@ class _ServerInfoItem extends StatelessWidget {
style: TextStyle(
fontSize: contentFontSize,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
textAlign: TextAlign.end,
@@ -1,6 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -51,7 +50,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
? const Icon(Icons.face_outlined, size: widgetSize)
: Semantics(
label: "logged_in_as".tr(namedArgs: {"user": user.name}),
child: UserCircleAvatar(radius: 17, size: 31, user: user),
child: UserCircleAvatar(size: 32, user: user),
),
),
);
@@ -153,11 +152,6 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
actions: [
if (actions != null)
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if (kDebugMode || kProfileMode)
IconButton(
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
),
if (isCasting)
Padding(
padding: const EdgeInsets.only(right: 12),
@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
@@ -46,45 +48,37 @@ class ImmichSliverAppBar extends ConsumerWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return SliverAnimatedOpacity(
duration: Durations.medium1,
opacity: isMultiSelectEnabled ? 0 : 1,
sliver: SliverAppBar(
backgroundColor: context.colorScheme.surface,
surfaceTintColor: context.colorScheme.surfaceTint,
elevation: 0,
scrolledUnderElevation: 1.0,
floating: floating,
pinned: pinned,
snap: snap,
expandedHeight: expandedHeight,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
automaticallyImplyLeading: false,
centerTitle: false,
title: title ?? const _ImmichLogoWithText(),
actions: [
if (isCasting && !isReadonlyModeEnabled)
Padding(
padding: const EdgeInsets.only(right: 12),
child: IconButton(
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
return SliverIgnorePointer(
ignoring: isMultiSelectEnabled,
sliver: SliverAnimatedOpacity(
duration: Durations.medium1,
opacity: isMultiSelectEnabled ? 0 : 1,
sliver: SliverAppBar(
backgroundColor: context.colorScheme.surface,
surfaceTintColor: context.colorScheme.surfaceTint,
elevation: 0,
scrolledUnderElevation: 1.0,
floating: floating,
pinned: pinned,
snap: snap,
expandedHeight: expandedHeight,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
automaticallyImplyLeading: false,
centerTitle: false,
title: title ?? const _ImmichLogoWithText(),
actions: [
const _SyncStatusIndicator(),
if (isCasting && !isReadonlyModeEnabled)
IconButton(
onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()),
icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded),
),
),
const _SyncStatusIndicator(),
if (actions != null)
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
IconButton(
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
),
if (showUploadButton && !isReadonlyModeEnabled)
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),
const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()),
],
if (actions != null) ...actions!,
if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(),
const _ProfileIndicator(),
const SizedBox(width: 8),
],
),
),
);
}
@@ -94,27 +88,14 @@ class _ImmichLogoWithText extends StatelessWidget {
const _ImmichLogoWithText();
@override
Widget build(BuildContext context) {
return Builder(
builder: (BuildContext context) {
return Row(
children: [
Builder(
builder: (context) {
return Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset(
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
height: 40,
),
);
},
),
],
);
},
);
}
Widget build(BuildContext context) => AnimatedOpacity(
opacity: IconTheme.of(context).opacity ?? 1,
duration: kThemeChangeDuration,
child: SvgPicture.asset(
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
height: 40,
),
);
}
class _ProfileIndicator extends ConsumerWidget {
@@ -126,7 +107,7 @@ class _ProfileIndicator extends ConsumerWidget {
final bool versionWarningPresent = ref.watch(versionWarningPresentProvider(user));
final serverInfoState = ref.watch(serverInfoProvider);
const widgetSize = 30.0;
const widgetSize = 32.0;
// TODO: remove this when update Flutter version newer than 3.35.7
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
@@ -146,27 +127,23 @@ class _ProfileIndicator extends ConsumerWidget {
);
}
return InkWell(
onTap: () => showDialog(
return IconButton(
onPressed: () => showDialog(
context: context,
useRootNavigator: false,
barrierDismissible: !isIpad,
builder: (ctx) => const ImmichAppBarDialog(),
),
onLongPress: () => toggleReadonlyMode(),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
label: Container(
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.black : Colors.white,
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: Icon(
icon: Badge(
label: _BadgeLabel(
Icon(
Icons.info,
color: serverInfoState.versionStatus == VersionStatus.error
? context.colorScheme.error
: context.primaryColor,
size: widgetSize / 2,
semanticLabel: 'new_version_available'.tr(),
),
),
backgroundColor: Colors.transparent,
@@ -177,7 +154,16 @@ class _ProfileIndicator extends ConsumerWidget {
? const Icon(Icons.face_outlined, size: widgetSize)
: Semantics(
label: "logged_in_as".tr(namedArgs: {"user": user.name}),
child: AbsorbPointer(child: UserCircleAvatar(radius: 17, size: 31, user: user)),
child: AbsorbPointer(
child: Builder(
builder: (context) => UserCircleAvatar(
size: 34,
user: user,
opacity: IconTheme.of(context).opacity ?? 1,
hasBorder: true,
),
),
),
),
),
);
@@ -193,10 +179,9 @@ class _BackupIndicator extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final indicatorIcon = _getBackupBadgeIcon(context, ref);
return InkWell(
onTap: () => context.pushRoute(const DriftBackupRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
return IconButton(
onPressed: () => context.pushRoute(const DriftBackupRoute()),
icon: Badge(
label: indicatorIcon,
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
@@ -278,12 +263,14 @@ class _BadgeLabel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final opacity = IconTheme.of(context).opacity ?? 1;
return Container(
width: _kBadgeWidgetSize / 2,
height: _kBadgeWidgetSize / 2,
decoration: BoxDecoration(
color: backgroundColor ?? context.colorScheme.surfaceContainer,
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)),
color: (backgroundColor ?? context.colorScheme.surfaceContainer).withValues(alpha: opacity),
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3 * opacity)),
borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2),
),
child: indicator,
@@ -346,23 +333,30 @@ class _SyncStatusIndicatorState extends ConsumerState<_SyncStatusIndicator> with
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]),
builder: (context, child) {
return Padding(
padding: EdgeInsets.only(right: isSyncing ? 16 : 0),
child: Transform.scale(
scale: isSyncing ? 1.0 : _dismissalAnimation.value,
child: Opacity(
opacity: isSyncing ? 1.0 : _dismissalAnimation.value,
child: Transform.rotate(
angle: _rotationAnimation.value * 2 * 3.14159 * -1, // Rotate counter-clockwise
child: Icon(Icons.sync, size: 24, color: context.primaryColor),
),
),
),
);
},
return Padding(
padding: const EdgeInsets.all(8),
child: TweenAnimationBuilder<double>(
tween: Tween(end: IconTheme.of(context).opacity ?? 1),
duration: kThemeChangeDuration,
builder: (context, opacity, child) {
return AnimatedBuilder(
animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]),
builder: (context, child) {
final dismissalValue = isSyncing ? 1.0 : _dismissalAnimation.value;
return IconTheme(
data: IconTheme.of(context).copyWith(opacity: opacity * dismissalValue),
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..scaleByDouble(dismissalValue, dismissalValue, dismissalValue, 1.0)
..rotateZ(-_rotationAnimation.value * 2 * math.pi),
child: const Icon(Icons.sync),
),
);
},
);
},
),
);
}
}
@@ -3,7 +3,7 @@ import 'dart:io';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -254,22 +254,9 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
),
GestureDetector(
onTap: widget.onEditTitle,
child: SizedBox(
width: double.infinity,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
currentAlbum.name,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: 36,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black54)],
),
),
),
child: LayoutBuilder(
builder: (context, constraints) =>
_DynamicText(text: currentAlbum.name, maxWidth: constraints.maxWidth),
),
),
if (currentAlbum.description.isNotEmpty)
@@ -549,3 +536,46 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
);
}
}
class _DynamicText extends StatelessWidget {
final String text;
final double maxWidth;
const _DynamicText({required this.text, required this.maxWidth});
static const _baseTextStyle = TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black54)],
overflow: TextOverflow.ellipsis,
);
int _lineCount(double fontSize) {
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: _baseTextStyle.copyWith(fontSize: fontSize),
),
maxLines: 3,
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth);
return textPainter.computeLineMetrics().length;
}
double _fontSize() {
final fontSizes = [44.0, 36.0];
for (final fontSize in fontSizes) {
final lineCount = _lineCount(fontSize);
if (lineCount == 1) {
return fontSize;
}
}
return 28;
}
@override
Widget build(BuildContext context) {
return Text(text, style: _baseTextStyle.copyWith(fontSize: _fontSize()), maxLines: 3);
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.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/tag.provider.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
class TagPicker extends HookConsumerWidget {
const TagPicker({super.key, required this.onSelect, required this.filter});
final Function(Iterable<Tag>) onSelect;
final Set<String> filter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final formFocus = useFocusNode();
final searchQuery = useState('');
final tags = ref.watch(tagProvider);
final selectedTagIds = useState<Set<String>>(filter);
final borderRadius = const BorderRadius.all(Radius.circular(10));
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: SearchField(
focusNode: formFocus,
onChanged: (value) => searchQuery.value = value,
onTapOutside: (_) => formFocus.unfocus(),
filled: true,
hintText: 'filter_tags'.tr(),
),
),
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0),
child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1),
),
Expanded(
child: tags.widgetWhen(
onData: (tags) {
final queryResult = tags
.where((t) => t.value.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
return ListView.builder(
itemCount: queryResult.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final tag = queryResult[index];
final isSelected = selectedTagIds.value.any((id) => id == tag.id);
return Padding(
padding: const EdgeInsets.only(bottom: 2.0),
child: Container(
decoration: BoxDecoration(
color: isSelected ? context.primaryColor : context.primaryColor.withAlpha(25),
borderRadius: borderRadius,
),
child: ListTile(
title: Text(
tag.value,
style: context.textTheme.bodyLarge?.copyWith(
color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
),
),
onTap: () {
final newSelected = {...selectedTagIds.value};
if (isSelected) {
newSelected.removeWhere((id) => id == tag.id);
} else {
newSelected.add(tag.id);
}
selectedTagIds.value = newSelected;
onSelect(tags.where((t) => newSelected.contains(t.id)));
},
),
),
);
},
);
},
),
),
],
);
}
}
@@ -8,49 +8,52 @@ import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.
// ignore: must_be_immutable
class UserCircleAvatar extends ConsumerWidget {
final UserDto user;
double radius;
double size;
bool hasBorder;
double opacity;
UserCircleAvatar({super.key, this.radius = 22, this.size = 44, this.hasBorder = false, required this.user});
UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAvatarColor = user.avatarColor.toColor();
final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity);
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
final textColor = (user.avatarColor.toColor().computeLuminance() > 0.5 ? Colors.black : Colors.white).withValues(
alpha: opacity,
);
final textIcon = DefaultTextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: userAvatarColor.computeLuminance() > 0.5 ? Colors.black : Colors.white,
),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: textColor),
child: Text(user.name[0].toUpperCase()),
);
return Tooltip(
message: user.name,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: hasBorder ? Border.all(color: Colors.grey[500]!, width: 1) : null,
),
child: CircleAvatar(
backgroundColor: userAvatarColor,
radius: radius,
child: UnconstrainedBox(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: userAvatarColor,
shape: BoxShape.circle,
border: hasBorder ? Border.all(color: userAvatarColor.withValues(alpha: opacity), width: 1.5) : null,
),
child: user.hasProfileImage
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(50)),
borderRadius: BorderRadius.all(Radius.circular(size / 2)),
child: Image(
fit: BoxFit.cover,
width: size,
height: size,
image: RemoteImageProvider(url: profileImageUrl),
errorBuilder: (context, error, stackTrace) => textIcon,
color: Colors.white.withValues(alpha: opacity),
colorBlendMode: BlendMode.modulate,
),
)
: textIcon,
: Center(child: textIcon),
),
),
);
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class AssetMarkerIcon extends StatelessWidget {
const AssetMarkerIcon({required this.id, required this.thumbhash, super.key});
final String id;
final String thumbhash;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
return LayoutBuilder(
builder: (context, constraints) {
final pinHeight = constraints.maxHeight * 0.14;
final pinWidth = constraints.maxWidth * 0.14;
return SizedOverflowBox(
size: Size(pinWidth, pinHeight),
child: Stack(
// alignment: AlignmentGeometry.center,
children: [
Positioned(
bottom: 0,
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: context.colorScheme.onSurface,
secondaryColor: context.colorScheme.surface,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
child: SizedBox(height: pinHeight, width: pinWidth),
),
),
Positioned(
top: constraints.maxHeight * 0.07,
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: context.colorScheme.onSurface,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: RemoteImageProvider(url: imageUrl),
),
),
),
],
),
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
const _PinPainter({
required this.primaryColor,
required this.secondaryColor,
required this.primaryRadius,
required this.secondaryRadius,
});
@override
void paint(Canvas canvas, Size size) {
Paint primaryBrush = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
Paint secondaryBrush = Paint()
..color = secondaryColor
..style = PaintingStyle.fill;
Paint lineBrush = Paint()
..color = primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush);
canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush);
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
// The line is to make the above triangluar path more prominent since it has a slight curve
canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush);
}
Path getTrianglePath(double x, double y) {
final firstEndPoint = Offset(x / 2, y);
final controlPoint = Offset(x / 2, y * 0.3);
final secondEndPoint = Offset(x, 0);
return Path()
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy)
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy)
..lineTo(0, 0);
}
@override
bool shouldRepaint(_PinPainter old) {
return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor;
}
}
+13 -23
View File
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:immich_mobile/widgets/map/asset_marker_icon.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
/// A non-interactive thumbnail of a map in the given coordinates with optional markers
@@ -45,21 +45,12 @@ class MapThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
final controller = useRef<MapLibreMapController?>(null);
final styleLoaded = useState(false);
final position = useValueNotifier<Point<num>?>(null);
Future<void> onMapCreated(MapLibreMapController mapController) async {
controller.value = mapController;
styleLoaded.value = false;
if (assetMarkerRemoteId != null) {
// The iOS impl returns wrong toScreenLocation without the delay
Future.delayed(
const Duration(milliseconds: 100),
() async => position.value = await mapController.toScreenLocation(centre),
);
}
onCreated?.call(mapController);
}
@@ -90,11 +81,11 @@ class MapThumbnail extends HookConsumerWidget {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: Stack(
alignment: Alignment.center,
alignment: AlignmentGeometry.topCenter,
children: [
style.widgetWhen(
onData: (style) => MapLibreMap(
initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom),
initialCameraPosition: CameraPosition(target: centre, zoom: zoom),
styleString: style,
onMapCreated: onMapCreated,
onStyleLoadedCallback: onStyleLoaded,
@@ -109,17 +100,16 @@ class MapThumbnail extends HookConsumerWidget {
attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null,
),
),
ValueListenableBuilder(
valueListenable: position,
builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null
? PositionedAssetMarkerIcon(
size: height / 2,
point: value,
assetRemoteId: assetMarkerRemoteId!,
assetThumbhash: assetThumbhash!,
)
: const SizedBox.shrink(),
),
if (assetMarkerRemoteId != null && assetThumbhash != null)
Container(
width: width,
height: height / 2,
alignment: Alignment.bottomCenter,
child: SizedBox.square(
dimension: height / 2.5,
child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!),
),
),
],
),
),
@@ -3,8 +3,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/map/asset_marker_icon.dart';
class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point;
@@ -36,106 +35,9 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
onTap: () => onTap?.call(),
child: SizedBox.square(
dimension: size,
child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
),
),
);
}
}
class _AssetMarkerIcon extends StatelessWidget {
const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key});
final String id;
final String thumbhash;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
Positioned(
bottom: 0,
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: context.colorScheme.onSurface,
secondaryColor: context.colorScheme.surface,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
child: SizedBox(height: constraints.maxHeight * 0.14, width: constraints.maxWidth * 0.14),
),
),
Positioned(
top: constraints.maxHeight * 0.07,
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: context.colorScheme.onSurface,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: RemoteImageProvider(url: imageUrl),
),
),
),
],
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
const _PinPainter({
required this.primaryColor,
required this.secondaryColor,
required this.primaryRadius,
required this.secondaryRadius,
});
@override
void paint(Canvas canvas, Size size) {
Paint primaryBrush = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
Paint secondaryBrush = Paint()
..color = secondaryColor
..style = PaintingStyle.fill;
Paint lineBrush = Paint()
..color = primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush);
canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush);
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
// The line is to make the above triangluar path more prominent since it has a slight curve
canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush);
}
Path getTrianglePath(double x, double y) {
final firstEndPoint = Offset(x / 2, y);
final controlPoint = Offset(x / 2, y * 0.3);
final secondEndPoint = Offset(x, 0);
return Path()
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy)
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy)
..lineTo(0, 0);
}
@override
bool shouldRepaint(_PinPainter old) {
return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor;
}
}
@@ -257,6 +257,7 @@ class PhotoView extends StatefulWidget {
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd,
this.onLongPressStart,
this.customSize,
@@ -299,6 +300,7 @@ class PhotoView extends StatefulWidget {
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd,
this.onLongPressStart,
this.customSize,
@@ -417,6 +419,9 @@ class PhotoView extends StatefulWidget {
/// location.
final PhotoViewImageDragUpdateCallback? onDragUpdate;
/// A callback when a drag gesture is canceled by the system.
final VoidCallback? onDragCancel;
/// A pointer that will trigger a scale has stopped contacting the screen at a
/// particular location.
final PhotoViewImageScaleEndCallback? onScaleEnd;
@@ -543,7 +548,7 @@ class _PhotoViewState extends State<PhotoView> with AutomaticKeepAliveClientMixi
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final computedOuterSize = widget.customSize ?? constraints.biggest;
final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.black);
final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.transparent);
return widget._isCustomChild
? CustomChildWrapper(
@@ -564,6 +569,7 @@ class _PhotoViewState extends State<PhotoView> with AutomaticKeepAliveClientMixi
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onDragCancel: widget.onDragCancel,
onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart,
outerSize: computedOuterSize,
@@ -596,6 +602,7 @@ class _PhotoViewState extends State<PhotoView> with AutomaticKeepAliveClientMixi
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onDragCancel: widget.onDragCancel,
onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart,
outerSize: computedOuterSize,
@@ -284,6 +284,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
onDragStart: pageOption.onDragStart,
onDragEnd: pageOption.onDragEnd,
onDragUpdate: pageOption.onDragUpdate,
onDragCancel: pageOption.onDragCancel,
onScaleEnd: pageOption.onScaleEnd,
onLongPressStart: pageOption.onLongPressStart,
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
@@ -321,6 +322,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
onDragStart: pageOption.onDragStart,
onDragEnd: pageOption.onDragEnd,
onDragUpdate: pageOption.onDragUpdate,
onDragCancel: pageOption.onDragCancel,
onScaleEnd: pageOption.onScaleEnd,
onLongPressStart: pageOption.onLongPressStart,
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
@@ -367,6 +369,7 @@ class PhotoViewGalleryPageOptions {
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd,
this.onLongPressStart,
this.gestureDetectorBehavior,
@@ -397,6 +400,7 @@ class PhotoViewGalleryPageOptions {
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd,
this.onLongPressStart,
this.gestureDetectorBehavior,
@@ -454,9 +458,12 @@ class PhotoViewGalleryPageOptions {
/// Mirror to [PhotoView.onDragDown]
final PhotoViewImageDragEndCallback? onDragEnd;
/// Mirror to [PhotoView.onDraUpdate]
/// Mirror to [PhotoView.onDragUpdate]
final PhotoViewImageDragUpdateCallback? onDragUpdate;
/// Mirror to [PhotoView.onDragCancel]
final VoidCallback? onDragCancel;
/// Mirror to [PhotoView.onTapDown]
final PhotoViewImageTapDownCallback? onTapDown;
@@ -36,6 +36,7 @@ class PhotoViewCore extends StatefulWidget {
required this.onDragStart,
required this.onDragEnd,
required this.onDragUpdate,
required this.onDragCancel,
required this.onScaleEnd,
required this.onLongPressStart,
required this.gestureDetectorBehavior,
@@ -62,6 +63,7 @@ class PhotoViewCore extends StatefulWidget {
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd,
this.onLongPressStart,
this.gestureDetectorBehavior,
@@ -100,6 +102,7 @@ class PhotoViewCore extends StatefulWidget {
final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate;
final VoidCallback? onDragCancel;
final PhotoViewImageLongPressStartCallback? onLongPressStart;
@@ -386,6 +389,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
onDragUpdate: widget.onDragUpdate != null
? (details) => widget.onDragUpdate!(context, details, widget.controller.value)
: null,
onDragCancel: widget.onDragCancel,
hitDetector: this,
onTapUp: widget.onTapUp != null ? (details) => widget.onTapUp!(context, details, value) : null,
onTapDown: widget.onTapDown != null ? (details) => widget.onTapDown!(context, details, value) : null,
@@ -16,6 +16,7 @@ class PhotoViewGestureDetector extends StatelessWidget {
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onDragCancel,
this.onLongPressStart,
this.child,
this.onTapUp,
@@ -34,6 +35,7 @@ class PhotoViewGestureDetector extends StatelessWidget {
final GestureDragEndCallback? onDragEnd;
final GestureDragStartCallback? onDragStart;
final GestureDragUpdateCallback? onDragUpdate;
final GestureDragCancelCallback? onDragCancel;
final GestureTapUpCallback? onTapUp;
final GestureTapDownCallback? onTapDown;
@@ -73,7 +75,8 @@ class PhotoViewGestureDetector extends StatelessWidget {
instance
..onStart = onDragStart
..onUpdate = onDragUpdate
..onEnd = onDragEnd;
..onEnd = onDragEnd
..onCancel = onDragCancel;
},
);
}
@@ -28,6 +28,7 @@ class ImageWrapper extends StatefulWidget {
required this.onDragStart,
required this.onDragEnd,
required this.onDragUpdate,
required this.onDragCancel,
required this.onScaleEnd,
required this.onLongPressStart,
required this.outerSize,
@@ -62,6 +63,7 @@ class ImageWrapper extends StatefulWidget {
final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate;
final VoidCallback? onDragCancel;
final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageLongPressStartCallback? onLongPressStart;
final Size outerSize;
@@ -203,6 +205,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onDragCancel: widget.onDragCancel,
onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart,
outerSize: widget.outerSize,
@@ -233,6 +236,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onDragCancel: widget.onDragCancel,
onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart,
gestureDetectorBehavior: widget.gestureDetectorBehavior,
@@ -281,6 +285,7 @@ class CustomChildWrapper extends StatelessWidget {
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd,
this.onLongPressStart,
required this.outerSize,
@@ -313,6 +318,7 @@ class CustomChildWrapper extends StatelessWidget {
final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate;
final VoidCallback? onDragCancel;
final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageLongPressStartCallback? onLongPressStart;
final Size outerSize;
@@ -348,6 +354,7 @@ class CustomChildWrapper extends StatelessWidget {
onDragStart: onDragStart,
onDragEnd: onDragEnd,
onDragUpdate: onDragUpdate,
onDragCancel: onDragCancel,
onScaleEnd: onScaleEnd,
onLongPressStart: onLongPressStart,
gestureDetectorBehavior: gestureDetectorBehavior,
@@ -135,7 +135,7 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_enable_alternate_media_filter_title".tr(),
subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(),
),
const BetaTimelineListTile(),
if (!Store.isBetaTimelineEnabled) const BetaTimelineListTile(),
if (Store.isBetaTimelineEnabled)
SettingsSwitchListTile(
valueNotifier: readonlyModeEnabled,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'video_viewer_settings.dart';
@@ -8,7 +9,11 @@ class AssetViewerSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
final assetViewerSetting = [const ImageViewerQualitySetting(), const VideoViewerSettings()];
final assetViewerSetting = [
const ImageViewerQualitySetting(),
const ImageViewerTapToNavigateSetting(),
const VideoViewerSettings(),
];
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
}
@@ -0,0 +1,30 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
const ImageViewerTapToNavigateSetting({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: "setting_image_navigation_title".tr()),
SettingsSwitchListTile(
valueNotifier: tapToNavigate,
title: "setting_image_navigation_enable_title".tr(),
subtitle: "setting_image_navigation_enable_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);
}
}
@@ -54,7 +54,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
saveEndpointList();
}
Widget proxyDecorator(Widget child, int index, Animation<double> animation) {
Widget proxyDecorator(Widget child, int _, Animation<double> animation) {
return AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget? child) {
@@ -78,7 +78,10 @@ class SharedLinkItem extends ConsumerWidget {
return;
}
Clipboard.setData(ClipboardData(text: "${serverUrl}share/${sharedLink.key}")).then((_) {
final hasSlug = sharedLink.slug?.isNotEmpty == true;
final urlPath = hasSlug ? sharedLink.slug : sharedLink.key;
final basePath = hasSlug ? 's' : 'share';
Clipboard.setData(ClipboardData(text: "$serverUrl$basePath/$urlPath")).then((_) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(