♻️ Refactored web feed, poll

This commit is contained in:
2025-10-12 12:19:50 +08:00
parent bbb07d574a
commit 27157e7cc1
9 changed files with 415 additions and 476 deletions

View File

@@ -50,7 +50,6 @@ import 'package:island/screens/discovery/feeds/feed_detail.dart';
import 'package:island/screens/creators/poll/poll_list.dart'; import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/creators/publishers_form.dart'; import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/creators/webfeed/webfeed_list.dart'; import 'package:island/screens/creators/webfeed/webfeed_list.dart';
import 'package:island/screens/creators/webfeed/webfeed_edit.dart';
import 'package:island/screens/poll/poll_editor.dart'; import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/post_detail.dart'; import 'package:island/screens/posts/post_detail.dart';
@@ -162,27 +161,6 @@ final routerProvider = Provider<GoRouter>((ref) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
return WebFeedListScreen(pubName: name); return WebFeedListScreen(pubName: name);
}, },
routes: [
GoRoute(
name: 'creatorFeedNew',
path: 'new',
builder: (context, state) {
return WebFeedNewScreen(
pubName: state.pathParameters['name']!,
);
},
),
GoRoute(
name: 'creatorFeedEdit',
path: ':feedId',
builder: (context, state) {
return WebFeedEditScreen(
pubName: state.pathParameters['name']!,
feedId: state.pathParameters['feedId'],
);
},
),
],
), ),
GoRoute( GoRoute(
name: 'creatorPosts', name: 'creatorPosts',

View File

@@ -175,7 +175,7 @@ class AccountScreen extends HookConsumerWidget {
], ],
).padding(horizontal: 16, vertical: 12), ).padding(horizontal: 16, vertical: 12),
onTap: () { onTap: () {
context.pushNamed('creatorHub'); context.goNamed('creatorHub');
}, },
), ),
).height(140), ).height(140),

View File

@@ -379,6 +379,21 @@ class CreatorHubScreen extends HookConsumerWidget {
); );
}, },
), ),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48,
title: const Text('webFeeds').tr(),
trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.rss_feed),
onTap: () {
context.push('/creators/${currentPublisher.value!.name}/feeds');
},
),
];
final rightItems = [
ListTile( ListTile(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -398,21 +413,6 @@ class CreatorHubScreen extends HookConsumerWidget {
); );
}, },
), ),
];
final rightItems = [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48,
title: const Text('webFeeds').tr(),
trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.rss_feed),
onTap: () {
context.push('/creators/${currentPublisher.value!.name}/feeds');
},
),
ExpansionTile( ExpansionTile(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),

View File

@@ -106,10 +106,13 @@ class CreatorPollListScreen extends HookConsumerWidget {
return endItemView; return endItemView;
} }
final pollWithStats = data.items[index]; final pollWithStats = data.items[index];
return _CreatorPollItem( return ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: _CreatorPollItem(
pollWithStats: pollWithStats, pollWithStats: pollWithStats,
pubName: pubName, pubName: pubName,
); ),
).center();
}, },
), ),
), ),

View File

@@ -1,12 +1,9 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/post/post_list.dart';
import 'package:material_symbols_icons/symbols.dart';
class CreatorPostListScreen extends HookConsumerWidget { class CreatorPostListScreen extends HookConsumerWidget {
final String pubName; final String pubName;
@@ -16,62 +13,19 @@ class CreatorPostListScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final refreshKey = useState(0); final refreshKey = useState(0);
void showCreatePostSheet() {
showModalBottomSheet(
context: context,
builder:
(context) => SheetScaffold(
titleText: 'create'.tr(),
child: Column(
children: [
ListTile(
leading: const Icon(Symbols.edit),
title: Text('Post'),
subtitle: Text('Create a regular post'),
onTap: () async {
Navigator.pop(context);
final result = await context.pushNamed(
'postCompose',
queryParameters: {'type': '0'},
);
if (result == true) {
refreshKey.value++;
}
},
),
ListTile(
leading: const Icon(Symbols.article),
title: Text('Article'),
subtitle: Text('Create a detailed article'),
onTap: () async {
Navigator.pop(context);
final result = await context.pushNamed(
'postCompose',
queryParameters: {'type': '1'},
);
if (result == true) {
refreshKey.value++;
}
},
),
],
),
),
);
}
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: AppBar(title: Text('posts').tr()), appBar: AppBar(title: Text('posts').tr()),
body: CustomScrollView( body: CustomScrollView(
key: ValueKey(refreshKey.value), key: ValueKey(refreshKey.value),
slivers: [ slivers: [
SliverPostList(pubName: pubName, itemType: PostItemType.creator), SliverPostList(
], pubName: pubName,
itemType: PostItemType.creator,
maxWidth: 640,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
), ),
floatingActionButton: FloatingActionButton( ],
onPressed: showCreatePostSheet,
child: const Icon(Symbols.add),
), ),
); );
} }

