📱 Responsive for desktop

This commit is contained in:
LittleSheep 2025-05-21 00:04:36 +08:00
parent 1f2a5c107d
commit ea90364566
23 changed files with 761 additions and 440 deletions

View File

@ -42,7 +42,7 @@
"update": "Update",
"edit": "Edit",
"delete": "Delete",
"deletePublisher": "Delete Publisher {}",
"deletePublisher": "Delete Publisher",
"deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.",
"somethingWentWrong": "Something went wrong...",
"deletePost": "Delete Post",
@ -260,5 +260,6 @@
"walletCreate": "Create a Wallet",
"settingsServerUrl": "Server URL",
"settingsApplied": "The settings has been applied.",
"notifications": "Notifications"
"notifications": "Notifications",
"posts": "Posts"
}

View File

@ -37,6 +37,30 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: ChatDetailRoute.page, path: ':id/detail'),
],
),
AutoRoute(
page: CreatorHubShellRoute.page,
path: '/creators',
children: [
AutoRoute(page: CreatorHubRoute.page, path: ''),
AutoRoute(page: StickersRoute.page, path: ':name/stickers'),
AutoRoute(page: NewStickerPacksRoute.page, path: ':name/stickers/new'),
AutoRoute(
page: EditStickerPacksRoute.page,
path: ':name/stickers/:packId/edit',
),
AutoRoute(
page: StickerPackDetailRoute.page,
path: ':name/stickers/:packId',
),
AutoRoute(page: NewStickersRoute.page, path: ':name/stickers/new'),
AutoRoute(
page: EditStickersRoute.page,
path: ':name/stickers/:id/edit',
),
AutoRoute(page: NewPublisherRoute.page, path: 'new'),
AutoRoute(page: EditPublisherRoute.page, path: ':name/edit'),
],
),
AutoRoute(page: LoginRoute.page, path: '/auth/login'),
AutoRoute(page: CreateAccountRoute.page, path: '/auth/create-account'),
AutoRoute(page: SettingsRoute.page, path: '/settings'),
@ -46,27 +70,5 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: NewRealmRoute.page, path: '/realms/new'),
AutoRoute(page: RealmDetailRoute.page, path: '/realms/:slug'),
AutoRoute(page: EditRealmRoute.page, path: '/realms/:slug/edit'),
AutoRoute(page: CreatorHubRoute.page, path: '/creators'),
AutoRoute(page: StickersRoute.page, path: '/creators/:name/stickers'),
AutoRoute(
page: NewStickerPacksRoute.page,
path: '/creators/:name/stickers/new',
),
AutoRoute(
page: EditStickerPacksRoute.page,
path: '/creators/:name/stickers/:packId/edit',
),
AutoRoute(
page: StickerPackDetailRoute.page,
path: '/creators/:name/stickers/:packId',
),
AutoRoute(
page: NewStickersRoute.page,
path: '/creators/:name/stickers/new',
),
AutoRoute(
page: EditStickersRoute.page,
path: '/creators/:name/stickers/:id/edit',
),
];
}

View File

