mirror of https://github.com/immich-app/immich.git
feat(mobile): shared album activities (#4833)
* fix(server): global activity like duplicate search * mobile: user_circle_avatar - fallback to text icon if no profile pic available * mobile: use favourite icon in search "your activity" * feat(mobile): shared album activities * mobile: align hearts with user profile icon * styling * replace bottom sheet with dismissible * add auto focus to the input --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>pull/4881/head
parent
c74ea7282a
commit
26fd9d7e5f
@ -0,0 +1,90 @@
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
enum ActivityType { comment, like }
|
||||
|
||||
class Activity {
|
||||
final String id;
|
||||
final String? assetId;
|
||||
final String? comment;
|
||||
final DateTime createdAt;
|
||||
final ActivityType type;
|
||||
final User user;
|
||||
|
||||
const Activity({
|
||||
required this.id,
|
||||
this.assetId,
|
||||
this.comment,
|
||||
required this.createdAt,
|
||||
required this.type,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
Activity copyWith({
|
||||
String? id,
|
||||
String? assetId,
|
||||
String? comment,
|
||||
DateTime? createdAt,
|
||||
ActivityType? type,
|
||||
User? user,
|
||||
}) {
|
||||
return Activity(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
comment: comment ?? this.comment,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
|
||||
Activity.fromDto(ActivityResponseDto dto)
|
||||
: id = dto.id,
|
||||
assetId = dto.assetId,
|
||||
comment = dto.comment,
|
||||
createdAt = dto.createdAt,
|
||||
type = dto.type == ActivityResponseDtoTypeEnum.comment
|
||||
? ActivityType.comment
|
||||
: ActivityType.like,
|
||||
user = User(
|
||||
email: dto.user.email,
|
||||
firstName: dto.user.firstName,
|
||||
lastName: dto.user.lastName,
|
||||
profileImagePath: dto.user.profileImagePath,
|
||||
id: dto.user.id,
|
||||
// Placeholder values
|
||||
isAdmin: false,
|
||||
updatedAt: DateTime.now(),
|
||||
isPartnerSharedBy: false,
|
||||
isPartnerSharedWith: false,
|
||||
memoryEnabled: false,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is Activity &&
|
||||
other.id == id &&
|
||||
other.assetId == assetId &&
|
||||
other.comment == comment &&
|
||||
other.createdAt == createdAt &&
|
||||
other.type == type &&
|
||||
other.user == user;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
assetId.hashCode ^
|
||||
comment.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
type.hashCode ^
|
||||
user.hashCode;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
|
||||
import 'package:immich_mobile/modules/activities/services/activity.service.dart';
|
||||
|
||||
class ActivityNotifier extends StateNotifier<AsyncValue<List<Activity>>> {
|
||||
final Ref _ref;
|
||||
final ActivityService _activityService;
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
|
||||
ActivityNotifier(
|
||||
this._ref,
|
||||
this._activityService,
|
||||
this.albumId,
|
||||
this.assetId,
|
||||
) : super(
|
||||
const AsyncData([]),
|
||||
) {
|
||||
fetchActivity();
|
||||
}
|
||||
|
||||
Future<void> fetchActivity() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => _activityService.getAllActivities(albumId, assetId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeActivity(String id) async {
|
||||
final activities = state.asData?.value ?? [];
|
||||
if (await _activityService.removeActivity(id)) {
|
||||
final removedActivity = activities.firstWhere((a) => a.id == id);
|
||||
activities.remove(removedActivity);
|
||||
state = AsyncData(activities);
|
||||
if (removedActivity.type == ActivityType.comment) {
|
||||
_ref
|
||||
.read(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: albumId, assetId: assetId),
|
||||
).notifier,
|
||||
)
|
||||
.removeActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addComment(String comment) async {
|
||||
final activity = await _activityService.addActivity(
|
||||
albumId,
|
||||
ActivityType.comment,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
);
|
||||
|
||||
if (activity != null) {
|
||||
final activities = state.asData?.value ?? [];
|
||||
state = AsyncData([...activities, activity]);
|
||||
_ref
|
||||
.read(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: albumId, assetId: assetId),
|
||||
).notifier,
|
||||
)
|
||||
.addActivity();
|
||||
if (assetId != null) {
|
||||
// Add a count to the current album's provider as well
|
||||
_ref
|
||||
.read(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: albumId, assetId: null),
|
||||
).notifier,
|
||||
)
|
||||
.addActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addLike() async {
|
||||
final activity = await _activityService
|
||||
.addActivity(albumId, ActivityType.like, assetId: assetId);
|
||||
if (activity != null) {
|
||||
final activities = state.asData?.value ?? [];
|
||||
state = AsyncData([...activities, activity]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ActivityStatisticsNotifier extends StateNotifier<int> {
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
final ActivityService _activityService;
|
||||
ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId)
|
||||
: super(0) {
|
||||
fetchStatistics();
|
||||
}
|
||||
|
||||
Future<void> fetchStatistics() async {
|
||||
state = await _activityService.getStatistics(albumId, assetId: assetId);
|
||||
}
|
||||
|
||||
Future<void> addActivity() async {
|
||||
state = state + 1;
|
||||
}
|
||||
|
||||
Future<void> removeActivity() async {
|
||||
state = state - 1;
|
||||
}
|
||||
}
|
||||
|
||||
typedef ActivityParams = ({String albumId, String? assetId});
|
||||
|
||||
final activityStateProvider = StateNotifierProvider.autoDispose
|
||||
.family<ActivityNotifier, AsyncValue<List<Activity>>, ActivityParams>(
|
||||
(ref, args) {
|
||||
return ActivityNotifier(
|
||||
ref,
|
||||
ref.watch(activityServiceProvider),
|
||||
args.albumId,
|
||||
args.assetId,
|
||||
);
|
||||
});
|
||||
|
||||
final activityStatisticsStateProvider = StateNotifierProvider.autoDispose
|
||||
.family<ActivityStatisticsNotifier, int, ActivityParams>((ref, args) {
|
||||
return ActivityStatisticsNotifier(
|
||||
ref.watch(activityServiceProvider),
|
||||
args.albumId,
|
||||
args.assetId,
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,85 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final activityServiceProvider =
|
||||
Provider((ref) => ActivityService(ref.watch(apiServiceProvider)));
|
||||
|
||||
class ActivityService {
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("ActivityService");
|
||||
|
||||
ActivityService(this._apiService);
|
||||
|
||||
Future<List<Activity>> getAllActivities(
|
||||
String albumId,
|
||||
String? assetId,
|
||||
) async {
|
||||
try {
|
||||
final list = await _apiService.activityApi
|
||||
.getActivities(albumId, assetId: assetId);
|
||||
return list != null ? list.map(Activity.fromDto).toList() : [];
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to fetch activities for albumId - $albumId; assetId - $assetId -> $e",
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> getStatistics(String albumId, {String? assetId}) async {
|
||||
try {
|
||||
final dto = await _apiService.activityApi
|
||||
.getActivityStatistics(albumId, assetId: assetId);
|
||||
return dto?.comments ?? 0;
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to fetch activity statistics for albumId - $albumId; assetId - $assetId -> $e",
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Future<bool> removeActivity(String id) async {
|
||||
try {
|
||||
await _apiService.activityApi.deleteActivity(id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to remove activity id - $id -> $e",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<Activity?> addActivity(
|
||||
String albumId,
|
||||
ActivityType type, {
|
||||
String? assetId,
|
||||
String? comment,
|
||||
}) async {
|
||||
try {
|
||||
final dto = await _apiService.activityApi.createActivity(
|
||||
ActivityCreateDto(
|
||||
albumId: albumId,
|
||||
type: type == ActivityType.comment
|
||||
? ReactionType.comment
|
||||
: ReactionType.like,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
),
|
||||
);
|
||||
if (dto != null) {
|
||||
return Activity.fromDto(dto);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to add activity for albumId - $albumId; assetId - $assetId -> $e",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,312 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/utils/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class ActivitiesPage extends HookConsumerWidget {
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
final bool withAssetThumbs;
|
||||
final String appBarTitle;
|
||||
final bool isOwner;
|
||||
const ActivitiesPage(
|
||||
this.albumId, {
|
||||
this.appBarTitle = "",
|
||||
this.assetId,
|
||||
this.withAssetThumbs = true,
|
||||
this.isOwner = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider =
|
||||
activityStateProvider((albumId: albumId, assetId: assetId));
|
||||
final activities = ref.watch(provider);
|
||||
final inputController = useTextEditingController();
|
||||
final inputFocusNode = useFocusNode();
|
||||
final listViewScrollController = useScrollController();
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
inputFocusNode.requestFocus();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
buildTitleWithTimestamp(Activity activity, {bool leftAlign = true}) {
|
||||
final textColor = Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black;
|
||||
final textStyle = Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: textColor.withOpacity(0.6));
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: leftAlign
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: leftAlign ? MainAxisSize.min : MainAxisSize.max,
|
||||
children: [
|
||||
Text(
|
||||
"${activity.user.firstName} ${activity.user.lastName}",
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (leftAlign)
|
||||
Text(
|
||||
" • ",
|
||||
style: textStyle,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
activity.createdAt.copyWith().timeAgo(),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: leftAlign ? TextAlign.left : TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
buildAssetThumbnail(Activity activity) {
|
||||
return withAssetThumbs && activity.assetId != null
|
||||
? Container(
|
||||
width: 40,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
getThumbnailUrlForRemoteId(
|
||||
activity.assetId!,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKeyForRemoteId(
|
||||
activity.assetId!,
|
||||
),
|
||||
headers: {
|
||||
"Authorization":
|
||||
'Bearer ${Store.get(StoreKey.accessToken)}',
|
||||
},
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: const SizedBox.shrink(),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
buildTextField(String? likedId) {
|
||||
final liked = likedId != null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: TextField(
|
||||
controller: inputController,
|
||||
focusNode: inputFocusNode,
|
||||
textInputAction: TextInputAction.send,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
prefixIcon: currentUser != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: UserCircleAvatar(
|
||||
user: currentUser,
|
||||
size: 30,
|
||||
radius: 15,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
liked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
),
|
||||
onPressed: () async {
|
||||
liked
|
||||
? await ref
|
||||
.read(provider.notifier)
|
||||
.removeActivity(likedId)
|
||||
: await ref.read(provider.notifier).addLike();
|
||||
},
|
||||
),
|
||||
),
|
||||
suffixIconColor: liked ? Colors.red[700] : null,
|
||||
hintText: 'shared_album_activities_input_hint'.tr(),
|
||||
hintStyle: TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
onEditingComplete: () async {
|
||||
await ref.read(provider.notifier).addComment(inputController.text);
|
||||
inputController.clear();
|
||||
inputFocusNode.unfocus();
|
||||
listViewScrollController.animateTo(
|
||||
listViewScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
},
|
||||
onTapOutside: (_) => inputFocusNode.unfocus(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getDismissibleWidget(
|
||||
Widget widget,
|
||||
Activity activity,
|
||||
bool canDelete,
|
||||
) {
|
||||
return Dismissible(
|
||||
key: Key(activity.id),
|
||||
dismissThresholds: const {
|
||||
DismissDirection.horizontal: 0.7,
|
||||
},
|
||||
direction: DismissDirection.horizontal,
|
||||
confirmDismiss: (direction) => canDelete
|
||||
? showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
onOk: () {},
|
||||
title: "shared_album_activity_remove_title",
|
||||
content: "shared_album_activity_remove_content",
|
||||
ok: "delete_dialog_ok",
|
||||
),
|
||||
)
|
||||
: Future.value(false),
|
||||
onDismissed: (direction) async =>
|
||||
await ref.read(provider.notifier).removeActivity(activity.id),
|
||||
background: Container(
|
||||
color: canDelete ? Colors.red[400] : Colors.grey[600],
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: canDelete
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: Icon(
|
||||
Icons.delete_sweep_rounded,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
secondaryBackground: Container(
|
||||
color: canDelete ? Colors.red[400] : Colors.grey[600],
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: canDelete
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: Icon(
|
||||
Icons.delete_sweep_rounded,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(appBarTitle)),
|
||||
body: activities.maybeWhen(
|
||||
orElse: () {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
},
|
||||
data: (data) {
|
||||
final liked = data.firstWhereOrNull(
|
||||
(a) =>
|
||||
a.type == ActivityType.like &&
|
||||
a.user.id == currentUser?.id &&
|
||||
a.assetId == assetId,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: listViewScrollController,
|
||||
itemCount: data.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
// Vertical gap after the last element
|
||||
if (index == data.length) {
|
||||
return const SizedBox(
|
||||
height: 80,
|
||||
);
|
||||
}
|
||||
|
||||
final activity = data[index];
|
||||
final canDelete =
|
||||
activity.user.id == currentUser?.id || isOwner;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: activity.type == ActivityType.comment
|
||||
? getDismissibleWidget(
|
||||
ListTile(
|
||||
minVerticalPadding: 15,
|
||||
leading: UserCircleAvatar(user: activity.user),
|
||||
title: buildTitleWithTimestamp(
|
||||
activity,
|
||||
leftAlign:
|
||||
withAssetThumbs && activity.assetId != null,
|
||||
),
|
||||
titleAlignment: ListTileTitleAlignment.top,
|
||||
trailing: buildAssetThumbnail(activity),
|
||||
subtitle: Text(activity.comment!),
|
||||
),
|
||||
activity,
|
||||
canDelete,
|
||||
)
|
||||
: getDismissibleWidget(
|
||||
ListTile(
|
||||
minVerticalPadding: 15,
|
||||
leading: Container(
|
||||
width: 44,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Colors.red[700],
|
||||
),
|
||||
),
|
||||
title: buildTitleWithTimestamp(activity),
|
||||
trailing: buildAssetThumbnail(activity),
|
||||
),
|
||||
activity,
|
||||
canDelete,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: buildTextField(liked?.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
extension TimeAgoExtension on DateTime {
|
||||
String timeAgo({bool numericDates = true}) {
|
||||
DateTime date = toLocal();
|
||||
final date2 = DateTime.now().toLocal();
|
||||
final difference = date2.difference(date);
|
||||
|
||||
if (difference.inSeconds < 5) {
|
||||
return 'Just now';
|
||||
} else if (difference.inSeconds < 60) {
|
||||
return '${difference.inSeconds} seconds ago';
|
||||
} else if (difference.inMinutes <= 1) {
|
||||
return (numericDates) ? '1 minute ago' : 'A minute ago';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} minutes ago';
|
||||
} else if (difference.inHours <= 1) {
|
||||
return (numericDates) ? '1 hour ago' : 'An hour ago';
|
||||
} else if (difference.inHours < 60) {
|
||||
return '${difference.inHours} hours ago';
|
||||
} else if (difference.inDays <= 1) {
|
||||
return (numericDates) ? '1 day ago' : 'Yesterday';
|
||||
} else if (difference.inDays < 6) {
|
||||
return '${difference.inDays} days ago';
|
||||
} else if ((difference.inDays / 7).ceil() <= 1) {
|
||||
return (numericDates) ? '1 week ago' : 'Last week';
|
||||
} else if ((difference.inDays / 7).ceil() < 4) {
|
||||
return '${(difference.inDays / 7).ceil()} weeks ago';
|
||||
} else if ((difference.inDays / 30).ceil() <= 1) {
|
||||
return (numericDates) ? '1 month ago' : 'Last month';
|
||||
} else if ((difference.inDays / 30).ceil() < 30) {
|
||||
return '${(difference.inDays / 30).ceil()} months ago';
|
||||
} else if ((difference.inDays / 365).ceil() <= 1) {
|
||||
return (numericDates) ? '1 year ago' : 'Last year';
|
||||
}
|
||||
return '${(difference.inDays / 365).floor()} years ago';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue