diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 51acc8f..9bc0476 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -98,7 +98,9 @@ "explore": "Explore", "exploreFilterSubscriptions": "Subscriptions", "exploreFilterFriends": "Friends", - "discoverCommunities": "Discover Communities", + "discover": "Discover", + "discoverRealms": "Discover Realms", + "joinRealm": "Join Realm", "account": "Account", "name": "Name", "slug": "Slug", @@ -621,5 +623,5 @@ "tagsHint": "Enter tags, separated by commas", "categories": "Categories", "categoriesHint": "Enter categories, separated by commas", - "joinRealm": "Join the Realm" + "joinRealmSuccess": "Successfully joined realm!" } diff --git a/lib/models/realm.dart b/lib/models/realm.dart index 9f89371..d6e985e 100644 --- a/lib/models/realm.dart +++ b/lib/models/realm.dart @@ -11,7 +11,7 @@ sealed class SnRealm with _$SnRealm { required String id, required String slug, required String name, - required String description, + @Default('') String description, required String? verifiedAs, required DateTime? verifiedAt, required bool isCommunity, diff --git a/lib/models/realm.freezed.dart b/lib/models/realm.freezed.dart index 3b128db..dd43b27 100644 --- a/lib/models/realm.freezed.dart +++ b/lib/models/realm.freezed.dart @@ -117,13 +117,13 @@ $SnCloudFileCopyWith<$Res>? get background { @JsonSerializable() class _SnRealm implements SnRealm { - const _SnRealm({required this.id, required this.slug, required this.name, required 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, 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}); factory _SnRealm.fromJson(Map json) => _$SnRealmFromJson(json); @override final String id; @override final String slug; @override final String name; -@override final String description; +@override@JsonKey() final String description; @override final String? verifiedAs; @override final DateTime? verifiedAt; @override final bool isCommunity; diff --git a/lib/models/realm.g.dart b/lib/models/realm.g.dart index 193c3d3..876bc83 100644 --- a/lib/models/realm.g.dart +++ b/lib/models/realm.g.dart @@ -10,7 +10,7 @@ _SnRealm _$SnRealmFromJson(Map json) => _SnRealm( id: json['id'] as String, slug: json['slug'] as String, name: json['name'] as String, - description: json['description'] as String, + description: json['description'] as String? ?? '', verifiedAs: json['verified_as'] as String?, verifiedAt: json['verified_at'] == null diff --git a/lib/route.dart b/lib/route.dart index 55277ed..dc831ea 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -31,6 +31,7 @@ import 'package:island/screens/settings.dart'; import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/detail.dart'; import 'package:island/screens/account/event_calendar.dart'; +import 'package:island/screens/discovery/realms.dart'; // Shell route keys for nested navigation final rootNavigatorKey = GlobalKey(); @@ -105,10 +106,7 @@ final routerProvider = Provider((ref) { builder: (context, state) { final name = state.pathParameters['name']!; final packId = state.pathParameters['packId']!; - return EditStickerPacksScreen( - pubName: name, - packId: packId, - ); + return EditStickerPacksScreen(pubName: name, packId: packId); }, ), GoRoute( @@ -190,6 +188,10 @@ final routerProvider = Provider((ref) { return PublisherProfileScreen(name: name); }, ), + GoRoute( + path: 'discovery/realms', + builder: (context, state) => const DiscoveryRealmsScreen(), + ), ], ), @@ -227,9 +229,9 @@ final routerProvider = Provider((ref) { ), // Realms tab - GoRoute( - path: '/realms', - builder: (context, state) => const RealmListScreen(), + GoRoute( + path: '/realms', + builder: (context, state) => const RealmListScreen(), routes: [ GoRoute( path: 'new', diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 55a2bd4..16a7d25 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -200,7 +200,7 @@ class AccountScreen extends HookConsumerWidget { ], ), onTap: () { - context.push('/notification'); + context.push('/account/notifications'); }, ), ListTile( diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 9c8d7dc..83d99e2 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -295,6 +295,20 @@ class ChatRoomScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final chatRoom = ref.watch(chatroomProvider(id)); final chatIdentity = ref.watch(chatroomIdentityProvider(id)); + + if (chatIdentity.isLoading || chatRoom.isLoading) { + return AppScaffold( + appBar: AppBar(leading: const PageBackButton()), + body: CircularProgressIndicator().center(), + ); + } else if (chatIdentity.value == null) { + // 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')), + ); + } + final messages = ref.watch(messagesNotifierProvider(id)); final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); final ws = ref.watch(websocketProvider); diff --git a/lib/screens/discovery/realms.dart b/lib/screens/discovery/realms.dart new file mode 100644 index 0000000..312e4eb --- /dev/null +++ b/lib/screens/discovery/realms.dart @@ -0,0 +1,24 @@ +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/widgets/app_scaffold.dart'; +import 'package:island/widgets/realm/realm_list.dart'; + +class DiscoveryRealmsScreen extends HookConsumerWidget { + const DiscoveryRealmsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AppScaffold( + appBar: AppBar(title: Text('discoverRealms'.tr())), + body: CustomScrollView( + slivers: [ + SliverGap(16), + SliverRealmList(), + SliverGap(MediaQuery.of(context).padding.bottom + 16), + ], + ), + ); + } +} diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index b11d41d..1adb08a 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/activity.dart'; +import 'package:island/models/realm.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/app_scaffold.dart'; @@ -17,8 +18,8 @@ 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:styled_widget/styled_widget.dart'; -import 'package:island/models/realm.dart'; part 'explore.g.dart'; @@ -206,7 +207,7 @@ class _DiscoveryActivityItem extends StatelessWidget { padding: const EdgeInsets.only(right: 8), itemBuilder: (context, index) { final realm = items[index]; - return _RealmCard(realm: realm); + return RealmCard(realm: realm); }, ), ), @@ -215,86 +216,6 @@ class _DiscoveryActivityItem extends StatelessWidget { } } -class _RealmCard extends ConsumerWidget { - final SnRealm realm; - - const _RealmCard({required this.realm}); - - @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, - ); - } else { - imageWidget = Container( - color: Theme.of(context).colorScheme.secondaryContainer, - child: Center( - child: Icon( - Symbols.photo_camera, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), - ); - } - - return Card( - clipBehavior: Clip.antiAlias, - margin: const EdgeInsets.only(left: 16, bottom: 8, top: 8), - child: InkWell( - onTap: () { - context.push('/realms/${realm.slug}'); - }, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: AspectRatio( - aspectRatio: 16 / 7, - child: Stack( - 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: Text( - realm.name, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - class _ActivityListView extends HookConsumerWidget { final CursorPagingData data; final int widgetCount; diff --git a/lib/screens/realm/detail.dart b/lib/screens/realm/detail.dart index 4e918ca..27ed8e1 100644 --- a/lib/screens/realm/detail.dart +++ b/lib/screens/realm/detail.dart @@ -41,9 +41,16 @@ Future realmAppbarForegroundColor(Ref ref, String realmSlug) async { @riverpod Future realmIdentity(Ref ref, String realmSlug) async { - final apiClient = ref.watch(apiClientProvider); - final response = await apiClient.get('/realms/$realmSlug/members/me'); - return SnRealmMember.fromJson(response.data); + try { + final apiClient = ref.watch(apiClientProvider); + final response = await apiClient.get('/realms/$realmSlug/members/me'); + return SnRealmMember.fromJson(response.data); + } catch (err) { + if (err is DioException && err.response?.statusCode == 404) { + return null; // No identity found, user is not a member + } + rethrow; + } } @riverpod @@ -135,12 +142,14 @@ class RealmDetailScreen extends HookConsumerWidget { tilePadding: EdgeInsets.symmetric( horizontal: 20, ), + expandedCrossAxisAlignment: + CrossAxisAlignment.stretch, children: [ Text( realm.description, style: const TextStyle(fontSize: 16), ).padding( - horizontal: 16, + horizontal: 20, bottom: 16, top: 8, ), @@ -160,13 +169,14 @@ class RealmDetailScreen extends HookConsumerWidget { realmIdentityProvider(slug), ); ref.invalidate(realmsJoinedProvider); + showSnackBar('joinRealmSuccess'.tr()); } catch (err) { showErrorAlert(err); } }, icon: const Icon(Symbols.add), label: const Text('joinRealm').tr(), - ).padding(horizontal: 16, vertical: 4) + ).padding(horizontal: 16, vertical: 8) else const SizedBox.shrink(), ], diff --git a/lib/screens/realm/detail.g.dart b/lib/screens/realm/detail.g.dart index 8ce028a..754e997 100644 --- a/lib/screens/realm/detail.g.dart +++ b/lib/screens/realm/detail.g.dart @@ -155,7 +155,7 @@ class _RealmAppbarForegroundColorProviderElement (origin as RealmAppbarForegroundColorProvider).realmSlug; } -String _$realmIdentityHash() => r'eac6e829b5b46bcfadbf201ab6f918d78c894b9f'; +String _$realmIdentityHash() => r'308d43eef8a6145c762d27bdf7e12e27149524db'; /// See also [realmIdentity]. @ProviderFor(realmIdentity) diff --git a/lib/screens/realm/realms.dart b/lib/screens/realm/realms.dart index ab1ae33..146369d 100644 --- a/lib/screens/realm/realms.dart +++ b/lib/screens/realm/realms.dart @@ -46,6 +46,10 @@ class RealmListScreen extends HookConsumerWidget { appBar: AppBar( title: const Text('realms').tr(), actions: [ + IconButton( + icon: const Icon(Symbols.travel_explore), + onPressed: () => context.push('/discovery/realms'), + ), IconButton( icon: Badge( label: Text( diff --git a/lib/widgets/realm/realm_card.dart b/lib/widgets/realm/realm_card.dart new file mode 100644 index 0000000..c07060d --- /dev/null +++ b/lib/widgets/realm/realm_card.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.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:material_symbols_icons/symbols.dart'; + +class RealmCard extends ConsumerWidget { + final SnRealm realm; + + const RealmCard({super.key, required this.realm}); + + @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, + ); + } else { + imageWidget = Container( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Center( + child: Icon( + Symbols.photo_camera, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ); + } + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + context.push('/realms/${realm.slug}'); + }, + child: AspectRatio( + aspectRatio: 16 / 7, + child: Stack( + 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: Text( + realm.name, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/realm/realm_list.dart b/lib/widgets/realm/realm_list.dart new file mode 100644 index 0000000..6254793 --- /dev/null +++ b/lib/widgets/realm/realm_list.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/realm.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/realm/realm_card.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; + +part 'realm_list.g.dart'; + +@riverpod +class RealmListNotifier extends _$RealmListNotifier + with CursorPagingNotifierMixin { + static const int _pageSize = 20; + + @override + Future> build() { + return fetch(cursor: null); + } + + @override + Future> fetch({required String? cursor}) async { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + + final queryParams = {'offset': offset, 'take': _pageSize}; + + final response = await client.get( + '/discovery/realms', + queryParameters: queryParams, + ); + final total = int.parse(response.headers.value('X-Total') ?? '0'); + final List data = response.data; + final realms = data.map((json) => SnRealm.fromJson(json)).toList(); + + final hasMore = offset + realms.length < total; + final nextCursor = hasMore ? (offset + realms.length).toString() : null; + + return CursorPagingData( + items: realms, + hasMore: hasMore, + nextCursor: nextCursor, + ); + } +} + +class SliverRealmList extends HookConsumerWidget { + const SliverRealmList({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PagingHelperSliverView( + provider: realmListNotifierProvider, + futureRefreshable: realmListNotifierProvider.future, + notifierRefreshable: realmListNotifierProvider.notifier, + contentBuilder: + (data, widgetCount, endItemView) => SliverList.builder( + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } + + final realm = data.items[index]; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: RealmCard(realm: realm), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/realm/realm_list.g.dart b/lib/widgets/realm/realm_list.g.dart new file mode 100644 index 0000000..4fbd541 --- /dev/null +++ b/lib/widgets/realm/realm_list.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'realm_list.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$realmListNotifierHash() => r'440eb8c61db2059699191b904b6518a0b01ccd25'; + +/// See also [RealmListNotifier]. +@ProviderFor(RealmListNotifier) +final realmListNotifierProvider = AutoDisposeAsyncNotifierProvider< + RealmListNotifier, + CursorPagingData +>.internal( + RealmListNotifier.new, + name: r'realmListNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$realmListNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$RealmListNotifier = + AutoDisposeAsyncNotifier>; +// 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/widgets/realm/realm_tile.dart b/lib/widgets/realm/realm_tile.dart new file mode 100644 index 0000000..3b4b79e --- /dev/null +++ b/lib/widgets/realm/realm_tile.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/realm.dart'; +import 'package:island/widgets/content/cloud_files.dart'; + +class RealmTile extends HookConsumerWidget { + final SnRealm realm; + const RealmTile({super.key, required this.realm}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: ProfilePictureWidget(file: realm.picture), + title: Text(realm.name), + subtitle: Text(realm.description), + onTap: () => context.push('/realms/${realm.slug}'), + ); + } +}