From b8dec9f798973deaee4b6944af6d42bc66679bad Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 28 Jun 2025 00:24:43 +0800 Subject: [PATCH] :sparkles: Joindable chat, detailed realms, discovery mixed into explore :bug: bunch of bugs fixes --- assets/i18n/en-US.json | 9 +- ios/Runner.xcodeproj/project.pbxproj | 22 ++-- lib/database/message_repository.dart | 55 ++++---- lib/models/chat.dart | 4 +- lib/models/chat.freezed.dart | 6 +- lib/models/chat.g.dart | 4 +- lib/models/realm.dart | 2 +- lib/models/realm.freezed.dart | 4 +- lib/models/realm.g.dart | 2 +- lib/screens/chat/chat.dart | 26 +++- lib/screens/chat/chat.g.dart | 4 +- lib/screens/chat/room.dart | 74 ++++++++++- lib/screens/explore.dart | 146 ++++++++++++---------- lib/screens/explore.g.dart | 2 +- lib/screens/realm/detail.dart | 8 +- lib/widgets/app_wrapper.dart | 3 +- lib/widgets/publisher/publisher_card.dart | 100 +++++++++++++++ lib/widgets/realm/realm_card.dart | 69 ++++++---- 18 files changed, 393 insertions(+), 147 deletions(-) create mode 100644 lib/widgets/publisher/publisher_card.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 8361aea..002ccc9 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -556,5 +556,12 @@ "tags": "Tags", "tagsHint": "Enter tags, separated by commas", "categories": "Categories", - "categoriesHint": "Enter categories, separated by commas" + "categoriesHint": "Enter categories, separated by commas", + "chatNotJoined": "You have not joined this chat yet.", + "chatUnableJoin": "You can't join this chat due to it's access control settings.", + "chatJoin": "Join the Chat", + "realmJoin": "Join the Realm", + "realmJoinSuccess": "Successfully joined the realm.", + "discoverRealms": "Discover Realms", + "discoverPublishers": "Discover Publishers" } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 251b90d..84228ef 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -525,10 +525,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -586,10 +590,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -857,7 +865,7 @@ INFOPLIST_FILE = SolianShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -900,7 +908,7 @@ INFOPLIST_FILE = SolianShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -940,7 +948,7 @@ INFOPLIST_FILE = SolianShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -979,7 +987,7 @@ INFOPLIST_FILE = SolianNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1021,7 +1029,7 @@ INFOPLIST_FILE = SolianNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1060,7 +1068,7 @@ INFOPLIST_FILE = SolianNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/lib/database/message_repository.dart b/lib/database/message_repository.dart index 03747fc..234c735 100644 --- a/lib/database/message_repository.dart +++ b/lib/database/message_repository.dart @@ -71,25 +71,32 @@ class MessageRepository { bool synced = false, }) async { try { + // For initial load, fetch latest messages in the background to sync. + if (offset == 0 && !synced) { + // Not awaiting this is intentional, for a quicker UI response. + // The UI should rely on a stream from the database to get updates. + _fetchAndCacheMessages(room.id, offset: 0, take: take).catchError((_) { + // Best effort, errors will be handled by later fetches. + return []; + }); + } + final localMessages = await _getCachedMessages( room.id, offset: offset, take: take, ); - // If it already synced with the remote, skip this - if (offset == 0 && !synced) { - // Fetch latest messages - _fetchAndCacheMessages(room.id, offset: offset, take: take); - - if (localMessages.isNotEmpty) { - return localMessages; - } + // If local cache has messages, return them. This is the common case for scrolling up. + if (localMessages.isNotEmpty) { + return localMessages; } + // If local cache is empty, we've probably reached the end of cached history. + // Fetch from remote. This will also be hit on first load if cache is empty. return await _fetchAndCacheMessages(room.id, offset: offset, take: take); } catch (e) { - // If API fails but we have local messages, return them + // Final fallback to cache in case of network errors during fetch. final localMessages = await _getCachedMessages( room.id, offset: offset, @@ -117,24 +124,26 @@ class MessageRepository { final dbLocalMessages = dbMessages.map(_database.companionToMessage).toList(); - // Combine with pending messages - final pendingForRoom = - pendingMessages.values.where((msg) => msg.roomId == roomId).toList(); + // Combine with pending messages for the first page + if (offset == 0) { + final pendingForRoom = + pendingMessages.values.where((msg) => msg.roomId == roomId).toList(); - // Sort by timestamp descending (newest first) - final allMessages = [...pendingForRoom, ...dbLocalMessages]; - allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final allMessages = [...pendingForRoom, ...dbLocalMessages]; + allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); - // Apply pagination - if (offset >= allMessages.length) { - return []; + // Remove duplicates by ID, preserving the order + final uniqueMessages = []; + final seenIds = {}; + for (final message in allMessages) { + if (seenIds.add(message.id)) { + uniqueMessages.add(message); + } + } + return uniqueMessages; } - final end = - (offset + take) > allMessages.length - ? allMessages.length - : (offset + take); - return allMessages.sublist(offset, end); + return dbLocalMessages; } Future> _fetchAndCacheMessages( diff --git a/lib/models/chat.dart b/lib/models/chat.dart index 3b55b6b..bc714b0 100644 --- a/lib/models/chat.dart +++ b/lib/models/chat.dart @@ -13,8 +13,8 @@ sealed class SnChatRoom with _$SnChatRoom { required String? name, required String? description, required int type, - required bool isPublic, - required bool isCommunity, + @Default(false) bool isPublic, + @Default(false) bool isCommunity, required SnCloudFile? picture, required SnCloudFile? background, required String? realmId, diff --git a/lib/models/chat.freezed.dart b/lib/models/chat.freezed.dart index 966ca0c..8352d6e 100644 --- a/lib/models/chat.freezed.dart +++ b/lib/models/chat.freezed.dart @@ -129,15 +129,15 @@ $SnRealmCopyWith<$Res>? get realm { @JsonSerializable() class _SnChatRoom implements SnChatRoom { - const _SnChatRoom({required this.id, required this.name, required this.description, required this.type, required this.isPublic, required this.isCommunity, required this.picture, required this.background, required this.realmId, required this.realm, required this.createdAt, required this.updatedAt, required this.deletedAt, required final List? members}): _members = members; + const _SnChatRoom({required this.id, required this.name, required this.description, required this.type, this.isPublic = false, this.isCommunity = false, required this.picture, required this.background, required this.realmId, required this.realm, required this.createdAt, required this.updatedAt, required this.deletedAt, required final List? members}): _members = members; factory _SnChatRoom.fromJson(Map json) => _$SnChatRoomFromJson(json); @override final String id; @override final String? name; @override final String? description; @override final int type; -@override final bool isPublic; -@override final bool isCommunity; +@override@JsonKey() final bool isPublic; +@override@JsonKey() final bool isCommunity; @override final SnCloudFile? picture; @override final SnCloudFile? background; @override final String? realmId; diff --git a/lib/models/chat.g.dart b/lib/models/chat.g.dart index b5e3134..1dca414 100644 --- a/lib/models/chat.g.dart +++ b/lib/models/chat.g.dart @@ -11,8 +11,8 @@ _SnChatRoom _$SnChatRoomFromJson(Map json) => _SnChatRoom( name: json['name'] as String?, description: json['description'] as String?, type: (json['type'] as num).toInt(), - isPublic: json['is_public'] as bool, - isCommunity: json['is_community'] as bool, + isPublic: json['is_public'] as bool? ?? false, + isCommunity: json['is_community'] as bool? ?? false, picture: json['picture'] == null ? null diff --git a/lib/models/realm.dart b/lib/models/realm.dart index d6e985e..31ed6e3 100644 --- a/lib/models/realm.dart +++ b/lib/models/realm.dart @@ -10,7 +10,7 @@ sealed class SnRealm with _$SnRealm { const factory SnRealm({ required String id, required String slug, - required String name, + @Default('') String name, @Default('') String description, required String? verifiedAs, required DateTime? verifiedAt, diff --git a/lib/models/realm.freezed.dart b/lib/models/realm.freezed.dart index dd43b27..49fd407 100644 --- a/lib/models/realm.freezed.dart +++ b/lib/models/realm.freezed.dart @@ -117,12 +117,12 @@ $SnCloudFileCopyWith<$Res>? get background { @JsonSerializable() class _SnRealm implements SnRealm { - const _SnRealm({required this.id, required this.slug, required this.name, this.description = '', required this.verifiedAs, required this.verifiedAt, required this.isCommunity, required this.isPublic, required this.picture, required this.background, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); + const _SnRealm({required this.id, required this.slug, this.name = '', this.description = '', required this.verifiedAs, required this.verifiedAt, required this.isCommunity, required this.isPublic, required this.picture, required this.background, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); factory _SnRealm.fromJson(Map json) => _$SnRealmFromJson(json); @override final String id; @override final String slug; -@override final String name; +@override@JsonKey() final String name; @override@JsonKey() final String description; @override final String? verifiedAs; @override final DateTime? verifiedAt; diff --git a/lib/models/realm.g.dart b/lib/models/realm.g.dart index 876bc83..3283d37 100644 --- a/lib/models/realm.g.dart +++ b/lib/models/realm.g.dart @@ -9,7 +9,7 @@ part of 'realm.dart'; _SnRealm _$SnRealmFromJson(Map json) => _SnRealm( id: json['id'] as String, slug: json['slug'] as String, - name: json['name'] as String, + name: json['name'] as String? ?? '', description: json['description'] as String? ?? '', verifiedAs: json['verified_as'] as String?, verifiedAt: diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index b7cf628..cdb5bf9 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -434,17 +434,31 @@ class ChatListScreen extends HookConsumerWidget { @riverpod Future chatroom(Ref ref, String? identifier) async { if (identifier == null) return null; - final client = ref.watch(apiClientProvider); - final resp = await client.get('/chat/$identifier'); - return SnChatRoom.fromJson(resp.data); + try { + final client = ref.watch(apiClientProvider); + final resp = await client.get('/chat/$identifier'); + return SnChatRoom.fromJson(resp.data); + } catch (err) { + if (err is DioException && err.response?.statusCode == 404) { + return null; // Chat room not found + } + rethrow; // Rethrow other errors + } } @riverpod Future chatroomIdentity(Ref ref, String? identifier) async { if (identifier == null) return null; - final client = ref.watch(apiClientProvider); - final resp = await client.get('/chat/$identifier/members/me'); - return SnChatMember.fromJson(resp.data); + try { + final client = ref.watch(apiClientProvider); + final resp = await client.get('/chat/$identifier/members/me'); + return SnChatMember.fromJson(resp.data); + } catch (err) { + if (err is DioException && err.response?.statusCode == 404) { + return null; // Chat member not found + } + rethrow; // Rethrow other errors + } } class NewChatScreen extends StatelessWidget { diff --git a/lib/screens/chat/chat.g.dart b/lib/screens/chat/chat.g.dart index c9d6d91..a81a0d4 100644 --- a/lib/screens/chat/chat.g.dart +++ b/lib/screens/chat/chat.g.dart @@ -25,7 +25,7 @@ final chatroomsJoinedProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef ChatroomsJoinedRef = AutoDisposeFutureProviderRef>; -String _$chatroomHash() => r'dce3c0fc407f178bb7c306a08b9fa545795a9205'; +String _$chatroomHash() => r'8dac7aaac50932e6dd213039102d43c1cf5f1d4e'; /// Copied from Dart SDK class _SystemHash { @@ -164,7 +164,7 @@ class _ChatroomProviderElement String? get identifier => (origin as ChatroomProvider).identifier; } -String _$chatroomIdentityHash() => r'4c349ea4265df7b0498cf26c82dbaabe3d868727'; +String _$chatroomIdentityHash() => r'ad6ad09b6fc4cf7c4abe146ea97f8e364a3d4fd0'; /// See also [chatroomIdentity]. @ProviderFor(chatroomIdentity) diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 83d99e2..ba79f3b 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -305,7 +305,55 @@ class ChatRoomScreen extends HookConsumerWidget { // Identity was not found, user was not joined return AppScaffold( appBar: AppBar(leading: const PageBackButton()), - body: Center(child: Text('You are not a member of this chat room')), + body: Center( + child: + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + chatRoom.value?.isCommunity == true + ? Symbols.person_add + : Symbols.person_remove, + size: 36, + fill: 1, + ).padding(bottom: 4), + Text('chatNotJoined').tr(), + if (chatRoom.value?.isCommunity != true) + Text( + 'chatUnableJoin', + textAlign: TextAlign.center, + ).tr().bold() + else + FilledButton.tonalIcon( + onPressed: () async { + try { + showLoadingModal(context); + final apiClient = ref.read(apiClientProvider); + if (chatRoom.value == null) { + hideLoadingModal(context); + return; + } + + await apiClient.post( + '/chat/${chatRoom.value!.id}/members/me', + ); + ref.invalidate(chatroomIdentityProvider(id)); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + }, + label: Text('chatJoin').tr(), + icon: const Icon(Icons.add), + ).padding(top: 8), + ], + ), + ).center(), + ), ); } @@ -443,6 +491,28 @@ class ChatRoomScreen extends HookConsumerWidget { return () => subscription.cancel(); }, [ws, chatRoom]); + useEffect(() { + final wsState = ref.read(websocketStateProvider.notifier); + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.subscribe', + data: {'chat_room_id': id}, + ), + ), + ); + return () { + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.unsubscribe', + data: {'chat_room_id': id}, + ), + ), + ); + }; + }, [id]); + Future pickPhotoMedia() async { final result = await ref .watch(imagePickerProvider) @@ -617,7 +687,7 @@ class ChatRoomScreen extends HookConsumerWidget { IconButton( icon: const Icon(Icons.more_vert), onPressed: () { - context.push('/chat/id/detail'); + context.push('/chat/$id/detail'); }, ), const Gap(8), diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 1adb08a..a4b8d54 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -1,4 +1,5 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -12,13 +13,13 @@ import 'package:island/widgets/app_scaffold.dart'; import 'package:island/models/post.dart'; import 'package:island/widgets/check_in.dart'; import 'package:island/widgets/post/post_item.dart'; -import 'package:island/widgets/tour/tour.dart'; import 'package:island/screens/tabs.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:island/pods/network.dart'; import 'package:island/widgets/realm/realm_card.dart'; +import 'package:island/widgets/publisher/publisher_card.dart'; import 'package:styled_widget/styled_widget.dart'; part 'explore.g.dart'; @@ -85,65 +86,64 @@ class ExploreScreen extends HookConsumerWidget { activityListNotifierProvider(currentFilter.value).notifier, ); - return TourTriggerWidget( - child: AppScaffold( - extendBody: false, // Prevent conflicts with tabs navigation - appBar: AppBar( - toolbarHeight: 0, - bottom: TabBar( - controller: tabController, - tabs: [ - Tab( - child: Text( - 'explore'.tr(), - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).appBarTheme.foregroundColor!, - ), - ), - ), - Tab( - child: Text( - 'exploreFilterSubscriptions'.tr(), - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).appBarTheme.foregroundColor!, - ), - ), - ), - Tab( - child: Text( - 'exploreFilterFriends'.tr(), - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).appBarTheme.foregroundColor!, - ), - ), - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - heroTag: Key("explore-page-fab"), - onPressed: () { - context.push('/posts/compose').then((value) { - if (value != null) { - activitiesNotifier.forceRefresh(); - } - }); - }, - child: const Icon(Symbols.edit), - ), - floatingActionButtonLocation: TabbedFabLocation(context), - body: TabBarView( + return AppScaffold( + extendBody: false, // Prevent conflicts with tabs navigation + appBar: AppBar( + toolbarHeight: 0, + bottom: TabBar( controller: tabController, - children: [ - _buildActivityList(ref, null), - _buildActivityList(ref, 'subscriptions'), - _buildActivityList(ref, 'friends'), + tabs: [ + Tab( + child: Text( + 'explore'.tr(), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor!, + ), + ), + ), + Tab( + child: Text( + 'exploreFilterSubscriptions'.tr(), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor!, + ), + ), + ), + Tab( + child: Text( + 'exploreFilterFriends'.tr(), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor!, + ), + ), + ), ], ), ), + floatingActionButton: FloatingActionButton( + heroTag: Key("explore-page-fab"), + onPressed: () { + context.push('/posts/compose').then((value) { + if (value != null) { + activitiesNotifier.forceRefresh(); + } + }); + }, + child: const Icon(Symbols.edit), + ), + floatingActionButtonLocation: TabbedFabLocation(context), + body: TabBarView( + controller: tabController, + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildActivityList(ref, null), + _buildActivityList(ref, 'subscriptions'), + _buildActivityList(ref, 'friends'), + ], + ), ); } @@ -180,10 +180,8 @@ class _DiscoveryActivityItem extends StatelessWidget { @override Widget build(BuildContext context) { - final items = - (data['items'] as List) - .map((e) => SnRealm.fromJson(e['data'] as Map)) - .toList(); + final items = data['items'] as List; + final type = items.firstOrNull?['type'] ?? 'unknown'; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -194,7 +192,11 @@ class _DiscoveryActivityItem extends StatelessWidget { const Icon(Symbols.explore, size: 19), const Gap(8), Text( - 'discoverCommunities'.tr(), + (switch (type) { + 'realm' => 'discoverRealms', + 'publisher' => 'discoverPublishers', + _ => 'unknown', + }).tr(), style: Theme.of(context).textTheme.titleMedium, ).padding(top: 1), ], @@ -204,13 +206,26 @@ class _DiscoveryActivityItem extends StatelessWidget { child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: items.length, - padding: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { - final realm = items[index]; - return RealmCard(realm: realm); + final item = items[index]; + switch (type) { + case 'realm': + return RealmCard( + realm: SnRealm.fromJson(item['data']), + maxWidth: 280, + ); + case 'publisher': + return PublisherCard( + publisher: SnPublisher.fromJson(item['data']), + maxWidth: 280, + ); + default: + return Placeholder(); + } }, ), - ), + ).padding(bottom: 4), ], ); } @@ -326,6 +341,7 @@ class ActivityListNotifier extends _$ActivityListNotifier if (cursor != null) 'cursor': cursor, 'take': take, if (filter != null) 'filter': filter, + if (kDebugMode) 'debugInclude': 'realms,publishers', }; final response = await client.get( diff --git a/lib/screens/explore.g.dart b/lib/screens/explore.g.dart index a7e823a..150b646 100644 --- a/lib/screens/explore.g.dart +++ b/lib/screens/explore.g.dart @@ -7,7 +7,7 @@ part of 'explore.dart'; // ************************************************************************** String _$activityListNotifierHash() => - r'14ec2f211c86e1e64a9a34b142d0e8f78ff6361a'; + r'57e9dcec944a9f88f8508b69fc91342592f5b349'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/screens/realm/detail.dart b/lib/screens/realm/detail.dart index 27ed8e1..78718c4 100644 --- a/lib/screens/realm/detail.dart +++ b/lib/screens/realm/detail.dart @@ -155,7 +155,7 @@ class RealmDetailScreen extends HookConsumerWidget { ), ], ), - if (identity == null && realm.isPublic) + if (identity == null && realm.isCommunity) FilledButton.tonalIcon( onPressed: () async { try { @@ -169,14 +169,14 @@ class RealmDetailScreen extends HookConsumerWidget { realmIdentityProvider(slug), ); ref.invalidate(realmsJoinedProvider); - showSnackBar('joinRealmSuccess'.tr()); + showSnackBar('realmJoinSuccess'.tr()); } catch (err) { showErrorAlert(err); } }, icon: const Icon(Symbols.add), - label: const Text('joinRealm').tr(), - ).padding(horizontal: 16, vertical: 8) + label: const Text('realmJoin').tr(), + ).padding(horizontal: 16, vertical: 16) else const SizedBox.shrink(), ], diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index 475179f..c66d3d0 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/services/notify.dart'; import 'package:island/services/sharing_intent.dart'; +import 'package:island/widgets/tour/tour.dart'; class AppWrapper extends HookConsumerWidget { final Widget child; @@ -24,6 +25,6 @@ class AppWrapper extends HookConsumerWidget { }; }, const []); - return child; + return TourTriggerWidget(child: child); } } diff --git a/lib/widgets/publisher/publisher_card.dart b/lib/widgets/publisher/publisher_card.dart new file mode 100644 index 0000000..1132c02 --- /dev/null +++ b/lib/widgets/publisher/publisher_card.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/post.dart'; +import 'package:island/widgets/content/cloud_files.dart'; + +class PublisherCard extends ConsumerWidget { + final SnPublisher publisher; + final double? maxWidth; + + const PublisherCard({super.key, required this.publisher, this.maxWidth}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Widget imageWidget; + if (publisher.picture != null) { + imageWidget = CloudImageWidget( + file: publisher.background, + fit: BoxFit.cover, + ); + } else { + imageWidget = ColoredBox( + color: Theme.of(context).colorScheme.secondaryContainer, + ); + } + + Widget card = Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + context.push('/publishers/${publisher.id}'); + }, + child: AspectRatio( + aspectRatio: 16 / 7, + child: Stack( + fit: StackFit.expand, + children: [ + imageWidget, + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.7), + Colors.transparent, + ], + ), + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: ProfilePictureWidget( + file: publisher.picture, + radius: 12, + ), + ), + const Gap(2), + Text( + publisher.nick, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), + child: card, + ); + } +} diff --git a/lib/widgets/realm/realm_card.dart b/lib/widgets/realm/realm_card.dart index c07060d..a3b7f1e 100644 --- a/lib/widgets/realm/realm_card.dart +++ b/lib/widgets/realm/realm_card.dart @@ -1,41 +1,33 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/realm.dart'; -import 'package:island/pods/network.dart'; +import 'package:island/widgets/content/cloud_files.dart'; import 'package:material_symbols_icons/symbols.dart'; class RealmCard extends ConsumerWidget { final SnRealm realm; + final double? maxWidth; - const RealmCard({super.key, required this.realm}); + const RealmCard({super.key, required this.realm, this.maxWidth}); @override Widget build(BuildContext context, WidgetRef ref) { - final client = ref.watch(apiClientProvider); - Widget imageWidget; if (realm.picture != null) { - final imageUrl = '${client.options.baseUrl}/files/${realm.picture!.id}'; - imageWidget = Image.network( - imageUrl, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - ); + imageWidget = + imageWidget = CloudImageWidget( + file: realm.background, + fit: BoxFit.cover, + ); } else { - imageWidget = Container( + imageWidget = ColoredBox( color: Theme.of(context).colorScheme.secondaryContainer, - child: Center( - child: Icon( - Symbols.photo_camera, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), ); } - return Card( + Widget card = Card( clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { @@ -44,6 +36,7 @@ class RealmCard extends ConsumerWidget { child: AspectRatio( aspectRatio: 16 / 7, child: Stack( + fit: StackFit.expand, children: [ imageWidget, Positioned( @@ -62,14 +55,37 @@ class RealmCard extends ConsumerWidget { ), ), padding: const EdgeInsets.all(8), - child: Text( - realm.name, - style: Theme.of(context).textTheme.titleSmall?.copyWith( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: ProfilePictureWidget( + file: realm.picture, + fallbackIcon: Symbols.group, + radius: 12, + ), + ), + const Gap(2), + Text( + realm.name, + style: Theme.of(context).textTheme.titleSmall?.copyWith( color: Colors.white, fontWeight: FontWeight.bold, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ), @@ -78,5 +94,10 @@ class RealmCard extends ConsumerWidget { ), ), ); + + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), + child: card, + ); } }