@ -16,7 +16,6 @@ import 'package:island/models/post.dart' as _i29;
import 'package:island/route.dart' as _i30;
import 'package:island/screens/account.dart' as _i2;
import 'package:island/screens/account/me/event_calendar.dart' as _i15;
import 'package:island/screens/account/me/publishers.dart' as _i9;
import 'package:island/screens/account/me/settings.dart' as _i3;
import 'package:island/screens/account/me/update.dart' as _i24;
import 'package:island/screens/account/profile.dart' as _i1;
@ -28,6 +27,7 @@ import 'package:island/screens/chat/chat.dart' as _i5;
import 'package:island/screens/chat/room.dart' as _i6;
import 'package:island/screens/chat/room_detail.dart' as _i4;
import 'package:island/screens/creators/hub.dart' as _i8;
import 'package:island/screens/creators/publishers.dart' as _i9;
import 'package:island/screens/creators/stickers/pack_detail.dart' as _i12;
import 'package:island/screens/creators/stickers/stickers.dart' as _i11;
import 'package:island/screens/explore.dart' as _i13;
@ -308,16 +308,55 @@ class CreateAccountRoute extends _i26.PageRouteInfo<void> {
/// generated route for
/// [_i8.CreatorHubScreen]
class CreatorHubRoute extends _i26.PageRouteInfo<void> {
const CreatorHubRoute({List<_i26.PageRouteInfo>? children})
: super(CreatorHubRoute.name, initialChildren: children);
class CreatorHubRoute extends _i26.PageRouteInfo<CreatorHubRouteArgs> {
CreatorHubRoute({
_i27.Key? key,
bool isAside = false,
List<_i26.PageRouteInfo>? children,
}) : super(
CreatorHubRoute.name,
args: CreatorHubRouteArgs(key: key, isAside: isAside),
initialChildren: children,
);
static const String name = 'CreatorHubRoute';
static _i26.PageInfo page = _i26.PageInfo(
name,
builder: (data) {
return const _i8.CreatorHubScreen();
final args = data.argsAs<CreatorHubRouteArgs>(
orElse: () => const CreatorHubRouteArgs(),
);
return _i8.CreatorHubScreen(key: args.key, isAside: args.isAside);
},
);
}
class CreatorHubRouteArgs {
const CreatorHubRouteArgs({this.key, this.isAside = false});
final _i27.Key? key;
final bool isAside;
@override
String toString() {
return 'CreatorHubRouteArgs{key: $key, isAside: $isAside}';
}
}
/// generated route for
/// [_i8.CreatorHubShellScreen]
class CreatorHubShellRoute extends _i26.PageRouteInfo<void> {
const CreatorHubShellRoute({List<_i26.PageRouteInfo>? children})
: super(CreatorHubShellRoute.name, initialChildren: children);
static const String name = 'CreatorHubShellRoute';
static _i26.PageInfo page = _i26.PageInfo(
name,
builder: (data) {
return const _i8.CreatorHubShellScreen();
},
);
}
@ -591,22 +630,6 @@ class LoginRoute extends _i26.PageRouteInfo<void> {
);
}
/// generated route for
/// [_i9.ManagedPublisherScreen]
class ManagedPublisherRoute extends _i26.PageRouteInfo<void> {
const ManagedPublisherRoute({List<_i26.PageRouteInfo>? children})
: super(ManagedPublisherRoute.name, initialChildren: children);
static const String name = 'ManagedPublisherRoute';
static _i26.PageInfo page = _i26.PageInfo(
name,
builder: (data) {
return const _i9.ManagedPublisherScreen();
},
);
}
/// generated route for
/// [_i15.MyselfEventCalendarScreen]
class MyselfEventCalendarRoute extends _i26.PageRouteInfo<void> {

View File

@ -64,7 +64,8 @@ class MyselfEventCalendarScreen extends HookConsumerWidget {
leading: const PageBackButton(),
title: Text('eventCalander').tr(),
),
body: Column(
body: SingleChildScrollView(
child: Column(
children: [
TableCalendar(
locale: EasyLocalization.of(context)!.locale.toString(),
@ -192,6 +193,7 @@ class MyselfEventCalendarScreen extends HookConsumerWidget {
),
],
),
),
);
}
}

View File

@ -106,27 +106,14 @@ class TabsNavigationWidget extends HookConsumerWidget {
Column(
children: [
Gap(MediaQuery.of(context).padding.top + 8),
if (useExpandableLayout)
Expanded(
child: NavigationDrawer(
backgroundColor: Colors.transparent,
children: [
for (final destination in destinations)
NavigationDrawerDestination(
label: Text(destination.label),
icon: destination.icon,
),
],
),
)
else
Expanded(
child: NavigationRail(
extended: useExpandableLayout,
selectedIndex: activeIndex,
onDestinationSelected: (index) {
router.replace(routes[index]);
},
labelType: NavigationRailLabelType.all,
// labelType: NavigationRailLabelType.all,
destinations:
destinations
.map(

View File

@ -8,7 +8,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/account/me/publishers.dart';
import 'package:island/screens/creators/publishers.dart';
import 'package:island/services/responsive.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';
@ -25,17 +27,69 @@ Future<SnPublisherStats?> publisherStats(Ref ref, String? uname) async {
return SnPublisherStats.fromJson(resp.data);
}
@RoutePage()
class CreatorHubShellScreen extends StatelessWidget {
const CreatorHubShellScreen({super.key});
@override
Widget build(BuildContext context) {
final isWide = isWideScreen(context);
if (isWide) {
return Row(
children: [
SizedBox(width: 360, child: const CreatorHubScreen(isAside: true)),
const VerticalDivider(width: 1),
Expanded(child: AutoRouter()),
],
);
}
return AutoRouter();
}
}
@RoutePage()
class CreatorHubScreen extends HookConsumerWidget {
const CreatorHubScreen({super.key});
final bool isAside;
const CreatorHubScreen({super.key, this.isAside = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context);
if (isWide && !isAside) {
return Container(color: Theme.of(context).colorScheme.surface);
}
final publishers = ref.watch(publishersManagedProvider);
final currentPublisher = useState<SnPublisher?>(
publishers.value?.firstOrNull,
);
void updatePublisher() {
context.router
.push(EditPublisherRoute(name: currentPublisher.value!.name))
.then((value) async {
if (value == null) return;
final data = await ref.refresh(publishersManagedProvider.future);
currentPublisher.value =
data
.where((e) => e.id == currentPublisher.value!.id)
.firstOrNull;
});
}
void deletePublisher() {
showConfirmAlert('deletePublisherHint'.tr(), 'deletePublisher'.tr()).then(
(confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/publishers/${currentPublisher.value!.name}');
ref.invalidate(publishersManagedProvider);
currentPublisher.value = null;
}
},
);
}
final List<DropdownMenuItem<SnPublisher>> publishersMenu = publishers.when(
data:
(data) =>
@ -184,12 +238,11 @@ class CreatorHubScreen extends HookConsumerWidget {
_PublisherStatsWidget(
stats: stats,
).padding(vertical: 12, horizontal: 12),
if (currentPublisher.value != null)
ListTile(
minTileHeight: 48,
title: Text('stickers').tr(),
trailing: Icon(Symbols.chevron_right),
leading: const Icon(Symbols.sticky_note),
leading: const Icon(Symbols.ar_stickers),
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
@ -201,6 +254,40 @@ class CreatorHubScreen extends HookConsumerWidget {
);
},
),
ListTile(
minTileHeight: 48,
title: Text('posts').tr(),
trailing: Icon(Symbols.chevron_right),
leading: const Icon(Symbols.sticky_note_2),
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
),
Divider(height: 1).padding(vertical: 8),
ListTile(
minTileHeight: 48,
title: Text('editPublisher').tr(),
trailing: Icon(Symbols.chevron_right),
leading: const Icon(Symbols.edit),
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
onTap: () {
updatePublisher();
},
),
ListTile(
minTileHeight: 48,
title: Text('deletePublisher').tr(),
trailing: Icon(Symbols.chevron_right),
leading: const Icon(Symbols.delete),
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
onTap: () {
deletePublisher();
},
),
],
),
),

View File

@ -12,7 +12,6 @@ import 'package:island/models/realm.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart';
import 'package:island/widgets/alert.dart';
@ -35,124 +34,6 @@ Future<List<SnPublisher>> publishersManaged(Ref ref) async {
.toList();
}
@RoutePage()
class ManagedPublisherScreen extends HookConsumerWidget {
const ManagedPublisherScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final publishers = ref.watch(publishersManagedProvider);
return AppScaffold(
appBar: AppBar(
title: Text('publishers').tr(),
leading: const PageBackButton(),
),
body: RefreshIndicator(
child: publishers.when(
data:
(value) => Column(
children: [
ListTile(
leading: const Icon(Symbols.add),
title: Text('createPublisher').tr(),
subtitle: Text('createPublisherHint').tr(),
trailing: const Icon(Symbols.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
context.router.push(NewPublisherRoute()).then((value) {
if (value != null) {
ref.invalidate(publishersManagedProvider);
}
});
},
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
itemCount: value.length,
itemBuilder: (context, item) {
return ListTile(
leading: ProfilePictureWidget(
fileId: value[item].pictureId,
),
title: Text(value[item].nick),
subtitle: Text('@${value[item].name}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'deletePublisherHint'.tr(),
'deletePublisher'.tr(
args: ['@${value[item].name}'],
),
).then((confirm) {
if (confirm) {
final client = ref.watch(
apiClientProvider,
);
client.delete(
'/publishers/${value[item].name}',
);
ref.invalidate(publishersManagedProvider);
}
});
},
),
IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: Icon(Symbols.edit),
onPressed: () {
context.router
.push(
EditPublisherRoute(
name: value[item].name,
),
)
.then((value) {
if (value != null) {
ref.invalidate(
publishersManagedProvider,
);
}
});
},
),
],
),
contentPadding: EdgeInsets.only(left: 16, right: 14),
);
},
),
),
],
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(e, _) => GestureDetector(
child: Center(
child: Text('Error: $e', textAlign: TextAlign.center),
),
onTap: () {
ref.invalidate(publishersManagedProvider);
},
),
),
onRefresh: () => ref.refresh(publishersManagedProvider.future),
),
);
}
}
@riverpod
Future<SnPublisher?> publisher(Ref ref, String? identifier) async {
if (identifier == null) return null;

View File

@ -1,11 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/activity.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/route.gr.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/status.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart';
@ -25,9 +26,10 @@ class ExploreScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userInfoProvider);
final activitiesNotifier = ref.watch(activityListNotifierProvider.notifier);
final isWide = isWideScreen(context);
return TourTriggerWidget(
child: AppScaffold(
appBar: AppBar(title: const Text('explore').tr()),
@ -50,10 +52,68 @@ class ExploreScreen extends ConsumerWidget {
futureRefreshable: activityListNotifierProvider.future,
notifierRefreshable: activityListNotifierProvider.notifier,
contentBuilder:
(data, widgetCount, endItemView) => CustomScrollView(
(data, widgetCount, endItemView) => Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: kWideScreenWidth - 160,
),
child:
isWide
? Card(
elevation: 8,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
color: Theme.of(context)
.colorScheme
.surfaceContainerLow
.withOpacity(0.8),
child: _ActivityListView(
data: data,
widgetCount: widgetCount,
endItemView: endItemView,
activitiesNotifier: activitiesNotifier,
),
)
: _ActivityListView(
data: data,
widgetCount: widgetCount,
endItemView: endItemView,
activitiesNotifier: activitiesNotifier,
),
),
),
),
),
),
);
}
}
class _ActivityListView extends HookConsumerWidget {
final CursorPagingData<SnActivity> data;
final int widgetCount;
final Widget endItemView;
final ActivityListNotifier activitiesNotifier;
const _ActivityListView({
required this.data,
required this.widgetCount,
required this.endItemView,
required this.activitiesNotifier,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userInfoProvider);
return CustomScrollView(
slivers: [
if (user.hasValue)
SliverToBoxAdapter(child: CheckInWidget()),
if (user.hasValue) SliverToBoxAdapter(child: CheckInWidget()),
SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
@ -62,12 +122,16 @@ class ExploreScreen extends ConsumerWidget {
}
final item = data.items[index];
if (item.data == null) return const SizedBox.shrink();
if (item.data == null) {
return const SizedBox.shrink();
}
Widget itemWidget;
switch (item.type) {
case 'posts.new':
itemWidget = PostItem(
backgroundColor:
isWideScreen(context) ? Colors.transparent : null,
item: SnPost.fromJson(item.data),
onRefresh: (_) {
activitiesNotifier.forceRefresh();
@ -90,17 +154,11 @@ class ExploreScreen extends ConsumerWidget {
itemWidget = const Placeholder();
}
return Column(
children: [itemWidget, const Divider(height: 1)],
);
return Column(children: [itemWidget, const Divider(height: 1)]);
},
),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],
),
),
),
),
);
}
}