View File

@@ -6,25 +6,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/webfeed.dart'; import 'package:island/models/webfeed.dart';
import 'package:island/pods/webfeed.dart'; import 'package:island/pods/webfeed.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
class WebFeedNewScreen extends StatelessWidget { class WebfeedForm extends HookConsumerWidget {
final String pubName;
const WebFeedNewScreen({super.key, required this.pubName});
@override
Widget build(BuildContext context) {
return WebFeedEditScreen(pubName: pubName, feedId: null);
}
}
class WebFeedEditScreen extends HookConsumerWidget {
final String pubName; final String pubName;
final String? feedId; final String? feedId;
const WebFeedEditScreen({super.key, required this.pubName, this.feedId}); const WebfeedForm({super.key, required this.pubName, this.feedId});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -113,16 +103,14 @@ class WebFeedEditScreen extends HookConsumerWidget {
); );
return feedAsync.when( return feedAsync.when(
loading: loading: () => const Center(child: CircularProgressIndicator()),
() => const AppScaffold(
isNoBackground: false,
body: Center(child: CircularProgressIndicator()),
),
error: error:
(error, stack) => AppScaffold( (error, _) => ResponseErrorWidget(
isNoBackground: false, error: error,
appBar: AppBar(title: const Text('Error')), onRetry:
body: Center(child: Text('Error: $error')), () => ref.invalidate(
webFeedNotifierProvider((pubName: pubName, feedId: feedId)),
),
), ),
data: (feed) { data: (feed) {
// Initialize form fields if they're empty and we have a feed // Initialize form fields if they're empty and we have a feed
@@ -133,40 +121,8 @@ class WebFeedEditScreen extends HookConsumerWidget {
isScrapEnabled.value = feed.config.scrapPage; isScrapEnabled.value = feed.config.scrapPage;
} }
return _buildForm(
context,
formKey: formKey,
titleController: titleController,
urlController: urlController,
descriptionController: descriptionController,
isScrapEnabled: isScrapEnabled.value,
onScrapEnabledChanged: (value) => isScrapEnabled.value = value,
onSave: saveFeed,
onDelete: deleteFeed,
isLoading: isLoading.value,
ref: ref,
hasFeedId: feedId != null,
);
},
);
}
Widget _buildForm(
BuildContext context, {
required WidgetRef ref,
required GlobalKey<FormState> formKey,
required TextEditingController titleController,
required TextEditingController urlController,
required TextEditingController descriptionController,
required bool isScrapEnabled,
required ValueChanged<bool> onScrapEnabledChanged,
required VoidCallback onSave,
required VoidCallback onDelete,
required bool isLoading,
required bool hasFeedId,
}) {
final scrapNow = useCallback(() async { final scrapNow = useCallback(() async {
showLoadingModal(context); isLoading.value = true;
try { try {
await ref await ref
.read( .read(
@@ -183,31 +139,20 @@ class WebFeedEditScreen extends HookConsumerWidget {
} catch (e) { } catch (e) {
showErrorAlert(e); showErrorAlert(e);
} finally { } finally {
if (context.mounted) hideLoadingModal(context); isLoading.value = false;
} }
}, [pubName, feedId, ref, context]); }, [pubName, feedId, ref, context, isLoading]);
return AppScaffold( final formFields = Column(
isNoBackground: false,
appBar: AppBar(
title: Text(hasFeedId ? 'Edit Web Feed' : 'New Web Feed'),
actions: [
if (hasFeedId)
IconButton(
icon: const Icon(Symbols.delete_forever),
onPressed: isLoading ? null : onDelete,
),
const SizedBox(width: 8),
],
),
body: Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
children: [ children: [
TextFormField( TextFormField(
controller: titleController, controller: titleController,
decoration: const InputDecoration(labelText: 'Title'), decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Please enter a title'; return 'Please enter a title';
@@ -223,6 +168,9 @@ class WebFeedEditScreen extends HookConsumerWidget {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'URL', labelText: 'URL',
hintText: 'https://example.com/feed', hintText: 'https://example.com/feed',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
validator: (value) { validator: (value) {
@@ -244,6 +192,9 @@ class WebFeedEditScreen extends HookConsumerWidget {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Description', labelText: 'Description',
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
), ),
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
@@ -263,30 +214,49 @@ class WebFeedEditScreen extends HookConsumerWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
value: isScrapEnabled, value: isScrapEnabled.value,
onChanged: onScrapEnabledChanged, onChanged: (value) => isScrapEnabled.value = value,
), ),
], ],
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (hasFeedId) ...[ if (feedId != null) ...[
FilledButton.tonalIcon( TextButton.icon(
onPressed: isLoading ? null : scrapNow, onPressed: isLoading.value ? null : scrapNow,
icon: const Icon(Symbols.refresh), icon: const Icon(Symbols.refresh),
label: const Text('Scrape Now'), label: const Text('Scrape Now'),
).alignment(Alignment.centerRight), ).alignment(Alignment.centerRight),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
FilledButton.icon( ],
onPressed: isLoading ? null : onSave, ).padding(all: 20);
final formWidget = Form(
key: formKey,
child: SingleChildScrollView(child: formFields),
);
final buttonsRow = Row(
children: [
if (feedId != null)
TextButton.icon(
onPressed: isLoading.value ? null : deleteFeed,
icon: const Icon(Symbols.delete_forever),
label: const Text('Delete Web Feed'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
const Spacer(),
TextButton.icon(
onPressed: isLoading.value ? null : saveFeed,
icon: const Icon(Symbols.save), icon: const Icon(Symbols.save),
label: Text('saveChanges').tr(), label: Text('saveChanges').tr(),
).alignment(Alignment.centerRight), ),
], ],
).padding(all: 20), ).padding(horizontal: 20, vertical: 12);
),
), return Column(children: [Expanded(child: formWidget), buttonsRow]);
},
); );
} }
} }

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:island/pods/webfeed.dart'; import 'package:island/pods/webfeed.dart';
import 'package:island/screens/creators/webfeed/webfeed_edit.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/empty_state.dart'; import 'package:island/widgets/empty_state.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class WebFeedListScreen extends ConsumerWidget { class WebFeedListScreen extends ConsumerWidget {
final String pubName; final String pubName;
@@ -22,9 +24,14 @@ class WebFeedListScreen extends ConsumerWidget {
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
onPressed: () { onPressed: () {
context.pushNamed( showModalBottomSheet(
'creatorFeedNew', context: context,
pathParameters: {'name': pubName}, isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'New Web Feed',
child: WebfeedForm(pubName: pubName, feedId: null),
),
); );
}, },
), ),
@@ -44,7 +51,9 @@ class WebFeedListScreen extends ConsumerWidget {
itemCount: feeds.length, itemCount: feeds.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final feed = feeds[index]; final feed = feeds[index];
return Card( return ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: Card(
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 4, vertical: 4,
@@ -67,13 +76,22 @@ class WebFeedListScreen extends ConsumerWidget {
), ),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
context.pushNamed( showModalBottomSheet(
'creatorFeedEdit', context: context,
pathParameters: {'name': pubName, 'feedId': feed.id}, isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'Edit Web Feed',
child: WebfeedForm(
pubName: pubName,
feedId: feed.id,
),
),
); );
}, },
), ),
); ),
).center();
}, },
), ),
); );

