From 36fb06b81ce47dca8d6cfa91259746e5d3776f71 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 14 Jun 2025 15:39:39 +0800 Subject: [PATCH] :sparkles: Post date --- assets/i18n/en-US.json | 7 +- lib/screens/account/profile.dart | 257 +++++++++++++++++++++-------- lib/screens/account/profile.g.dart | 244 +++++++++++++++++++++++++++ lib/screens/posts/detail.dart | 2 +- lib/widgets/post/post_item.dart | 11 ++ 5 files changed, 449 insertions(+), 72 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 8497434..efe1d0f 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -113,7 +113,8 @@ "addVideo": "Add video", "addPhoto": "Add photo", "addFile": "Add file", - "createDirectMessage": "New direct message", + "createDirectMessage": "Send new DM", + "gotoDirectMessage": "Go to DM", "react": "React", "reactions": { "zero": "Reactions", @@ -230,6 +231,7 @@ "creatorHubUnselectedHint": "Pick / create a publisher to get started.", "relationships": "Relationships", "addFriend": "Send a Friend Request", + "addFriendShort": "Add as Friend", "addFriendHint": "Add a friend to your relationship list.", "pendingRequest": "Pending", "waitingRequest": "Waiting", @@ -426,5 +428,6 @@ "checkInResultT3": "Good", "checkInResultT4": "Best", "accountProfileView": "View Profile", - "unspecified": "Unspecified" + "unspecified": "Unspecified", + "added": "Added" } diff --git a/lib/screens/account/profile.dart b/lib/screens/account/profile.dart index 9133d6b..e5202d6 100644 --- a/lib/screens/account/profile.dart +++ b/lib/screens/account/profile.dart @@ -1,8 +1,11 @@ import 'package:auto_route/auto_route.dart'; +import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/chat.dart'; +import 'package:island/models/relationship.dart'; import 'package:island/models/user.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/event_calendar.dart'; @@ -16,6 +19,7 @@ import 'package:island/widgets/account/badge.dart'; import 'package:island/widgets/account/fortune_graph.dart'; import 'package:island/widgets/account/leveling_progress.dart'; import 'package:island/widgets/account/status.dart'; +import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -62,6 +66,36 @@ Future accountAppbarForcegroundColor(Ref ref, String uname) async { return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; } +@riverpod +Future accountDirectChat(Ref ref, String uname) async { + final account = await ref.watch(accountProvider(uname).future); + final apiClient = ref.watch(apiClientProvider); + try { + final resp = await apiClient.get("/chat/direct/${account.id}"); + return SnChatRoom.fromJson(resp.data); + } catch (err) { + if (err is DioException && err.response?.statusCode == 404) { + return null; + } + rethrow; + } +} + +@riverpod +Future accountRelationship(Ref ref, String uname) async { + final account = await ref.watch(accountProvider(uname).future); + final apiClient = ref.watch(apiClientProvider); + try { + final resp = await apiClient.get("/relationships/${account.id}"); + return SnRelationship.fromJson(resp.data); + } catch (err) { + if (err is DioException && err.response?.statusCode == 404) { + return null; + } + rethrow; + } +} + @RoutePage() class AccountProfileScreen extends HookConsumerWidget { final String name; @@ -80,6 +114,9 @@ class AccountProfileScreen extends HookConsumerWidget { EventCalendarQuery(uname: name, year: now.year, month: now.month), ), ); + final accountChat = ref.watch(accountDirectChatProvider(name)); + final accountRelationship = ref.watch(accountRelationshipProvider(name)); + final appbarColor = ref.watch(accountAppbarForcegroundColorProvider(name)); final appbarShadow = Shadow( @@ -88,6 +125,100 @@ class AccountProfileScreen extends HookConsumerWidget { offset: Offset(1.0, 1.0), ); + Future relationshipAction() async { + if (accountRelationship.value != null) return; + showLoadingModal(context); + try { + final client = ref.watch(apiClientProvider); + await client.post('/relationships/${account.value!.id}/friends'); + ref.invalidate(accountRelationshipProvider(name)); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + } + + Future directMessageAction() async { + if (!account.hasValue) return; + if (accountChat.value != null) { + context.router.pushPath('/chat/${accountChat.value!.id}'); + return; + } + showLoadingModal(context); + try { + final client = ref.watch(apiClientProvider); + final resp = await client.post( + '/chat/direct', + data: {'related_user_id': account.value!.id}, + ); + final chat = SnChatRoom.fromJson(resp.data); + if (context.mounted) context.router.pushPath('/chat/${chat.id}'); + ref.invalidate(accountDirectChatProvider(name)); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + } + + List buildSubcolumn(SnAccount data) { + return [ + if (data.profile.birthday != null) + Row( + spacing: 6, + children: [ + const Icon(Symbols.cake, size: 17, fill: 1), + Text(data.profile.birthday!.formatCustom('yyyy-MM-dd')), + Text('·').bold(), + Text( + '${DateTime.now().difference(data.profile.birthday!).inDays ~/ 365} yrs old', + ), + ], + ), + if (data.profile.location.isNotEmpty) + Row( + spacing: 6, + children: [ + const Icon(Symbols.location_on, size: 17, fill: 1), + Text(data.profile.location), + ], + ), + if (data.profile.pronouns.isNotEmpty || data.profile.gender.isNotEmpty) + Row( + spacing: 6, + children: [ + const Icon(Symbols.person, size: 17, fill: 1), + Text( + data.profile.gender.isEmpty + ? 'unspecified'.tr() + : data.profile.gender, + ), + Text('·').bold(), + Text( + data.profile.pronouns.isEmpty + ? 'unspecified'.tr() + : data.profile.pronouns, + ), + ], + ), + if (data.profile.firstName.isNotEmpty || + data.profile.middleName.isNotEmpty || + data.profile.lastName.isNotEmpty) + Row( + spacing: 6, + children: [ + const Icon(Symbols.id_card, size: 17, fill: 1), + if (data.profile.firstName.isNotEmpty) + Text(data.profile.firstName), + if (data.profile.middleName.isNotEmpty) + Text(data.profile.middleName), + if (data.profile.lastName.isNotEmpty) Text(data.profile.lastName), + ], + ), + ]; + } + return account.when( data: (data) => AppScaffold( @@ -196,74 +327,12 @@ class AccountProfileScreen extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 24, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 2, - children: [ - if (data.profile.birthday != null) - Row( - spacing: 6, - children: [ - const Icon(Symbols.cake, size: 17, fill: 1), - Text( - data.profile.birthday!.formatCustom( - 'yyyy-MM-dd', - ), - ), - Text('·').bold(), - Text( - '${DateTime.now().difference(data.profile.birthday!).inDays ~/ 365} yrs old', - ), - ], - ), - if (data.profile.location.isNotEmpty) - Row( - spacing: 6, - children: [ - const Icon( - Symbols.location_on, - size: 17, - fill: 1, - ), - Text(data.profile.location), - ], - ), - if (data.profile.pronouns.isNotEmpty || - data.profile.gender.isNotEmpty) - Row( - spacing: 6, - children: [ - const Icon(Symbols.person, size: 17, fill: 1), - Text( - data.profile.gender.isEmpty - ? 'unspecified'.tr() - : data.profile.gender, - ), - Text('·').bold(), - Text( - data.profile.pronouns.isEmpty - ? 'unspecified'.tr() - : data.profile.pronouns, - ), - ], - ), - if (data.profile.firstName.isNotEmpty || - data.profile.middleName.isNotEmpty || - data.profile.lastName.isNotEmpty) - Row( - spacing: 6, - children: [ - const Icon(Symbols.id_card, size: 17, fill: 1), - if (data.profile.firstName.isNotEmpty) - Text(data.profile.firstName), - if (data.profile.middleName.isNotEmpty) - Text(data.profile.middleName), - if (data.profile.lastName.isNotEmpty) - Text(data.profile.lastName), - ], - ), - ], - ), + if (buildSubcolumn(data).isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: buildSubcolumn(data), + ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -308,7 +377,57 @@ class AccountProfileScreen extends HookConsumerWidget { ), SliverToBoxAdapter( - child: const Divider(height: 1).padding(top: 24), + child: const Divider(height: 1).padding(top: 24, bottom: 12), + ), + SliverToBoxAdapter( + child: Row( + spacing: 8, + children: [ + Expanded( + child: FilledButton.icon( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + accountRelationship.value == null + ? null + : Theme.of(context).colorScheme.secondary, + ), + foregroundColor: WidgetStatePropertyAll( + accountRelationship.value == null + ? null + : Theme.of(context).colorScheme.onSecondary, + ), + ), + onPressed: relationshipAction, + label: + Text( + accountRelationship.value == null + ? 'addFriendShort' + : 'added', + ).tr(), + icon: + accountRelationship.value == null + ? const Icon(Symbols.person_add) + : const Icon(Symbols.person_check), + ), + ), + Expanded( + child: FilledButton.icon( + onPressed: directMessageAction, + icon: const Icon(Symbols.message), + label: + Text( + accountChat.value == null + ? 'createDirectMessage' + : 'gotoDirectMessage', + maxLines: 1, + ).tr(), + ), + ), + ], + ).padding(horizontal: 16), + ), + SliverToBoxAdapter( + child: const Divider(height: 1).padding(top: 12), ), SliverToBoxAdapter( child: Column( diff --git a/lib/screens/account/profile.g.dart b/lib/screens/account/profile.g.dart index 9a29e06..71bf551 100644 --- a/lib/screens/account/profile.g.dart +++ b/lib/screens/account/profile.g.dart @@ -395,5 +395,249 @@ class _AccountAppbarForcegroundColorProviderElement String get uname => (origin as AccountAppbarForcegroundColorProvider).uname; } +String _$accountDirectChatHash() => r'60d0015fc2a3c8fc2190bb41d6818cf3027d9d0a'; + +/// See also [accountDirectChat]. +@ProviderFor(accountDirectChat) +const accountDirectChatProvider = AccountDirectChatFamily(); + +/// See also [accountDirectChat]. +class AccountDirectChatFamily extends Family> { + /// See also [accountDirectChat]. + const AccountDirectChatFamily(); + + /// See also [accountDirectChat]. + AccountDirectChatProvider call(String uname) { + return AccountDirectChatProvider(uname); + } + + @override + AccountDirectChatProvider getProviderOverride( + covariant AccountDirectChatProvider provider, + ) { + return call(provider.uname); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'accountDirectChatProvider'; +} + +/// See also [accountDirectChat]. +class AccountDirectChatProvider extends AutoDisposeFutureProvider { + /// See also [accountDirectChat]. + AccountDirectChatProvider(String uname) + : this._internal( + (ref) => accountDirectChat(ref as AccountDirectChatRef, uname), + from: accountDirectChatProvider, + name: r'accountDirectChatProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$accountDirectChatHash, + dependencies: AccountDirectChatFamily._dependencies, + allTransitiveDependencies: + AccountDirectChatFamily._allTransitiveDependencies, + uname: uname, + ); + + AccountDirectChatProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.uname, + }) : super.internal(); + + final String uname; + + @override + Override overrideWith( + FutureOr Function(AccountDirectChatRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: AccountDirectChatProvider._internal( + (ref) => create(ref as AccountDirectChatRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + uname: uname, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _AccountDirectChatProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is AccountDirectChatProvider && other.uname == uname; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, uname.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin AccountDirectChatRef on AutoDisposeFutureProviderRef { + /// The parameter `uname` of this provider. + String get uname; +} + +class _AccountDirectChatProviderElement + extends AutoDisposeFutureProviderElement + with AccountDirectChatRef { + _AccountDirectChatProviderElement(super.provider); + + @override + String get uname => (origin as AccountDirectChatProvider).uname; +} + +String _$accountRelationshipHash() => + r'cb7d0d3f8cd4f23ad9d2d529872c540dac483d4f'; + +/// See also [accountRelationship]. +@ProviderFor(accountRelationship) +const accountRelationshipProvider = AccountRelationshipFamily(); + +/// See also [accountRelationship]. +class AccountRelationshipFamily extends Family> { + /// See also [accountRelationship]. + const AccountRelationshipFamily(); + + /// See also [accountRelationship]. + AccountRelationshipProvider call(String uname) { + return AccountRelationshipProvider(uname); + } + + @override + AccountRelationshipProvider getProviderOverride( + covariant AccountRelationshipProvider provider, + ) { + return call(provider.uname); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'accountRelationshipProvider'; +} + +/// See also [accountRelationship]. +class AccountRelationshipProvider + extends AutoDisposeFutureProvider { + /// See also [accountRelationship]. + AccountRelationshipProvider(String uname) + : this._internal( + (ref) => accountRelationship(ref as AccountRelationshipRef, uname), + from: accountRelationshipProvider, + name: r'accountRelationshipProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$accountRelationshipHash, + dependencies: AccountRelationshipFamily._dependencies, + allTransitiveDependencies: + AccountRelationshipFamily._allTransitiveDependencies, + uname: uname, + ); + + AccountRelationshipProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.uname, + }) : super.internal(); + + final String uname; + + @override + Override overrideWith( + FutureOr Function(AccountRelationshipRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: AccountRelationshipProvider._internal( + (ref) => create(ref as AccountRelationshipRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + uname: uname, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _AccountRelationshipProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is AccountRelationshipProvider && other.uname == uname; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, uname.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin AccountRelationshipRef on AutoDisposeFutureProviderRef { + /// The parameter `uname` of this provider. + String get uname; +} + +class _AccountRelationshipProviderElement + extends AutoDisposeFutureProviderElement + with AccountRelationshipRef { + _AccountRelationshipProviderElement(super.provider); + + @override + String get uname => (origin as AccountRelationshipProvider).uname; +} + // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/posts/detail.dart b/lib/screens/posts/detail.dart index 0c4e846..83c671a 100644 --- a/lib/screens/posts/detail.dart +++ b/lib/screens/posts/detail.dart @@ -47,6 +47,7 @@ class PostDetailScreen extends HookConsumerWidget { PostItem( item: post!, isOpenable: false, + isFullPost: true, backgroundColor: isWide ? Colors.transparent : null, ), const Divider(height: 1), @@ -63,7 +64,6 @@ class PostDetailScreen extends HookConsumerWidget { right: 0, child: Material( elevation: 2, - color: Colors.transparent, child: PostQuickReply( parent: post, onPosted: () { diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 073c019..2a91475 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -5,12 +5,14 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/post.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/route.gr.dart'; import 'package:island/services/responsive.dart'; +import 'package:island/services/time.dart'; import 'package:island/widgets/account/account_name.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; @@ -26,6 +28,7 @@ class PostItem extends HookConsumerWidget { final SnPost item; final EdgeInsets? padding; final bool isOpenable; + final bool isFullPost; final bool showReferencePost; final Function? onRefresh; final Function(SnPost)? onUpdate; @@ -35,6 +38,7 @@ class PostItem extends HookConsumerWidget { this.backgroundColor, this.padding, this.isOpenable = true, + this.isFullPost = false, this.showReferencePost = true, this.onRefresh, this.onUpdate, @@ -153,6 +157,13 @@ class PostItem extends HookConsumerWidget { VerificationMark( mark: item.publisher.verification!, ).padding(left: 4), + Spacer(), + Text( + isFullPost + ? item.publishedAt.formatSystem() + : item.publishedAt.formatRelative(context), + ).fontSize(11).alignment(Alignment.bottomRight), + const Gap(4), ], ), // Add visibility indicator if not public (visibility != 0)