mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/custom-date-range
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+30
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user