View File

@ -14,14 +14,13 @@ import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/account/me/publishers.dart';
import 'package:island/screens/creators/publishers.dart';
import 'package:island/screens/posts/detail.dart';
import 'package:island/services/file.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/publishers_modal.dart';
import 'package:markdown_editor_plus/widgets/markdown_auto_preview.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
@ -291,15 +290,14 @@ class PostComposeScreen extends HookConsumerWidget {
(_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TapRegion(
child: MarkdownAutoPreview(
const Gap(8),
TextField(
controller: contentController,
emojiConvert: true,
hintText: 'postPlaceholder'.tr(),
style: TextStyle(fontSize: 14),
decoration: InputDecoration(
border: InputBorder.none,
),
hintText: 'postPlaceholder'.tr(),
isDense: true,
),
onTapOutside:
(_) =>
@ -343,7 +341,7 @@ class PostComposeScreen extends HookConsumerWidget {
).padding(horizontal: 16),
),
Material(
elevation: 2,
elevation: 4,
child: Row(
children: [
IconButton(
@ -358,7 +356,7 @@ class PostComposeScreen extends HookConsumerWidget {
),
],
).padding(
bottom: MediaQuery.of(context).padding.bottom,
bottom: MediaQuery.of(context).padding.bottom + 16,
horizontal: 16,
top: 8,
),

View File

@ -4,6 +4,7 @@ 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/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_quick_reply.dart';
@ -29,16 +30,22 @@ class PostDetailScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final post = ref.watch(postProvider(id));
final isWide = isWideScreen(context);
return AppScaffold(
appBar: AppBar(title: const Text('Post')),
body: post.when(
data:
(post) => Stack(
data: (post) {
final content = Stack(
fit: StackFit.expand,
children: [
Column(
children: [
PostItem(item: post!, isOpenable: false),
PostItem(
item: post!,
isOpenable: false,
backgroundColor: isWide ? Colors.transparent : null,
),
const Divider(height: 1),
Expanded(child: PostRepliesList(postId: id)),
Gap(MediaQuery.of(context).padding.bottom),
@ -50,15 +57,41 @@ class PostDetailScreen extends HookConsumerWidget {
right: 0,
child: Material(
elevation: 2,
color: Colors.transparent,
child: PostQuickReply(parent: post).padding(
bottom: MediaQuery.of(context).padding.bottom,
bottom: MediaQuery.of(context).padding.bottom + 16,
top: 16,
horizontal: 16,
),
),
),
],
);
return isWide
? Center(
child: Card(
elevation: 8,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
color: Theme.of(
context,
).colorScheme.surfaceContainerLow.withOpacity(0.8),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: kWideScreenWidth - 160,
),
child: content,
),
),
)
: content;
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('Error: $e'),
),

View File

@ -1,8 +1,8 @@
import 'package:flutter/widgets.dart';
const kWideScreenWidth = 768;
const kWiderScreenWidth = 1024;
const kWidescreenWidth = 1280;
const kWideScreenWidth = 768.0;
const kWiderScreenWidth = 1024.0;
const kWidescreenWidth = 1280.0;
bool isWideScreen(BuildContext context) {
return MediaQuery.of(context).size.width > kWideScreenWidth;

View File

@ -53,7 +53,7 @@ class TourStatusNotifier extends _$TourStatusNotifier {
}
Future<Widget?> showTour(String tourId) async {
if (!isTourShown(tourId) || true) {
if (!isTourShown(tourId)) {
final newState = {...state, tourId: true};
await _saveState(newState);
return kAllTours.firstWhere((e) => e.id == tourId).widget;

View File

@ -7,7 +7,7 @@ part of 'tour.dart';
// **************************************************************************
String _$tourStatusNotifierHash() =>
r'040aac2d7cf6d14e539c1b04cf311421ee133ed3';
r'ee712e1f8010311df8f24838814ab5c451f9e593';
/// See also [TourStatusNotifier].
@ProviderFor(TourStatusNotifier)

View File

@ -0,0 +1,236 @@
// ignore_for_file: implementation_imports, invalid_use_of_internal_member
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_paging_utils/src/paging_data.dart';
import 'package:riverpod_paging_utils/src/paging_helper_view_theme.dart';
import 'package:riverpod_paging_utils/src/paging_notifier_mixin.dart';
import 'package:visibility_detector/visibility_detector.dart';
/// A generic widget for pagination.
///
/// Main features:
/// 1. Displays the widget created by [contentBuilder] when data is available.
/// 2. Shows a CircularProgressIndicator while loading the first page.
/// 3. Displays an error widget when there is an error on the first page.
/// 4. Shows error messages using a SnackBar.
/// 5. Loads the next page when the last item is displayed.
/// 6. Supports pull-to-refresh functionality.
///
/// You can customize the appearance of the loading view, error view, and endItemView using [PagingHelperViewTheme].
final class PagingHelperSliverView<D extends PagingData<I>, I>
extends ConsumerWidget {
const PagingHelperSliverView({
required this.provider,
required this.futureRefreshable,
required this.notifierRefreshable,
required this.contentBuilder,
this.showSecondPageError = true,
super.key,
});
final ProviderListenable<AsyncValue<D>> provider;
final Refreshable<Future<D>> futureRefreshable;
final Refreshable<PagingNotifierMixin<D, I>> notifierRefreshable;
/// Specifies a function that returns a widget to display when data is available.
/// endItemView is a widget to detect when the last displayed item is visible.
/// If endItemView is non-null, it is displayed at the end of the list.
final Widget Function(D data, int widgetCount, Widget endItemView)
contentBuilder;
final bool showSecondPageError;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context).extension<PagingHelperViewTheme>();
final loadingBuilder =
theme?.loadingViewBuilder ??
(context) => SliverFillRemaining(
child: const Center(child: CircularProgressIndicator()),
);
final errorBuilder =
theme?.errorViewBuilder ??
(context, e, st, onPressed) => SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: onPressed,
icon: const Icon(Icons.refresh),
),
Text(e.toString()),
],
),
),
);
return ref
.watch(provider)
.whenIgnorableError(
data: (
data, {
required hasError,
required isLoading,
required error,
}) {
final content = contentBuilder(
data,
// Add 1 to the length to include the endItemView
data.items.length + 1,
switch ((data.hasMore, hasError, isLoading)) {
// Display a widget to detect when the last element is reached
// if there are more pages and no errors
(true, false, _) => _EndVDLoadingItemView(
onScrollEnd:
() async => ref.read(notifierRefreshable).loadNext(),
),
(true, true, false) when showSecondPageError =>
_EndErrorItemView(
error: error,
onRetryButtonPressed:
() async => ref.read(notifierRefreshable).loadNext(),
),
(true, true, true) => const _EndLoadingItemView(),
_ => const SizedBox.shrink(),
},
);
return content;
},
// Loading state for the first page
loading: () => loadingBuilder(context),
// Error state for the first page
error:
(e, st) => errorBuilder(
context,
e,
st,
() => ref.read(notifierRefreshable).forceRefresh(),
),
// Prioritize data for errors on the second page and beyond
skipErrorOnHasValue: true,
);
}
}
final class _EndLoadingItemView extends StatelessWidget {
const _EndLoadingItemView();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).extension<PagingHelperViewTheme>();
final childBuilder =
theme?.endLoadingViewBuilder ??
(context) => const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
return childBuilder(context);
}
}
final class _EndVDLoadingItemView extends StatelessWidget {
const _EndVDLoadingItemView({required this.onScrollEnd});
final VoidCallback onScrollEnd;
@override
Widget build(BuildContext context) {
return VisibilityDetector(
key: key ?? const Key('EndItem'),
onVisibilityChanged: (info) {
if (info.visibleFraction > 0.1) {
onScrollEnd();
}
},
child: const _EndLoadingItemView(),
);
}
}
final class _EndErrorItemView extends StatelessWidget {
const _EndErrorItemView({
required this.error,
required this.onRetryButtonPressed,
});
final Object? error;
final VoidCallback onRetryButtonPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).extension<PagingHelperViewTheme>();
final childBuilder =
theme?.endErrorViewBuilder ??
(context, e, onPressed) => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
IconButton(
onPressed: onPressed,
icon: const Icon(Icons.refresh),
),
Text(error.toString()),
],
),
),
);
return childBuilder(context, error, onRetryButtonPressed);
}
}
extension _AsyncValueX<T> on AsyncValue<T> {
/// Extends the [when] method to handle async data states more effectively,
/// especially when maintaining data integrity despite errors.
///
/// Use `skipErrorOnHasValue` to retain and display existing data
/// even if subsequent fetch attempts result in errors,
/// ideal for maintaining a seamless user experience.
R whenIgnorableError<R>({
required R Function(
T data, {
required bool hasError,
required bool isLoading,
required Object? error,
})
data,
required R Function(Object error, StackTrace stackTrace) error,
required R Function() loading,
bool skipLoadingOnReload = false,
bool skipLoadingOnRefresh = true,
bool skipError = false,
bool skipErrorOnHasValue = false,
}) {
if (skipErrorOnHasValue) {
if (hasValue && hasError) {
return data(
requireValue,
hasError: true,
isLoading: isLoading,
error: this.error,
);
}
}
return when(
skipLoadingOnReload: skipLoadingOnReload,
skipLoadingOnRefresh: skipLoadingOnRefresh,
skipError: skipError,
data:
(d) => data(
d,
hasError: hasError,
isLoading: isLoading,
error: this.error,
),
error: error,
loading: loading,
);
}
}

