✨ Web feed
This commit is contained in:
@ -341,7 +341,10 @@ class UpdateProfileScreen extends HookConsumerWidget {
|
||||
),
|
||||
|
||||
TextFormField(
|
||||
decoration: InputDecoration(labelText: 'bio'.tr()),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'bio'.tr(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: null,
|
||||
minLines: 3,
|
||||
controller: bioController,
|
||||
|
@ -669,7 +669,10 @@ class EditChatScreen extends HookConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(labelText: 'Description'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
|
@ -370,9 +370,9 @@ class CreatorHubScreen extends HookConsumerWidget {
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: Text('publisherMembers').tr(),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.group),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
onTap: () {
|
||||
@ -387,6 +387,20 @@ class CreatorHubScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: const Text('Web Feeds').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.rss_feed),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
onTap: () {
|
||||
context.push(
|
||||
'/creators/${currentPublisher.value!.name}/feeds',
|
||||
);
|
||||
},
|
||||
),
|
||||
ExpansionTile(
|
||||
title: Text('publisherFeatures').tr(),
|
||||
leading: const Icon(Symbols.flag),
|
||||
|
@ -270,7 +270,10 @@ class EditPublisherScreen extends HookConsumerWidget {
|
||||
),
|
||||
TextFormField(
|
||||
controller: bioController,
|
||||
decoration: InputDecoration(labelText: 'bio'.tr()),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'bio'.tr(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
|
@ -71,9 +71,7 @@ class SliverStickerPacksList extends HookConsumerWidget {
|
||||
subtitle: Text(sticker.description),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
context.push(
|
||||
'/creators/$pubName/stickers/${sticker.id}',
|
||||
);
|
||||
context.push('/creators/$pubName/stickers/${sticker.id}');
|
||||
},
|
||||
);
|
||||
},
|
||||
@ -230,6 +228,7 @@ class EditStickerPacksScreen extends HookConsumerWidget {
|
||||
decoration: InputDecoration(
|
||||
labelText: 'description'.tr(),
|
||||
border: const UnderlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
|
287
lib/screens/creators/webfeed/webfeed_edit.dart
Normal file
287
lib/screens/creators/webfeed/webfeed_edit.dart
Normal file
@ -0,0 +1,287 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/webfeed.dart';
|
||||
import 'package:island/pods/webfeed.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class WebFeedNewScreen extends StatelessWidget {
|
||||
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? feedId;
|
||||
|
||||
const WebFeedEditScreen({super.key, required this.pubName, this.feedId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||
final titleController = useTextEditingController();
|
||||
final urlController = useTextEditingController();
|
||||
final descriptionController = useTextEditingController();
|
||||
final isLoading = useState(false);
|
||||
final isScrapEnabled = useState(false);
|
||||
|
||||
final saveFeed = useCallback(() async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
final feed = WebFeed(
|
||||
id: feedId ?? '',
|
||||
title: titleController.text,
|
||||
url: urlController.text,
|
||||
description: descriptionController.text,
|
||||
config: WebFeedConfig(scrapPage: isScrapEnabled.value),
|
||||
publisherId: pubName,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
);
|
||||
|
||||
await ref
|
||||
.read(
|
||||
webFeedNotifierProvider((
|
||||
pubName: pubName,
|
||||
feedId: feedId,
|
||||
)).notifier,
|
||||
)
|
||||
.saveFeed(feed);
|
||||
|
||||
// Refresh the feed list
|
||||
ref.invalidate(webFeedListProvider(pubName));
|
||||
|
||||
if (context.mounted) {
|
||||
showSnackBar('Web feed saved successfully');
|
||||
context.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}, [pubName, feedId, isScrapEnabled.value, context]);
|
||||
|
||||
final deleteFeed = useCallback(() async {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'Are you sure you want to delete this web feed? This action cannot be undone.',
|
||||
'Delete Web Feed',
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(
|
||||
webFeedNotifierProvider((
|
||||
pubName: pubName,
|
||||
feedId: feedId!,
|
||||
)).notifier,
|
||||
)
|
||||
.deleteFeed();
|
||||
|
||||
ref.invalidate(webFeedListProvider(pubName));
|
||||
|
||||
if (context.mounted) {
|
||||
showSnackBar('Web feed deleted successfully');
|
||||
context.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}, [pubName, feedId, context, ref]);
|
||||
|
||||
final feedAsync = ref.watch(
|
||||
webFeedNotifierProvider((pubName: pubName, feedId: feedId)),
|
||||
);
|
||||
|
||||
return feedAsync.when(
|
||||
loading:
|
||||
() =>
|
||||
const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
error:
|
||||
(error, stack) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Error')),
|
||||
body: Center(child: Text('Error: $error')),
|
||||
),
|
||||
data: (feed) {
|
||||
// Initialize form fields if they're empty and we have a feed
|
||||
if (titleController.text.isEmpty) {
|
||||
titleController.text = feed.title;
|
||||
urlController.text = feed.url;
|
||||
descriptionController.text = feed.description ?? '';
|
||||
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 {
|
||||
showLoadingModal(context);
|
||||
try {
|
||||
await ref
|
||||
.read(
|
||||
webFeedNotifierProvider((
|
||||
pubName: pubName,
|
||||
feedId: feedId!,
|
||||
)).notifier,
|
||||
)
|
||||
.scrapFeed();
|
||||
|
||||
if (context.mounted) {
|
||||
showSnackBar('Feed scraping successfully.');
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}, [pubName, feedId, ref, context]);
|
||||
|
||||
return Scaffold(
|
||||
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: [
|
||||
TextFormField(
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a title';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL',
|
||||
hintText: 'https://example.com/feed',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a URL';
|
||||
}
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri == null || !uri.hasAbsolutePath) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('Scrape web page for content'),
|
||||
subtitle: const Text(
|
||||
'When enabled, the system will attempt to extract full content from the web page',
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
value: isScrapEnabled,
|
||||
onChanged: onScrapEnabledChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (hasFeedId) ...[
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: isLoading ? null : scrapNow,
|
||||
icon: const Icon(Symbols.refresh),
|
||||
label: const Text('Scrape Now'),
|
||||
).alignment(Alignment.centerRight),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: isLoading ? null : onSave,
|
||||
icon: const Icon(Symbols.save),
|
||||
label: Text('saveChanges').tr(),
|
||||
).alignment(Alignment.centerRight),
|
||||
],
|
||||
).padding(all: 20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
78
lib/screens/creators/webfeed/webfeed_list.dart
Normal file
78
lib/screens/creators/webfeed/webfeed_list.dart
Normal file
@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/pods/webfeed.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/empty_state.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class WebFeedListScreen extends ConsumerWidget {
|
||||
final String pubName;
|
||||
|
||||
const WebFeedListScreen({super.key, required this.pubName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final feedsAsync = ref.watch(webFeedListProvider(pubName));
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: const Text('Web Feeds')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Symbols.add),
|
||||
onPressed: () {
|
||||
context.push('/creators/$pubName/feeds/new');
|
||||
},
|
||||
),
|
||||
body: feedsAsync.when(
|
||||
data: (feeds) {
|
||||
if (feeds.isEmpty) {
|
||||
return EmptyState(
|
||||
icon: Symbols.rss_feed,
|
||||
title: 'No Web Feeds',
|
||||
description: 'Add a new web feed to get started',
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(webFeedListProvider(pubName).future),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
itemCount: feeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final feed = feeds[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Symbols.rss_feed, size: 32),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
title: Text(
|
||||
feed.title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
feed.url,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
context.push('/creators/$pubName/feeds/${feed.id}');
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text('Error: $error')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -378,6 +378,7 @@ class EditAppScreen extends HookConsumerWidget {
|
||||
controller: descriptionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'description'.tr(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 3,
|
||||
onTapOutside:
|
||||
|
@ -344,7 +344,10 @@ class EditRealmScreen extends HookConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
decoration: InputDecoration(labelText: 'description'.tr()),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'description'.tr(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
|
Reference in New Issue
Block a user