View File

@@ -434,7 +434,10 @@ class PollEditorScreen extends ConsumerWidget {
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
child: Form( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child:
Form(
key: ValueKey(model.id), key: ValueKey(model.id),
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -442,16 +445,19 @@ class PollEditorScreen extends ConsumerWidget {
TextFormField( TextFormField(
initialValue: model.title ?? '', initialValue: model.title ?? '',
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'title'.tr(), labelText: 'postTitle'.tr(),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(16)), borderRadius: BorderRadius.all(
Radius.circular(16),
),
), ),
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
maxLength: 256, maxLength: 256,
onChanged: notifier.setTitle, onChanged: notifier.setTitle,
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
validator: (v) { validator: (v) {
if (v == null || v.trim().isEmpty) { if (v == null || v.trim().isEmpty) {
return 'pollTitleRequired'.tr(); return 'pollTitleRequired'.tr();
@@ -466,14 +472,17 @@ class PollEditorScreen extends ConsumerWidget {
labelText: 'description'.tr(), labelText: 'description'.tr(),
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(16)), borderRadius: BorderRadius.all(
Radius.circular(16),
),
), ),
), ),
maxLines: 3, maxLines: 3,
maxLength: 4096, maxLength: 4096,
onChanged: notifier.setDescription, onChanged: notifier.setDescription,
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
_EndDatePicker( _EndDatePicker(
@@ -505,7 +514,8 @@ class PollEditorScreen extends ConsumerWidget {
.map( .map(
(t) => MenuItemButton( (t) => MenuItemButton(
leadingIcon: Icon(_iconForType(t)), leadingIcon: Icon(_iconForType(t)),
onPressed: () => notifier.addQuestion(t), onPressed:
() => notifier.addQuestion(t),
child: Text(_labelForType(t)), child: Text(_labelForType(t)),
), ),
) )
@@ -553,13 +563,17 @@ class PollEditorScreen extends ConsumerWidget {
question: q, question: q,
onMoveUp: onMoveUp:
index > 0 index > 0
? () => notifier.moveQuestionUp(index) ? () =>
notifier.moveQuestionUp(index)
: null, : null,
onMoveDown: onMoveDown:
index < model.questions.length - 1 index < model.questions.length - 1
? () => notifier.moveQuestionDown(index) ? () => notifier.moveQuestionDown(
index,
)
: null, : null,
onDelete: () => notifier.removeQuestion(index), onDelete:
() => notifier.removeQuestion(index),
), ),
const Divider(height: 1), const Divider(height: 1),
Padding( Padding(
@@ -577,11 +591,15 @@ class PollEditorScreen extends ConsumerWidget {
const Gap(96), const Gap(96),
], ],
), ),
).center(),
), ),
), ),
Material( Material(
elevation: 2, elevation: 2,
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
child:
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: Row( child: Row(
children: [ children: [
OutlinedButton.icon( OutlinedButton.icon(
@@ -597,7 +615,9 @@ class PollEditorScreen extends ConsumerWidget {
_submitPoll(context, ref); _submitPoll(context, ref);
}, },
icon: const Icon(Icons.cloud_upload_outlined), icon: const Icon(Icons.cloud_upload_outlined),
label: Text(model.id == null ? 'create'.tr() : 'update'.tr()), label: Text(
model.id == null ? 'create'.tr() : 'update'.tr(),
),
), ),
], ],
).padding( ).padding(
@@ -605,6 +625,7 @@ class PollEditorScreen extends ConsumerWidget {
top: 16, top: 16,
bottom: MediaQuery.of(context).padding.bottom + 16, bottom: MediaQuery.of(context).padding.bottom + 16,
), ),
).center(),
), ),
], ],
), ),

View File

@@ -149,18 +149,13 @@ class SliverPostList extends HookConsumerWidget {
Widget _buildPostItem(SnPost post) { Widget _buildPostItem(SnPost post) {
switch (itemType) { switch (itemType) {
case PostItemType.creator: case PostItemType.creator:
return Column( return PostItemCreator(
children: [
PostItemCreator(
item: post, item: post,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
padding: padding, padding: padding,
isOpenable: isOpenable, isOpenable: isOpenable,
onRefresh: onRefresh, onRefresh: onRefresh,
onUpdate: onUpdate, onUpdate: onUpdate,
),
const Divider(),
],
); );
case PostItemType.regular: case PostItemType.regular:
return Card( return Card(