View File

@ -17,6 +17,7 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
class PostItem extends HookConsumerWidget {
final Color? backgroundColor;
final SnPost item;
final EdgeInsets? padding;
final bool isOpenable;
@ -25,6 +26,7 @@ class PostItem extends HookConsumerWidget {
const PostItem({
super.key,
required this.item,
this.backgroundColor,
this.padding,
this.isOpenable = true,
this.onRefresh,
@ -96,6 +98,7 @@ class PostItem extends HookConsumerWidget {
);
},
child: Material(
color: backgroundColor,
child: Padding(
padding: renderingPadding,
child: Column(

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/content/paging_helper_ext.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@ -53,7 +54,7 @@ class SliverPostList extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return PagingHelperView(
return PagingHelperSliverView(
provider: postListNotifierProvider,
futureRefreshable: postListNotifierProvider.future,
notifierRefreshable: postListNotifierProvider.notifier,

View File

@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/account/me/publishers.dart';
import 'package:island/screens/creators/publishers.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/publishers_modal.dart';

View File

@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/response.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -14,6 +16,7 @@ class PostRepliesList extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final postAsync = ref.watch(postRepliesProvider(postId));
final isWide = isWideScreen(context);
return RefreshIndicator(
onRefresh:
@ -37,7 +40,10 @@ class PostRepliesList extends HookConsumerWidget {
onFetchData: controller.fetchMore,
itemBuilder: (context, index) {
final post = controller.posts[index];
return PostItem(item: post);
return PostItem(
item: post,
backgroundColor: isWide ? Colors.transparent : null,
);
},
separatorBuilder: (_, __) => const Divider(height: 1),
emptyBuilder: (context) {
@ -55,11 +61,9 @@ class PostRepliesList extends HookConsumerWidget {
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(e, _) => GestureDetector(
child: Center(
child: Text('Error: $e', textAlign: TextAlign.center),
),
onTap: () {
(e, _) => ResponseErrorWidget(
error: e,
onRetry: () {
ref.invalidate(postRepliesProvider(postId));
},
),

View File

@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/account/me/publishers.dart';
import 'package:island/screens/creators/publishers.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:styled_widget/styled_widget.dart';

View File

@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class ResponseErrorWidget extends StatelessWidget {
final dynamic error;
@ -19,11 +20,14 @@ class ResponseErrorWidget extends StatelessWidget {
children: [
const Icon(Symbols.error_outline, size: 48),
const Gap(4),
Text(
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Text(
error.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
),
).center(),
const Gap(8),
TextButton(onPressed: onRetry, child: const Text('retry').tr()),
],

View File

@ -1972,7 +1972,7 @@ packages:
source: hosted
version: "0.9.0"
visibility_detector:
dependency: transitive
dependency: "direct main"
description:
name: visibility_detector
sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420

View File

@ -96,6 +96,7 @@ dependencies:
crypto: ^3.0.6
avatar_stack: ^3.0.0
markdown_widget: ^2.3.2+8
visibility_detector: ^0.4.0+2
dev_dependencies:
flutter_test: