2025-11-11 04:26:27 +09:00
|
|
|
import 'package:auto_route/auto_route.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
|
|
|
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
|
|
|
|
import 'package:immich_mobile/models/activities/activity.model.dart';
|
2026-02-05 12:16:42 -05:00
|
|
|
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
2025-11-11 04:26:27 +09:00
|
|
|
import 'package:immich_mobile/providers/activity.provider.dart';
|
|
|
|
|
import 'package:immich_mobile/providers/activity_service.provider.dart';
|
|
|
|
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
|
|
|
|
import 'package:immich_mobile/providers/user.provider.dart';
|
|
|
|
|
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
|
|
|
|
|
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
|
|
|
|
|
|
|
|
|
class CommentBubble extends ConsumerWidget {
|
|
|
|
|
final Activity activity;
|
|
|
|
|
final bool isAssetActivity;
|
|
|
|
|
|
|
|
|
|
const CommentBubble({super.key, required this.activity, this.isAssetActivity = false});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
|
|
final user = ref.watch(currentUserProvider);
|
|
|
|
|
final album = ref.watch(currentRemoteAlbumProvider)!;
|
|
|
|
|
final isOwn = activity.user.id == user?.id;
|
|
|
|
|
final canDelete = isOwn || album.ownerId == user?.id;
|
|
|
|
|
final showThumbnail = !isAssetActivity && activity.assetId != null && activity.assetId!.isNotEmpty;
|
|
|
|
|
final isLike = activity.type == ActivityType.like;
|
|
|
|
|
final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer;
|
|
|
|
|
|
|
|
|
|
final activityNotifier = ref.read(
|
2026-04-17 20:01:44 +05:30
|
|
|
albumActivityProvider((album.id, isAssetActivity ? activity.assetId : null)).notifier,
|
2025-11-11 04:26:27 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Future<void> openAssetViewer() async {
|
|
|
|
|
final activityService = ref.read(activityServiceProvider);
|
|
|
|
|
final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref);
|
2026-05-12 00:47:24 +07:00
|
|
|
if (route != null) {
|
|
|
|
|
await context.pushRoute(route);
|
|
|
|
|
}
|
2025-11-11 04:26:27 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// avatar (hidden for own messages)
|
|
|
|
|
Widget avatar = const SizedBox.shrink();
|
|
|
|
|
if (!isOwn) {
|
fix(mobile): inherit toolbar opacity (#25694)
Some widgets, like Icon widgets, automatically inherit opacity from the
icon theme in the context. Many other widgets however, do not. The
Immich logo, profile picture, and backup badge are examples of widgets
of this.
All unsupported toolbar widgets have been updated to support inheriting
the opacity from the icon theme.
IconButtons internally animate properties like opacity, which is kind of
nice, but means we have to do more work to replicate that behaviour for
other widgets. In most cases, we can simply use an IconButton widget and
forward the correct opacity. The Immich logo however is not a button,
and therefore we need to use a custom TweenAnimationBuilder.
All widgets are using efficient, native opacity rather than the heavy
Opacity widget.
2026-02-16 04:24:57 +00:00
|
|
|
avatar = UserCircleAvatar(user: activity.user, size: 28);
|
2025-11-11 04:26:27 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Thumbnail with tappable behavior and optional heart overlay
|
|
|
|
|
Widget? thumbnail;
|
|
|
|
|
if (showThumbnail) {
|
|
|
|
|
thumbnail = ConstrainedBox(
|
|
|
|
|
constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150),
|
|
|
|
|
child: Stack(
|
|
|
|
|
children: [
|
|
|
|
|
GestureDetector(
|
|
|
|
|
onTap: openAssetViewer,
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
|
|
|
|
child: Image(
|
2026-02-05 12:16:42 -05:00
|
|
|
image: RemoteImageProvider.thumbnail(assetId: activity.assetId!, thumbhash: ""),
|
2025-11-11 04:26:27 +09:00
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
if (isLike)
|
|
|
|
|
Positioned(
|
|
|
|
|
right: 6,
|
|
|
|
|
bottom: 6,
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.all(4),
|
2025-12-17 00:27:09 +09:00
|
|
|
decoration: BoxDecoration(color: context.colorScheme.surfaceContainer, shape: BoxShape.circle),
|
|
|
|
|
child: Icon(Icons.thumb_up, color: context.primaryColor, size: 18),
|
2025-11-11 04:26:27 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Likes widget
|
|
|
|
|
Widget? likes;
|
|
|
|
|
if (isLike && !showThumbnail) {
|
|
|
|
|
likes = Container(
|
|
|
|
|
padding: const EdgeInsets.all(8),
|
2025-12-17 00:27:09 +09:00
|
|
|
decoration: BoxDecoration(color: context.colorScheme.surfaceContainer, shape: BoxShape.circle),
|
|
|
|
|
child: Icon(Icons.thumb_up, color: context.primaryColor, size: 18),
|
2025-11-11 04:26:27 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Comment bubble, comment-only
|
|
|
|
|
Widget? commentBubble;
|
|
|
|
|
if (activity.comment != null && activity.comment!.isNotEmpty) {
|
|
|
|
|
commentBubble = ConstrainedBox(
|
|
|
|
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5),
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.all(10),
|
|
|
|
|
decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(12))),
|
|
|
|
|
child: Text(
|
|
|
|
|
activity.comment ?? '',
|
|
|
|
|
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurface),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Combined content widgets
|
|
|
|
|
final List<Widget> contentChildren = [thumbnail, likes, commentBubble].whereType<Widget>().toList();
|
|
|
|
|
|
|
|
|
|
return DismissibleActivity(
|
|
|
|
|
onDismiss: canDelete ? (id) async => await activityNotifier.removeActivity(id) : null,
|
|
|
|
|
activity.id,
|
|
|
|
|
Align(
|
|
|
|
|
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
|
|
|
|
|
child: ConstrainedBox(
|
|
|
|
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86),
|
|
|
|
|
child: Container(
|
|
|
|
|
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
if (!isOwn) ...[avatar, const SizedBox(width: 8)],
|
|
|
|
|
// Content column
|
|
|
|
|
Column(
|
|
|
|
|
crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
...contentChildren.map((w) => Padding(padding: const EdgeInsets.only(bottom: 8.0), child: w)),
|
|
|
|
|
Text(
|
|
|
|
|
'${activity.user.name} • ${activity.createdAt.timeAgo()}',
|
|
|
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
|
|
|
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
if (isOwn) const SizedBox(width: 8),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|