🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,434 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'hub.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(publisherStats)
final publisherStatsProvider = PublisherStatsFamily._();
final class PublisherStatsProvider
extends
$FunctionalProvider<
AsyncValue<SnPublisherStats?>,
SnPublisherStats?,
FutureOr<SnPublisherStats?>
>
with
$FutureModifier<SnPublisherStats?>,
$FutureProvider<SnPublisherStats?> {
PublisherStatsProvider._({
required PublisherStatsFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'publisherStatsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$publisherStatsHash();
@override
String toString() {
return r'publisherStatsProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnPublisherStats?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnPublisherStats?> create(Ref ref) {
final argument = this.argument as String?;
return publisherStats(ref, argument);
}
@override
bool operator ==(Object other) {
return other is PublisherStatsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$publisherStatsHash() => r'eea4ed98bf165cc785874f83b912bb7e23d1f7bc';
final class PublisherStatsFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnPublisherStats?>, String?> {
PublisherStatsFamily._()
: super(
retry: null,
name: r'publisherStatsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PublisherStatsProvider call(String? uname) =>
PublisherStatsProvider._(argument: uname, from: this);
@override
String toString() => r'publisherStatsProvider';
}
@ProviderFor(publisherHeatmap)
final publisherHeatmapProvider = PublisherHeatmapFamily._();
final class PublisherHeatmapProvider
extends
$FunctionalProvider<
AsyncValue<SnHeatmap?>,
SnHeatmap?,
FutureOr<SnHeatmap?>
>
with $FutureModifier<SnHeatmap?>, $FutureProvider<SnHeatmap?> {
PublisherHeatmapProvider._({
required PublisherHeatmapFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'publisherHeatmapProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$publisherHeatmapHash();
@override
String toString() {
return r'publisherHeatmapProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnHeatmap?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<SnHeatmap?> create(Ref ref) {
final argument = this.argument as String?;
return publisherHeatmap(ref, argument);
}
@override
bool operator ==(Object other) {
return other is PublisherHeatmapProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$publisherHeatmapHash() => r'5f70c55e14629ec8628445a317888e02fccd9af2';
final class PublisherHeatmapFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnHeatmap?>, String?> {
PublisherHeatmapFamily._()
: super(
retry: null,
name: r'publisherHeatmapProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PublisherHeatmapProvider call(String? uname) =>
PublisherHeatmapProvider._(argument: uname, from: this);
@override
String toString() => r'publisherHeatmapProvider';
}
@ProviderFor(publisherIdentity)
final publisherIdentityProvider = PublisherIdentityFamily._();
final class PublisherIdentityProvider
extends
$FunctionalProvider<
AsyncValue<SnPublisherMember?>,
SnPublisherMember?,
FutureOr<SnPublisherMember?>
>
with
$FutureModifier<SnPublisherMember?>,
$FutureProvider<SnPublisherMember?> {
PublisherIdentityProvider._({
required PublisherIdentityFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'publisherIdentityProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$publisherIdentityHash();
@override
String toString() {
return r'publisherIdentityProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnPublisherMember?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnPublisherMember?> create(Ref ref) {
final argument = this.argument as String;
return publisherIdentity(ref, argument);
}
@override
bool operator ==(Object other) {
return other is PublisherIdentityProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$publisherIdentityHash() => r'299372f25fa4b2bf8e11a8ba2d645100fc38e76f';
final class PublisherIdentityFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnPublisherMember?>, String> {
PublisherIdentityFamily._()
: super(
retry: null,
name: r'publisherIdentityProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PublisherIdentityProvider call(String uname) =>
PublisherIdentityProvider._(argument: uname, from: this);
@override
String toString() => r'publisherIdentityProvider';
}
@ProviderFor(publisherFeatures)
final publisherFeaturesProvider = PublisherFeaturesFamily._();
final class PublisherFeaturesProvider
extends
$FunctionalProvider<
AsyncValue<Map<String, bool>>,
Map<String, bool>,
FutureOr<Map<String, bool>>
>
with
$FutureModifier<Map<String, bool>>,
$FutureProvider<Map<String, bool>> {
PublisherFeaturesProvider._({
required PublisherFeaturesFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'publisherFeaturesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$publisherFeaturesHash();
@override
String toString() {
return r'publisherFeaturesProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<Map<String, bool>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<Map<String, bool>> create(Ref ref) {
final argument = this.argument as String?;
return publisherFeatures(ref, argument);
}
@override
bool operator ==(Object other) {
return other is PublisherFeaturesProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$publisherFeaturesHash() => r'08bace2d9a3da227ecec0cbf8709e55ee0646ca2';
final class PublisherFeaturesFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<Map<String, bool>>, String?> {
PublisherFeaturesFamily._()
: super(
retry: null,
name: r'publisherFeaturesProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PublisherFeaturesProvider call(String? uname) =>
PublisherFeaturesProvider._(argument: uname, from: this);
@override
String toString() => r'publisherFeaturesProvider';
}
@ProviderFor(publisherInvites)
final publisherInvitesProvider = PublisherInvitesProvider._();
final class PublisherInvitesProvider
extends
$FunctionalProvider<
AsyncValue<List<SnPublisherMember>>,
List<SnPublisherMember>,
FutureOr<List<SnPublisherMember>>
>
with
$FutureModifier<List<SnPublisherMember>>,
$FutureProvider<List<SnPublisherMember>> {
PublisherInvitesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'publisherInvitesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$publisherInvitesHash();
@$internal
@override
$FutureProviderElement<List<SnPublisherMember>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnPublisherMember>> create(Ref ref) {
return publisherInvites(ref);
}
}
String _$publisherInvitesHash() => r'93aafc2f02af0a7a055ec1770b3999363dfaabdc';
@ProviderFor(publisherActorStatus)
final publisherActorStatusProvider = PublisherActorStatusFamily._();
final class PublisherActorStatusProvider
extends
$FunctionalProvider<
AsyncValue<SnActorStatusResponse>,
SnActorStatusResponse,
FutureOr<SnActorStatusResponse>
>
with
$FutureModifier<SnActorStatusResponse>,
$FutureProvider<SnActorStatusResponse> {
PublisherActorStatusProvider._({
required PublisherActorStatusFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'publisherActorStatusProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$publisherActorStatusHash();
@override
String toString() {
return r'publisherActorStatusProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnActorStatusResponse> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnActorStatusResponse> create(Ref ref) {
final argument = this.argument as String?;
return publisherActorStatus(ref, argument);
}
@override
bool operator ==(Object other) {
return other is PublisherActorStatusProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$publisherActorStatusHash() =>
r'406117cb99b2aef236945ef0ef59e857d8835029';
final class PublisherActorStatusFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnActorStatusResponse>, String?> {
PublisherActorStatusFamily._()
: super(
retry: null,
name: r'publisherActorStatusProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PublisherActorStatusProvider call(String? publisherName) =>
PublisherActorStatusProvider._(argument: publisherName, from: this);
@override
String toString() => r'publisherActorStatusProvider';
}

View File

@@ -0,0 +1,229 @@
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/pagination/pagination.dart';
import 'package:island/polls/poll/poll_editor.dart';
import 'package:island/polls/polls_widgets/poll/poll_feedback.dart';
import 'package:island/posts/posts_models/poll.dart';
import 'package:island/core/network.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/shared/widgets/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/shared/widgets/extended_refresh_indicator.dart';
import 'package:styled_widget/styled_widget.dart';
part 'poll_list.g.dart';
final pollListNotifierProvider = AsyncNotifierProvider.family.autoDispose(
PollListNotifier.new,
);
class PollListNotifier extends AsyncNotifier<PaginationState<SnPollWithStats>>
with AsyncPaginationController<SnPollWithStats> {
static const int pageSize = 20;
final String? arg;
PollListNotifier(this.arg);
@override
Future<List<SnPollWithStats>> fetch() async {
final client = ref.read(apiClientProvider);
// read the current family argument passed to provider
final queryParams = {
'offset': fetchedCount.toString(),
'take': pageSize,
if (arg != null) 'pub': arg,
};
final response = await client.get(
'/sphere/polls/me',
queryParameters: queryParams,
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final items = response.data
.map((json) => SnPollWithStats.fromJson(json))
.cast<SnPollWithStats>()
.toList();
return items;
}
}
@riverpod
Future<SnPollWithStats> pollWithStats(Ref ref, String id) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/polls/$id');
return SnPollWithStats.fromJson(resp.data);
}
class CreatorPollListScreen extends HookConsumerWidget {
const CreatorPollListScreen({super.key, required this.pubName});
final String pubName;
Future<void> _createPoll(BuildContext context) async {
final result = await showModalBottomSheet<SnPollWithStats>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
builder: (context) => PollEditorScreen(initialPublisher: pubName),
);
if (result != null && context.mounted) {
Navigator.of(context).maybePop(result);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: const Text('Polls')),
floatingActionButton: FloatingActionButton(
onPressed: () => _createPoll(context),
child: const Icon(Icons.add),
),
body: ExtendedRefreshIndicator(
onRefresh: () => ref.refresh(pollListNotifierProvider(pubName).future),
child: PaginationList(
footerSkeletonMaxWidth: 640,
provider: pollListNotifierProvider(pubName),
notifier: pollListNotifierProvider(pubName).notifier,
padding: const EdgeInsets.only(top: 12),
itemBuilder: (context, index, pollWithStats) {
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: _CreatorPollItem(
pollWithStats: pollWithStats,
pubName: pubName,
),
).center();
},
),
),
);
}
}
class _CreatorPollItem extends HookConsumerWidget {
final String pubName;
const _CreatorPollItem({required this.pollWithStats, required this.pubName});
final SnPollWithStats pollWithStats;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final ended = pollWithStats.endedAt;
final endedText = ended == null
? 'No end'
: MaterialLocalizations.of(context).formatFullDate(ended);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
clipBehavior: Clip.antiAlias,
child: ListTile(
title: Text(pollWithStats.title ?? 'Untitled poll'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (pollWithStats.description != null &&
pollWithStats.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
pollWithStats.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Questions: ${pollWithStats.questions.length} · Ends: $endedText',
style: theme.textTheme.bodySmall,
),
),
],
),
trailing: PopupMenuButton<String>(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () async {
final result = await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
isDismissible: false,
builder: (context) => PollEditorScreen(
initialPublisher: pubName,
initialPollId: pollWithStats.id,
),
);
if (result != null && context.mounted) {
ref.invalidate(pollListNotifierProvider(pubName));
}
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete').tr().textColor(Colors.red),
],
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete Poll'),
content: Text('Are you sure you want to delete this poll?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete('/sphere/polls/${pollWithStats.id}');
ref.invalidate(pollListNotifierProvider(pubName));
showSnackBar('Poll deleted successfully');
} catch (e) {
showErrorAlert(e);
}
}
},
),
],
),
onTap: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => PollFeedbackSheet(pollId: pollWithStats.id),
);
},
),
);
}
}

View File

@@ -0,0 +1,85 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'poll_list.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(pollWithStats)
final pollWithStatsProvider = PollWithStatsFamily._();
final class PollWithStatsProvider
extends
$FunctionalProvider<
AsyncValue<SnPollWithStats>,
SnPollWithStats,
FutureOr<SnPollWithStats>
>
with $FutureModifier<SnPollWithStats>, $FutureProvider<SnPollWithStats> {
PollWithStatsProvider._({
required PollWithStatsFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'pollWithStatsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pollWithStatsHash();
@override
String toString() {
return r'pollWithStatsProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnPollWithStats> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnPollWithStats> create(Ref ref) {
final argument = this.argument as String;
return pollWithStats(ref, argument);
}
@override
bool operator ==(Object other) {
return other is PollWithStatsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$pollWithStatsHash() => r'6bb910046ce1e09368f9922dbec52fdc2cc86740';
final class PollWithStatsFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnPollWithStats>, String> {
PollWithStatsFamily._()
: super(
retry: null,
name: r'pollWithStatsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PollWithStatsProvider call(String id) =>
PollWithStatsProvider._(argument: id, from: this);
@override
String toString() => r'pollWithStatsProvider';
}

View File

@@ -0,0 +1,33 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/posts/post/post_list.dart';
import 'package:island/posts/posts_widgets/post/post_list.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
class CreatorPostListScreen extends HookConsumerWidget {
final String pubName;
const CreatorPostListScreen({super.key, required this.pubName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final refreshKey = useState(0);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text('posts').tr()),
body: CustomScrollView(
key: ValueKey(refreshKey.value),
slivers: [
SliverPostList(
query: PostListQuery(pubName: pubName),
itemType: PostItemType.creator,
maxWidth: 640,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
),
],
),
);
}
}

View File

@@ -0,0 +1,328 @@
import 'package:collection/collection.dart';
import 'package:croppy/croppy.dart' hide cropImage;
import 'package:dio/dio.dart';
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:image_picker/image_picker.dart';
import 'package:island/core/services/image.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/posts/posts_models/publisher.dart';
import 'package:island/realms/realm/realms.dart';
import 'package:island/realms/realms_models/realm.dart';
import 'package:island/core/network.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'publishers_form.g.dart';
@riverpod
Future<List<SnPublisher>> publishersManaged(Ref ref) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/sphere/publishers');
return resp.data
.map((e) => SnPublisher.fromJson(e))
.cast<SnPublisher>()
.toList();
}
@riverpod
Future<SnPublisher?> publisherNullable(Ref ref, String? identifier) async {
if (identifier == null) return null;
final client = ref.watch(apiClientProvider);
final resp = await client.get('/sphere/publishers/$identifier');
return SnPublisher.fromJson(resp.data);
}
class NewPublisherScreen extends StatelessWidget {
const NewPublisherScreen({super.key});
@override
Widget build(BuildContext context) {
return EditPublisherScreen(key: key);
}
}
class EditPublisherScreen extends HookConsumerWidget {
final String? name;
const EditPublisherScreen({super.key, this.name});
@override
Widget build(BuildContext context, WidgetRef ref) {
final submitting = useState(false);
final picture = useState<String?>(null);
final background = useState<String?>(null);
void setPicture(String position) async {
showLoadingModal(context);
var result = await ref
.read(imagePickerProvider)
.pickImage(source: ImageSource.gallery);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
hideLoadingModal(context);
result = await cropImage(
context,
image: result,
replacePath: true,
allowedAspectRatios: [
if (position == 'background')
CropAspectRatio(height: 7, width: 16)
else
CropAspectRatio(height: 1, width: 1),
],
);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
showLoadingModal(context);
submitting.value = true;
try {
final cloudFile = await FileUploader.createCloudFile(
ref: ref,
fileData: UniversalFile(data: result, type: UniversalFileType.image),
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
switch (position) {
case 'picture':
picture.value = cloudFile.id;
case 'background':
background.value = cloudFile.id;
}
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
if (context.mounted) hideLoadingModal(context);
}
}
final publisher = ref.watch(publisherNullableProvider(name));
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
final nameController = useTextEditingController(
text: publisher.value?.name,
);
final nickController = useTextEditingController(
text: publisher.value?.nick,
);
final bioController = useTextEditingController(text: publisher.value?.bio);
final joinedRealms = ref.watch(realmsJoinedProvider);
final currentRealm = useState<SnRealm?>(null);
useEffect(() {
if (publisher.value != null) {
picture.value = publisher.value!.picture?.id;
background.value = publisher.value!.background?.id;
nameController.text = publisher.value!.name;
nickController.text = publisher.value!.nick;
bioController.text = publisher.value!.bio;
currentRealm.value = joinedRealms.value?.firstWhereOrNull(
(realm) => realm.id == publisher.value!.realmId,
);
}
return null;
}, [publisher]);
Future<void> performAction() async {
if (!formKey.currentState!.validate()) return;
submitting.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.request(
'/sphere${name == null
? currentRealm.value == null
? '/publishers/individual'
: '/publishers/organization/${currentRealm.value!.slug}'
: '/publishers/$name'}',
data: {
'name': nameController.text,
'nick': nickController.text,
'bio': bioController.text,
'picture_id': picture.value,
'background_id': background.value,
},
options: Options(method: name == null ? 'POST' : 'PATCH'),
);
if (context.mounted) {
context.pop(SnPublisher.fromJson(resp.data));
}
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
final titleText = (name == null ? 'createPublisher' : 'editPublisher').tr();
return SheetScaffold(
titleText: titleText,
child: SingleChildScrollView(
padding: EdgeInsets.only(bottom: 16),
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: background.value != null
? CloudImageWidget(
fileId: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
fileId: picture.value,
radius: 40,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 480),
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: 'username'.tr(),
helperText: 'usernameCannotChangeHint'.tr(),
prefixText: '@',
),
readOnly: name != null,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
TextFormField(
controller: nickController,
decoration: InputDecoration(labelText: 'nickname'.tr()),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
TextFormField(
controller: bioController,
decoration: InputDecoration(
labelText: 'bio'.tr(),
alignLabelWithHint: true,
),
minLines: 3,
maxLines: null,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
DropdownButtonFormField<SnRealm>(
value: currentRealm.value,
decoration: InputDecoration(labelText: 'realm'.tr()),
items: [
DropdownMenuItem<SnRealm>(
value: null,
child: Text('individual'.tr()),
),
...joinedRealms.maybeWhen(
data: (realms) => realms.map(
(realm) => DropdownMenuItem(
value: realm,
child: Text(realm.name),
),
),
orElse: () => [],
),
],
onChanged: joinedRealms.isLoading
? null
: (SnRealm? value) {
currentRealm.value = value;
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
onPressed: () {
if (currentRealm.value == null) {
final user = ref.watch(userInfoProvider);
nameController.text = user.value!.name;
nickController.text = user.value!.nick;
bioController.text = user.value!.profile.bio;
picture.value = user.value!.profile.picture?.id;
background.value =
user.value!.profile.background?.id;
} else {
nameController.text = currentRealm.value!.slug;
nickController.text = currentRealm.value!.name;
bioController.text =
currentRealm.value!.description;
picture.value = currentRealm.value!.picture?.id;
background.value =
currentRealm.value!.background?.id;
}
},
label: Text(
currentRealm.value == null
? 'syncPublisher'
: 'syncPublisherRealm',
).tr(),
icon: const Icon(Symbols.link),
),
TextButton.icon(
onPressed: submitting.value ? null : performAction,
label: Text(
name == null ? 'create' : 'saveChanges',
).tr(),
icon: const Icon(Symbols.save),
),
],
),
],
).padding(horizontal: 24),
).alignment(Alignment.topCenter),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,126 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'publishers_form.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(publishersManaged)
final publishersManagedProvider = PublishersManagedProvider._();
final class PublishersManagedProvider
extends
$FunctionalProvider<
AsyncValue<List<SnPublisher>>,
List<SnPublisher>,
FutureOr<List<SnPublisher>>
>
with
$FutureModifier<List<SnPublisher>>,
$FutureProvider<List<SnPublisher>> {
PublishersManagedProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'publishersManagedProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$publishersManagedHash();
@$internal
@override
$FutureProviderElement<List<SnPublisher>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnPublisher>> create(Ref ref) {
return publishersManaged(ref);
}
}
String _$publishersManagedHash() => r'ea83759fed9bd5119738b4d09f12b4476959e0a3';
@ProviderFor(publisherNullable)
final publisherNullableProvider = PublisherNullableFamily._();
final class PublisherNullableProvider
extends
$FunctionalProvider<
AsyncValue<SnPublisher?>,
SnPublisher?,
FutureOr<SnPublisher?>
>
with $FutureModifier<SnPublisher?>, $FutureProvider<SnPublisher?> {
PublisherNullableProvider._({
required PublisherNullableFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'publisherNullableProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$publisherNullableHash();
@override
String toString() {
return r'publisherNullableProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnPublisher?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnPublisher?> create(Ref ref) {
final argument = this.argument as String?;
return publisherNullable(ref, argument);
}
@override
bool operator ==(Object other) {
return other is PublisherNullableProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$publisherNullableHash() => r'49b28083a2f351c5e5cde0b1a97f6c7503969041';
final class PublisherNullableFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnPublisher?>, String?> {
PublisherNullableFamily._()
: super(
retry: null,
name: r'publisherNullableProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PublisherNullableProvider call(String? identifier) =>
PublisherNullableProvider._(argument: identifier, from: this);
@override
String toString() => r'publisherNullableProvider';
}

View File

@@ -0,0 +1,157 @@
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/creators/publication_site.dart';
import 'package:island/core/network.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/sites/site_pages.dart';
import 'package:island/sites/sites_widgets/file_management_action_section.dart';
import 'package:island/sites/sites_widgets/file_management_section.dart';
import 'package:island/sites/sites_widgets/page_form.dart';
import 'package:island/sites/sites_widgets/pages_section.dart';
import 'package:island/sites/sites_widgets/site_action_menu.dart';
import 'package:island/sites/sites_widgets/site_detail_content.dart';
import 'package:island/sites/sites_widgets/site_info_card.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/shared/widgets/extended_refresh_indicator.dart';
import 'package:styled_widget/styled_widget.dart';
part 'site_detail.g.dart';
@riverpod
Future<SnPublicationSite> publicationSiteDetail(
Ref ref,
String pubName,
String siteSlug,
) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/zone/sites/$pubName/$siteSlug');
return SnPublicationSite.fromJson(resp.data);
}
class PublicationSiteDetailScreen extends HookConsumerWidget {
final String siteSlug;
final String pubName;
const PublicationSiteDetailScreen({
super.key,
required this.siteSlug,
required this.pubName,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final siteAsync = ref.watch(
publicationSiteDetailProvider(pubName, siteSlug),
);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: siteAsync.maybeWhen(
data: (site) => Text(site.name),
orElse: () => Text('siteDetails'.tr()),
),
actions: [
siteAsync.maybeWhen(
data: (site) => SiteActionMenu(site: site, pubName: pubName),
orElse: () => const SizedBox.shrink(),
),
const Gap(8),
],
),
body: siteAsync.when(
data: (site) {
if (isWideScreen(context)) {
return ExtendedRefreshIndicator(
onRefresh: () async => ref.invalidate(
publicationSiteDetailProvider(pubName, site.slug),
),
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PagesSection(site: site, pubName: pubName),
if (site.mode == 1) // Self-Managed only
FileManagementSection(site: site, pubName: pubName),
],
),
),
),
Expanded(
flex: 2,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SiteInfoCard(site: site),
const Gap(8),
if (site.mode == 1) // Self-Managed only
FileManagementActionSection(
site: site,
pubName: pubName,
),
],
),
),
),
],
).padding(horizontal: 12),
);
} else {
return SiteDetailContent(site: site, pubName: pubName);
}
},
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'failedToLoadSite'.tr(),
style: Theme.of(context).textTheme.headlineSmall,
),
const Gap(16),
Text(error.toString()),
const Gap(24),
ElevatedButton(
onPressed: () => ref.invalidate(
publicationSiteDetailProvider(pubName, siteSlug),
),
child: Text('retry'.tr()),
),
],
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
floatingActionButton: siteAsync.maybeWhen(
data: (site) => FloatingActionButton(
onPressed: () {
// Create new page
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => PageForm(site: site, pubName: pubName),
).then((_) {
// Refresh pages after creation
ref.invalidate(sitePagesProvider(pubName, site.slug));
});
},
child: const Icon(Symbols.add),
),
orElse: () => null,
),
);
}
}

View File

@@ -0,0 +1,95 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'site_detail.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(publicationSiteDetail)
final publicationSiteDetailProvider = PublicationSiteDetailFamily._();
final class PublicationSiteDetailProvider
extends
$FunctionalProvider<
AsyncValue<SnPublicationSite>,
SnPublicationSite,
FutureOr<SnPublicationSite>
>
with
$FutureModifier<SnPublicationSite>,
$FutureProvider<SnPublicationSite> {
PublicationSiteDetailProvider._({
required PublicationSiteDetailFamily super.from,
required (String, String) super.argument,
}) : super(
retry: null,
name: r'publicationSiteDetailProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$publicationSiteDetailHash();
@override
String toString() {
return r'publicationSiteDetailProvider'
''
'$argument';
}
@$internal
@override
$FutureProviderElement<SnPublicationSite> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnPublicationSite> create(Ref ref) {
final argument = this.argument as (String, String);
return publicationSiteDetail(ref, argument.$1, argument.$2);
}
@override
bool operator ==(Object other) {
return other is PublicationSiteDetailProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$publicationSiteDetailHash() =>
r'e5d259ea39c4ba47e92d37e644fc3d84984927a9';
final class PublicationSiteDetailFamily extends $Family
with
$FunctionalFamilyOverride<
FutureOr<SnPublicationSite>,
(String, String)
> {
PublicationSiteDetailFamily._()
: super(
retry: null,
name: r'publicationSiteDetailProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PublicationSiteDetailProvider call(String pubName, String siteSlug) =>
PublicationSiteDetailProvider._(
argument: (pubName, siteSlug),
from: this,
);
@override
String toString() => r'publicationSiteDetailProvider';
}

View File

@@ -0,0 +1,397 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/creators/creators/sites/site_detail.dart';
import 'package:island/creators/creators/sites/site_list.dart';
import 'package:island/creators/creators/sites/widgets/site_config_form.dart';
import 'package:island/creators/publication_site.dart';
import 'package:island/core/network.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/shared/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class SiteForm extends HookConsumerWidget {
final String pubName;
final String? siteSlug;
const SiteForm({super.key, required this.pubName, this.siteSlug});
Widget _buildForm(
GlobalKey<FormState> formKey,
TextEditingController slugController,
TextEditingController nameController,
TextEditingController descriptionController,
ValueNotifier<int> modeController,
ValueNotifier<SnPublicationSiteConfig> configController,
Function() saveSite,
Function() deleteSite,
String siteSlug,
) {
final formFields = Column(
children: [
TextFormField(
controller: slugController,
decoration: InputDecoration(
labelText: 'siteSlug'.tr(),
hintText: 'siteSlugHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'siteSlugRequired'.tr();
}
final slugRegex = RegExp(r'^[a-z0-9]+(?:-[a-z0-9]+)*$');
if (!slugRegex.hasMatch(value)) {
return 'siteSlugInvalid'.tr();
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: 'siteName'.tr(),
hintText: 'siteNameHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'siteNameRequired'.tr();
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(
labelText: 'description'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
maxLines: 3,
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: modeController.value,
decoration: InputDecoration(
labelText: 'siteMode'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
items: [
DropdownMenuItem(
value: 0,
child: Text('siteModeFullyManaged'.tr()),
),
DropdownMenuItem(value: 1, child: Text('siteModeSelfManaged'.tr())),
],
onChanged: (value) {
if (value != null) {
modeController.value = value;
}
},
),
const SizedBox(height: 16),
SiteConfigForm(
initialConfig: configController.value,
onChanged: (value) => configController.value = value,
),
],
).padding(all: 20);
return SheetScaffold(
titleText: 'editPublicationSite'.tr(),
child: Builder(
builder: (context) => SingleChildScrollView(
child: Column(
children: [
Form(key: formKey, child: formFields),
Row(
children: [
TextButton.icon(
onPressed: deleteSite,
icon: const Icon(Symbols.delete_forever),
label: Text('deletePublicationSite'.tr()),
style: TextButton.styleFrom(foregroundColor: Colors.red),
).alignment(Alignment.centerRight),
const Spacer(),
TextButton.icon(
onPressed: saveSite,
icon: const Icon(Symbols.save),
label: Text('saveChanges').tr(),
),
],
).padding(horizontal: 20, vertical: 12),
],
),
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final slugController = useTextEditingController();
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final modeController = useState<int>(0); // Default to fully managed (0)
final configController = useState<SnPublicationSiteConfig>(
const SnPublicationSiteConfig(),
);
final isLoading = useState(false);
final saveSite = useCallback(() async {
if (!formKey.currentState!.validate()) return;
isLoading.value = true;
try {
final client = ref.read(apiClientProvider);
final url = '/zone/sites/$pubName';
final payload = <String, dynamic>{
'slug': slugController.text,
'name': nameController.text,
'mode': modeController.value,
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
'config': configController.value.toJson(),
};
if (siteSlug != null) {
await client.patch('$url/$siteSlug', data: payload);
} else {
await client.post(url, data: payload);
}
// Refresh the site list
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
showSnackBar('publicationSiteSavedSuccess'.tr());
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}, [pubName, siteSlug, context]);
final deleteSite = useCallback(() async {
if (siteSlug == null) return; // Shouldn't happen for editing
final confirmed = await showConfirmAlert(
'publicationSiteDeleteConfirm'.tr(),
'deletePublicationSite'.tr(),
isDanger: true,
);
if (confirmed != true) return;
isLoading.value = true;
try {
final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/$pubName/$siteSlug');
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
showSnackBar('publicationSiteDeletedSuccess'.tr());
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}, [pubName, siteSlug, context]);
// Use Riverpod provider for loading and error states for editing
if (siteSlug != null) {
final editingSiteSlug =
siteSlug!; // Assert non-null since we checked above
final siteAsync = ref.watch(
publicationSiteDetailProvider(pubName, editingSiteSlug),
);
// Initialize form fields when site data is loaded
useEffect(() {
if (siteAsync.value != null && nameController.text.isEmpty) {
final site = siteAsync.value!;
slugController.text = site.slug;
nameController.text = site.name;
descriptionController.text = site.description ?? '';
modeController.value = site.mode ?? 0;
configController.value = site.config;
}
return null;
}, [siteAsync]);
// Handle loading and error states for editing using AsyncValue
return siteAsync.when(
data: (_) => _buildForm(
formKey,
slugController,
nameController,
descriptionController,
modeController,
configController,
saveSite,
deleteSite,
editingSiteSlug,
),
loading: () => SheetScaffold(
titleText: 'editPublicationSite'.tr(),
child: Center(child: CircularProgressIndicator()),
),
error: (error, _) => SheetScaffold(
titleText: 'editPublicationSite'.tr(),
child: ResponseErrorWidget(
error: error.toString(),
onRetry: () {
ref.invalidate(
publicationSiteDetailProvider(pubName, editingSiteSlug),
);
},
),
),
);
}
// For new sites, directly show the form
final formFields = Column(
children: [
TextFormField(
controller: slugController,
decoration: const InputDecoration(
labelText: 'Slug',
hintText: 'my-site',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a slug';
}
final slugRegex = RegExp(r'^[a-z0-9]+(?:-[a-z0-9]+)*$');
if (!slugRegex.hasMatch(value)) {
return 'Slug can only contain lowercase letters, numbers, and dashes';
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Site Name',
hintText: 'My Publication Site',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a site name';
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
maxLines: 3,
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: modeController.value,
decoration: const InputDecoration(
labelText: 'Mode',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
items: [
DropdownMenuItem(
value: 0,
child: Text('siteModeFullyManaged'.tr()),
),
DropdownMenuItem(value: 1, child: Text('siteModeSelfManaged'.tr())),
],
onChanged: (value) {
if (value != null) {
modeController.value = value;
}
},
),
const SizedBox(height: 16),
SiteConfigForm(
initialConfig: configController.value,
onChanged: (value) => configController.value = value,
),
],
).padding(all: 20);
final saveButton = TextButton.icon(
onPressed: isLoading.value ? null : saveSite,
icon: const Icon(Symbols.save),
label: Text('saveChanges').tr(),
).padding(horizontal: 20, vertical: 12);
return SheetScaffold(
titleText: siteSlug == null
? 'newPublicationSite'.tr()
: 'editPublicationSite'.tr(),
child: SingleChildScrollView(
child: Column(
children: [
Form(key: formKey, child: formFields),
Row(
children: [
if (siteSlug != null) ...[
TextButton.icon(
onPressed: isLoading.value ? null : deleteSite,
icon: const Icon(Symbols.delete_forever),
label: Text('deletePublicationSite'.tr()),
style: TextButton.styleFrom(foregroundColor: Colors.red),
).alignment(Alignment.centerRight),
const SizedBox(height: 16),
],
const Spacer(),
saveButton,
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,197 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/creators/creators/sites/site_edit.dart';
import 'package:island/creators/publication_site.dart';
import 'package:island/core/network.dart';
import 'package:island/pagination/pagination.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/shared/widgets/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
final siteListNotifierProvider = AsyncNotifierProvider.family.autoDispose(
SiteListNotifier.new,
);
class SiteListNotifier extends AsyncNotifier<PaginationState<SnPublicationSite>>
with AsyncPaginationController<SnPublicationSite> {
static const int pageSize = 20;
final String arg;
SiteListNotifier(this.arg);
@override
Future<List<SnPublicationSite>> fetch() async {
final client = ref.read(apiClientProvider);
// read the current family argument passed to provider
final queryParams = {'offset': fetchedCount.toString(), 'take': pageSize};
final response = await client.get(
'/zone/sites/$arg',
queryParameters: queryParams,
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final items = response.data
.map((json) => SnPublicationSite.fromJson(json))
.cast<SnPublicationSite>()
.toList();
return items;
}
}
class CreatorSiteListScreen extends HookConsumerWidget {
const CreatorSiteListScreen({super.key, required this.pubName});
final String pubName;
Future<void> _createSite(BuildContext context) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SiteForm(pubName: pubName),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text('publicationSites'.tr())),
floatingActionButton: FloatingActionButton(
onPressed: () => _createSite(context),
child: Icon(Icons.add),
),
body: PaginationList(
footerSkeletonMaxWidth: 640,
provider: siteListNotifierProvider(pubName),
notifier: siteListNotifierProvider(pubName).notifier,
padding: const EdgeInsets.only(top: 12),
itemBuilder: (context, index, site) {
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: _CreatorSiteItem(site: site, pubName: pubName),
).center();
},
),
);
}
}
class _CreatorSiteItem extends HookConsumerWidget {
final String pubName;
const _CreatorSiteItem({required this.site, required this.pubName});
final SnPublicationSite site;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// Navigate to site detail screen
context.pushNamed(
'creatorSiteDetail',
pathParameters: {'name': pubName, 'siteSlug': site.slug},
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
spacing: 2,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.globe,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const Gap(6),
Text(site.name).bold(),
],
),
if (site.description != null &&
site.description!.isNotEmpty)
Text(
site.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Divider(height: 8),
Text(
'${site.slug}.solian.page',
style: GoogleFonts.robotoMono(fontSize: 11),
).opacity(0.8),
],
),
),
PopupMenuButton<String>(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) =>
SiteForm(pubName: pubName, siteSlug: site.slug),
);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete').tr().textColor(Colors.red),
],
),
onTap: () async {
final confirmed = await showConfirmAlert(
'publicationSiteDeleteConfirm'.tr(),
'deleteSite'.tr(),
isDanger: true,
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete(
'/zone/sites/$pubName/${site.slug}',
);
ref.invalidate(siteListNotifierProvider(pubName));
showSnackBar('siteDeletedSuccess'.tr());
} catch (e) {
showErrorAlert(e);
}
}
},
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,172 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:island/creators/publication_site.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class SiteConfigForm extends HookWidget {
final SnPublicationSiteConfig? initialConfig;
final ValueChanged<SnPublicationSiteConfig> onChanged;
const SiteConfigForm({
super.key,
this.initialConfig,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final styleOverrideController = useTextEditingController(
text: initialConfig?.styleOverride,
);
final navItems = useState<List<SnPublicationSiteNavItems>>(
initialConfig?.navItems ?? [],
);
useEffect(() {
void listener() {
onChanged(
SnPublicationSiteConfig(
styleOverride: styleOverrideController.text,
navItems: navItems.value,
),
);
}
styleOverrideController.addListener(listener);
return () => styleOverrideController.removeListener(listener);
}, [styleOverrideController, navItems.value]);
return Card(
margin: EdgeInsets.zero,
child: ExpansionTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
title: Text('siteConfig'.tr()),
children: [
Column(
spacing: 8,
children: [
TextFormField(
controller: styleOverrideController,
decoration: InputDecoration(
labelText: 'siteConfigStyleOverride'.tr(),
hintText: "You can paste your CSS here...",
alignLabelWithHint: true,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
minLines: 3,
maxLines: null,
).padding(bottom: 8),
Row(
children: [
Text('siteConfigNavItems'.tr()).bold(),
const Spacer(),
TextButton.icon(
onPressed: () {
navItems.value = [
...navItems.value,
const SnPublicationSiteNavItems(label: '', href: ''),
];
// Trigger update manually as list mutation doesn't trigger controller listener
onChanged(
SnPublicationSiteConfig(
styleOverride: styleOverrideController.text,
navItems: navItems.value,
),
);
},
icon: const Icon(Symbols.add),
label: Text('siteConfigAddNavItem'.tr()),
),
],
).padding(bottom: 4),
if (navItems.value.isEmpty)
Text('dataEmpty'.tr()).center().padding(vertical: 20),
...navItems.value.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
borderRadius: BorderRadius.circular(10),
),
child: Column(
spacing: 12,
children: [
TextFormField(
initialValue: item.label,
decoration: InputDecoration(
labelText: 'siteConfigNavItemLabel'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onChanged: (value) {
final newItems = [...navItems.value];
newItems[index] = item.copyWith(label: value);
navItems.value = newItems;
onChanged(
SnPublicationSiteConfig(
styleOverride: styleOverrideController.text,
navItems: newItems,
),
);
},
),
TextFormField(
initialValue: item.href,
decoration: InputDecoration(
labelText: 'siteConfigNavItemHref'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onChanged: (value) {
final newItems = [...navItems.value];
newItems[index] = item.copyWith(href: value);
navItems.value = newItems;
onChanged(
SnPublicationSiteConfig(
styleOverride: styleOverrideController.text,
navItems: newItems,
),
);
},
),
TextButton.icon(
onPressed: () {
final newItems = [...navItems.value];
newItems.removeAt(index);
navItems.value = newItems;
onChanged(
SnPublicationSiteConfig(
styleOverride: styleOverrideController.text,
navItems: newItems,
),
);
},
icon: const Icon(Symbols.delete),
label: Text('delete'.tr()),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
).alignment(Alignment.centerRight),
],
).padding(horizontal: 16, vertical: 20),
);
}),
],
).padding(all: 16),
],
),
);
}
}

View File

@@ -0,0 +1,431 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/creators/creators/stickers/stickers.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/stickers/stickers_models/sticker.dart';
import 'package:island/core/network.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/cloud_file_picker.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
part 'pack_detail.g.dart';
part 'pack_detail.freezed.dart';
@riverpod
Future<List<SnSticker>> stickerPackContent(Ref ref, String packId) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/stickers/$packId/content');
return resp.data
.map<SnSticker>((e) => SnSticker.fromJson(e))
.cast<SnSticker>()
.toList();
}
class StickerPackDetailContent extends HookConsumerWidget {
final String id;
final String pubName;
const StickerPackDetailContent({
super.key,
required this.id,
required this.pubName,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pack = ref.watch(stickerPackProvider(id));
final packContent = ref.watch(stickerPackContentProvider(id));
Future<void> deleteSticker(SnSticker sticker) async {
final confirm = await showConfirmAlert(
'deleteStickerHint'.tr(),
'deleteSticker'.tr(),
);
if (!confirm) return;
if (!context.mounted) return;
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
await apiClient.delete('/sphere/stickers/$id/content/${sticker.id}');
ref.invalidate(stickerPackContentProvider(id));
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return pack.when(
data: (pack) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(pack!.description),
Row(
spacing: 4,
children: [
const Icon(Symbols.folder, size: 16),
Text(
'${packContent.value?.length ?? 0}/24',
style: GoogleFonts.robotoMono(),
),
],
).opacity(0.85),
Row(
spacing: 4,
children: [
const Icon(Symbols.sell, size: 16),
Text(pack.prefix, style: GoogleFonts.robotoMono()),
],
).opacity(0.85),
Row(
spacing: 4,
children: [
const Icon(Symbols.tag, size: 16),
Flexible(
child: SelectableText(
pack.id,
maxLines: 1,
style: GoogleFonts.robotoMono(),
),
),
],
).opacity(0.85),
],
).padding(horizontal: 24, vertical: 24),
const Divider(height: 1),
Expanded(
child: packContent.when(
data: (stickers) => RefreshIndicator(
onRefresh: () =>
ref.refresh(stickerPackContentProvider(id).future),
child: GridView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 20,
),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 80,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: stickers.length,
itemBuilder: (context, index) {
final sticker = stickers[index];
return ContextMenuWidget(
menuProvider: (_) {
return Menu(
children: [
MenuAction(
title: 'stickerCopyPlaceholder'.tr(),
image: MenuImage.icon(Symbols.copy_all),
callback: () {
Clipboard.setData(
ClipboardData(
text: ':${pack.prefix}+${sticker.slug}:',
),
);
},
),
MenuSeparator(),
MenuAction(
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
titleText: 'editSticker'.tr(),
child: StickerForm(
packId: id,
id: sticker.id,
),
),
).then((value) {
if (value != null) {
ref.invalidate(
stickerPackContentProvider(id),
);
}
});
},
),
MenuAction(
title: 'delete'.tr(),
image: MenuImage.icon(Symbols.delete),
callback: () {
deleteSticker(sticker);
},
),
],
);
},
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainer,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: CloudImageWidget(
file: sticker.image,
fit: BoxFit.contain,
),
),
),
);
},
),
),
error: (err, _) =>
Text('Error: $err').textAlignment(TextAlign.center).center(),
loading: () => const CircularProgressIndicator().center(),
),
),
],
),
error: (err, _) =>
Text('Error: $err').textAlignment(TextAlign.center).center(),
loading: () => const CircularProgressIndicator().center(),
);
}
}
class StickerPackActionMenu extends HookConsumerWidget {
final String pubName;
final String packId;
final Shadow iconShadow;
const StickerPackActionMenu({
super.key,
required this.pubName,
required this.packId,
required this.iconShadow,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return PopupMenuButton(
icon: Icon(Icons.more_vert, shadows: [iconShadow]),
itemBuilder: (context) => [
PopupMenuItem(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
titleText: 'editStickerPack'.tr(),
child: StickerPackForm(pubName: pubName, packId: packId),
),
).then((value) {
if (value != null) {
ref.invalidate(stickerPackProvider(packId));
}
});
},
child: Row(
children: [
Icon(
Icons.edit,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
const Gap(12),
const Text('editStickerPack').tr(),
],
),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const Gap(12),
const Text(
'deleteStickerPack',
style: TextStyle(color: Colors.red),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'deleteStickerPackHint'.tr(),
'deleteStickerPack'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/sphere/stickers/$packId');
ref.invalidate(stickerPacksProvider);
if (context.mounted) context.pop(true);
}
});
},
),
],
);
}
}
class StickerForm extends HookConsumerWidget {
final String packId;
final String? id;
const StickerForm({super.key, required this.packId, this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sticker = ref.watch(
stickerPackStickerProvider(
id == null ? null : StickerWithPackQuery(packId: packId, id: id!),
),
);
final formKey = useMemoized(() => GlobalKey<FormState>(), []);
final image = useState<String?>(id == null ? '' : sticker.value?.image.id);
final slugController = useTextEditingController(
text: id == null ? '' : sticker.value?.slug,
);
useEffect(() {
if (sticker.value != null) {
image.value = sticker.value!.image.id;
slugController.text = sticker.value!.slug;
}
return null;
}, [sticker]);
final submitting = useState(false);
Future<void> submit() async {
final apiClient = ref.watch(apiClientProvider);
submitting.value = true;
try {
final resp = await apiClient.request(
id == null
? '/sphere/stickers/$packId/content'
: '/sphere/stickers/$packId/content/$id',
data: {'slug': slugController.text, 'image_id': image.value},
options: Options(method: id == null ? 'POST' : 'PATCH'),
);
if (context.mounted) {
Navigator.pop(context, SnSticker.fromJson(resp.data));
}
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(
height: 80,
width: 80,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: (image.value?.isEmpty ?? true)
? const SizedBox.shrink()
: CloudImageWidget(fileId: image.value!),
),
),
),
IconButton.filledTonal(
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) =>
CloudFilePicker(allowedTypes: {UniversalFileType.image}),
).then((value) {
if (value == null) return;
image.value = value[0].id;
});
},
icon: const Icon(Symbols.cloud_upload),
),
],
),
const Gap(16),
Form(
key: formKey,
child: Column(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: slugController,
decoration: InputDecoration(
labelText: 'stickerSlug'.tr(),
helperText: 'stickerSlugHint'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
),
const Gap(12),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : submit,
icon: const Icon(Symbols.save),
label: Text(id == null ? 'create' : 'saveChanges').tr(),
),
),
],
).padding(horizontal: 24, vertical: 16);
}
}
@freezed
sealed class StickerWithPackQuery with _$StickerWithPackQuery {
const factory StickerWithPackQuery({
required String packId,
required String id,
}) = _StickerWithPackQuery;
}
@riverpod
Future<SnSticker?> stickerPackSticker(
Ref ref,
StickerWithPackQuery? query,
) async {
if (query == null) return null;
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get(
'/sphere/stickers/${query.packId}/content/${query.id}',
);
if (resp.data == null) return null;
return SnSticker.fromJson(resp.data);
}

View File

@@ -0,0 +1,268 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'pack_detail.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$StickerWithPackQuery {
String get packId; String get id;
/// Create a copy of StickerWithPackQuery
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$StickerWithPackQueryCopyWith<StickerWithPackQuery> get copyWith => _$StickerWithPackQueryCopyWithImpl<StickerWithPackQuery>(this as StickerWithPackQuery, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is StickerWithPackQuery&&(identical(other.packId, packId) || other.packId == packId)&&(identical(other.id, id) || other.id == id));
}
@override
int get hashCode => Object.hash(runtimeType,packId,id);
@override
String toString() {
return 'StickerWithPackQuery(packId: $packId, id: $id)';
}
}
/// @nodoc
abstract mixin class $StickerWithPackQueryCopyWith<$Res> {
factory $StickerWithPackQueryCopyWith(StickerWithPackQuery value, $Res Function(StickerWithPackQuery) _then) = _$StickerWithPackQueryCopyWithImpl;
@useResult
$Res call({
String packId, String id
});
}
/// @nodoc
class _$StickerWithPackQueryCopyWithImpl<$Res>
implements $StickerWithPackQueryCopyWith<$Res> {
_$StickerWithPackQueryCopyWithImpl(this._self, this._then);
final StickerWithPackQuery _self;
final $Res Function(StickerWithPackQuery) _then;
/// Create a copy of StickerWithPackQuery
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? packId = null,Object? id = null,}) {
return _then(_self.copyWith(
packId: null == packId ? _self.packId : packId // ignore: cast_nullable_to_non_nullable
as String,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [StickerWithPackQuery].
extension StickerWithPackQueryPatterns on StickerWithPackQuery {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _StickerWithPackQuery value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _StickerWithPackQuery() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _StickerWithPackQuery value) $default,){
final _that = this;
switch (_that) {
case _StickerWithPackQuery():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _StickerWithPackQuery value)? $default,){
final _that = this;
switch (_that) {
case _StickerWithPackQuery() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String packId, String id)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _StickerWithPackQuery() when $default != null:
return $default(_that.packId,_that.id);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String packId, String id) $default,) {final _that = this;
switch (_that) {
case _StickerWithPackQuery():
return $default(_that.packId,_that.id);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String packId, String id)? $default,) {final _that = this;
switch (_that) {
case _StickerWithPackQuery() when $default != null:
return $default(_that.packId,_that.id);case _:
return null;
}
}
}
/// @nodoc
class _StickerWithPackQuery implements StickerWithPackQuery {
const _StickerWithPackQuery({required this.packId, required this.id});
@override final String packId;
@override final String id;
/// Create a copy of StickerWithPackQuery
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$StickerWithPackQueryCopyWith<_StickerWithPackQuery> get copyWith => __$StickerWithPackQueryCopyWithImpl<_StickerWithPackQuery>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _StickerWithPackQuery&&(identical(other.packId, packId) || other.packId == packId)&&(identical(other.id, id) || other.id == id));
}
@override
int get hashCode => Object.hash(runtimeType,packId,id);
@override
String toString() {
return 'StickerWithPackQuery(packId: $packId, id: $id)';
}
}
/// @nodoc
abstract mixin class _$StickerWithPackQueryCopyWith<$Res> implements $StickerWithPackQueryCopyWith<$Res> {
factory _$StickerWithPackQueryCopyWith(_StickerWithPackQuery value, $Res Function(_StickerWithPackQuery) _then) = __$StickerWithPackQueryCopyWithImpl;
@override @useResult
$Res call({
String packId, String id
});
}
/// @nodoc
class __$StickerWithPackQueryCopyWithImpl<$Res>
implements _$StickerWithPackQueryCopyWith<$Res> {
__$StickerWithPackQueryCopyWithImpl(this._self, this._then);
final _StickerWithPackQuery _self;
final $Res Function(_StickerWithPackQuery) _then;
/// Create a copy of StickerWithPackQuery
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? packId = null,Object? id = null,}) {
return _then(_StickerWithPackQuery(
packId: null == packId ? _self.packId : packId // ignore: cast_nullable_to_non_nullable
as String,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View File

@@ -0,0 +1,162 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'pack_detail.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(stickerPackContent)
final stickerPackContentProvider = StickerPackContentFamily._();
final class StickerPackContentProvider
extends
$FunctionalProvider<
AsyncValue<List<SnSticker>>,
List<SnSticker>,
FutureOr<List<SnSticker>>
>
with $FutureModifier<List<SnSticker>>, $FutureProvider<List<SnSticker>> {
StickerPackContentProvider._({
required StickerPackContentFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'stickerPackContentProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$stickerPackContentHash();
@override
String toString() {
return r'stickerPackContentProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<SnSticker>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnSticker>> create(Ref ref) {
final argument = this.argument as String;
return stickerPackContent(ref, argument);
}
@override
bool operator ==(Object other) {
return other is StickerPackContentProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$stickerPackContentHash() =>
r'42d74f51022e67e35cb601c2f30f4f02e1f2be9d';
final class StickerPackContentFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<List<SnSticker>>, String> {
StickerPackContentFamily._()
: super(
retry: null,
name: r'stickerPackContentProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
StickerPackContentProvider call(String packId) =>
StickerPackContentProvider._(argument: packId, from: this);
@override
String toString() => r'stickerPackContentProvider';
}
@ProviderFor(stickerPackSticker)
final stickerPackStickerProvider = StickerPackStickerFamily._();
final class StickerPackStickerProvider
extends
$FunctionalProvider<
AsyncValue<SnSticker?>,
SnSticker?,
FutureOr<SnSticker?>
>
with $FutureModifier<SnSticker?>, $FutureProvider<SnSticker?> {
StickerPackStickerProvider._({
required StickerPackStickerFamily super.from,
required StickerWithPackQuery? super.argument,
}) : super(
retry: null,
name: r'stickerPackStickerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$stickerPackStickerHash();
@override
String toString() {
return r'stickerPackStickerProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnSticker?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<SnSticker?> create(Ref ref) {
final argument = this.argument as StickerWithPackQuery?;
return stickerPackSticker(ref, argument);
}
@override
bool operator ==(Object other) {
return other is StickerPackStickerProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$stickerPackStickerHash() =>
r'5c553666b3a63530bdebae4b7cd52f303c5ab3a0';
final class StickerPackStickerFamily extends $Family
with
$FunctionalFamilyOverride<FutureOr<SnSticker?>, StickerWithPackQuery?> {
StickerPackStickerFamily._()
: super(
retry: null,
name: r'stickerPackStickerProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
StickerPackStickerProvider call(StickerWithPackQuery? query) =>
StickerPackStickerProvider._(argument: query, from: this);
@override
String toString() => r'stickerPackStickerProvider';
}

View File

@@ -0,0 +1,347 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/creators/creators/stickers/pack_detail.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/pagination/pagination.dart';
import 'package:island/stickers/stickers_models/sticker.dart';
import 'package:island/core/network.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/core/widgets/content/cloud_file_picker.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/shared/widgets/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'stickers.g.dart';
class StickersScreen extends HookConsumerWidget {
final String pubName;
const StickersScreen({super.key, required this.pubName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final content = SliverStickerPacksList(pubName: pubName);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: const Text('stickers').tr(),
actions: [const Gap(8)],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
titleText: 'createStickerPack'.tr(),
child: StickerPackForm(pubName: pubName),
),
).then((value) {
if (value != null) {
ref.invalidate(stickerPacksProvider(pubName));
}
});
},
child: const Icon(Symbols.add),
),
body: isWideScreen(context)
? Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
),
margin: const EdgeInsets.only(top: 16),
child: content,
),
),
)
: content,
);
}
}
class SliverStickerPacksList extends HookConsumerWidget {
final String pubName;
const SliverStickerPacksList({super.key, required this.pubName});
@override
Widget build(BuildContext context, WidgetRef ref) {
return PaginationList(
padding: EdgeInsets.zero,
provider: stickerPacksProvider(pubName),
notifier: stickerPacksProvider(pubName).notifier,
itemBuilder: (context, index, sticker) {
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
title: Text(sticker.name),
subtitle: Text(sticker.description),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
titleText: sticker.name,
actions: [
IconButton(
icon: const Icon(Symbols.add_circle),
onPressed: () {
final id = sticker.id;
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
titleText: 'createSticker'.tr(),
child: StickerForm(packId: id),
),
).then((value) {
if (value != null) {
ref.invalidate(stickerPackContentProvider(id));
}
});
},
),
StickerPackActionMenu(
pubName: pubName,
packId: sticker.id,
iconShadow: Shadow(),
),
],
child: StickerPackDetailContent(
id: sticker.id,
pubName: pubName,
),
),
);
},
);
},
);
}
}
final stickerPacksProvider = AsyncNotifierProvider.family.autoDispose(
StickerPacksNotifier.new,
);
class StickerPacksNotifier extends AsyncNotifier<PaginationState<SnStickerPack>>
with AsyncPaginationController<SnStickerPack> {
static const int pageSize = 20;
final String arg;
StickerPacksNotifier(this.arg);
@override
Future<List<SnStickerPack>> fetch() async {
final client = ref.read(apiClientProvider);
try {
final response = await client.get(
'/sphere/stickers',
queryParameters: {
'offset': fetchedCount.toString(),
'take': pageSize,
'pub': arg,
},
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final stickers = response.data
.map((e) => SnStickerPack.fromJson(e))
.cast<SnStickerPack>()
.toList();
return stickers;
} catch (err) {
rethrow;
}
}
}
@riverpod
Future<SnStickerPack?> stickerPack(Ref ref, String? packId) async {
if (packId == null) return null;
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/stickers/$packId');
return SnStickerPack.fromJson(resp.data);
}
class StickerPackForm extends HookConsumerWidget {
final String pubName;
final String? packId;
const StickerPackForm({super.key, required this.pubName, this.packId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>(), []);
final initialPack = ref.watch(stickerPackProvider(packId));
final icon = useState<String?>(
packId == null ? '' : initialPack.value?.icon?.id,
);
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final prefixController = useTextEditingController();
useEffect(() {
if (initialPack.value != null) {
nameController.text = initialPack.value!.name;
descriptionController.text = initialPack.value!.description;
prefixController.text = initialPack.value!.prefix;
}
return null;
}, [initialPack]);
final submitting = useState(false);
Future<void> submit() async {
if (!(formKey.currentState?.validate() ?? false)) return;
try {
submitting.value = true;
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.request(
packId == null ? '/sphere/stickers' : '/sphere/stickers/$packId',
data: {
'name': nameController.text,
'description': descriptionController.text,
'prefix': prefixController.text,
'icon_id': icon.value,
},
queryParameters: {'pub': pubName},
options: Options(method: packId == null ? 'POST' : 'PATCH'),
);
if (!context.mounted) return;
Navigator.of(context).pop(SnStickerPack.fromJson(resp.data));
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return Column(
children: [
Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16,
children: [
Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(
height: 80,
width: 80,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: (icon.value?.isEmpty ?? true)
? const SizedBox.shrink()
: CloudImageWidget(fileId: icon.value!),
),
),
),
IconButton.filledTonal(
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => CloudFilePicker(
allowedTypes: {UniversalFileType.image},
),
).then((value) {
if (value == null) return;
icon.value = value[0].id;
});
},
icon: const Icon(Symbols.cloud_upload),
),
],
),
TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: 'name'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(
labelText: 'description'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
alignLabelWithHint: true,
),
minLines: 3,
maxLines: null,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
TextFormField(
controller: prefixController,
decoration: InputDecoration(
labelText: 'stickerPackPrefix'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
helperText: 'stickerPackPrefixHint'.tr(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
),
const Gap(12),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : submit,
icon: const Icon(Symbols.save),
label: Text(packId == null ? 'create'.tr() : 'saveChanges'.tr()),
),
),
],
).padding(horizontal: 24, vertical: 16);
}
}

View File

@@ -0,0 +1,85 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'stickers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(stickerPack)
final stickerPackProvider = StickerPackFamily._();
final class StickerPackProvider
extends
$FunctionalProvider<
AsyncValue<SnStickerPack?>,
SnStickerPack?,
FutureOr<SnStickerPack?>
>
with $FutureModifier<SnStickerPack?>, $FutureProvider<SnStickerPack?> {
StickerPackProvider._({
required StickerPackFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'stickerPackProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$stickerPackHash();
@override
String toString() {
return r'stickerPackProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnStickerPack?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnStickerPack?> create(Ref ref) {
final argument = this.argument as String?;
return stickerPack(ref, argument);
}
@override
bool operator ==(Object other) {
return other is StickerPackProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$stickerPackHash() => r'71ef84471237c8191918095094bdfc87d3920e77';
final class StickerPackFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnStickerPack?>, String?> {
StickerPackFamily._()
: super(
retry: null,
name: r'stickerPackProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
StickerPackProvider call(String? packId) =>
StickerPackProvider._(argument: packId, from: this);
@override
String toString() => r'stickerPackProvider';
}

View File

@@ -0,0 +1,266 @@
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/discovery/discovery_models/webfeed.dart';
import 'package:island/discovery/webfeed.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class WebfeedForm extends HookConsumerWidget {
final String pubName;
final String? feedId;
const WebfeedForm({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 = SnWebFeed(
id: feedId ?? '',
title: titleController.text,
url: urlController.text,
description: descriptionController.text,
config: SnWebFeedConfig(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',
isDanger: true,
);
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 Center(child: CircularProgressIndicator()),
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry: () => ref.invalidate(
webFeedNotifierProvider((pubName: pubName, feedId: feedId)),
),
),
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;
}
final scrapNow = useCallback(() async {
isLoading.value = true;
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) isLoading.value = false;
}
}, [pubName, feedId, ref, context, isLoading]);
final formFields = Column(
children: [
TextFormField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
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',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
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,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
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.value,
onChanged: (value) => isScrapEnabled.value = value,
),
],
),
),
const SizedBox(height: 20),
if (feedId != null) ...[
TextButton.icon(
onPressed: isLoading.value ? null : scrapNow,
icon: const Icon(Symbols.refresh),
label: const Text('Scrape Now'),
).alignment(Alignment.centerRight),
const SizedBox(height: 16),
],
],
).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),
label: Text('saveChanges').tr(),
),
],
).padding(horizontal: 20, vertical: 12);
return Column(
children: [
Expanded(child: formWidget),
buttonsRow,
],
);
},
);
}
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/creators/creators/webfeed/webfeed_edit.dart';
import 'package:island/discovery/webfeed.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/shared/widgets/empty_state.dart';
import 'package:island/shared/widgets/extended_refresh_indicator.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.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(
isNoBackground: false,
appBar: AppBar(title: const Text('Web Feeds')),
floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.add),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
titleText: 'New Web Feed',
child: WebfeedForm(pubName: pubName, feedId: null),
),
);
},
),
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 ExtendedRefreshIndicator(
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 ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: 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: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
titleText: 'Edit Web Feed',
child: WebfeedForm(
pubName: pubName,
feedId: feed.id,
),
),
);
},
),
),
).center();
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
),
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'publication_site.freezed.dart';
part 'publication_site.g.dart';
@freezed
sealed class SnPublicationSiteNavItems with _$SnPublicationSiteNavItems {
const factory SnPublicationSiteNavItems({
required String label,
required String href,
}) = _SnPublicationSiteNavItems;
factory SnPublicationSiteNavItems.fromJson(Map<String, dynamic> json) =>
_$SnPublicationSiteNavItemsFromJson(json);
}
@freezed
sealed class SnPublicationSiteConfig with _$SnPublicationSiteConfig {
const factory SnPublicationSiteConfig({
String? styleOverride,
List<SnPublicationSiteNavItems>? navItems,
}) = _SnPublicationSiteConfig;
factory SnPublicationSiteConfig.fromJson(Map<String, dynamic> json) =>
_$SnPublicationSiteConfigFromJson(json);
}
@freezed
sealed class SnPublicationSite with _$SnPublicationSite {
const factory SnPublicationSite({
required String id,
required String slug,
required String name,
String? description,
int? mode,
required String publisherId,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required List<SnPublicationPage> pages,
required SnPublicationSiteConfig config,
}) = _SnPublicationSite;
factory SnPublicationSite.fromJson(Map<String, dynamic> json) =>
_$SnPublicationSiteFromJson(json);
}
@freezed
sealed class SnPublicationPage with _$SnPublicationPage {
const factory SnPublicationPage({
required String id,
String? preset,
String? path,
Map<String, dynamic>? config,
required String siteId,
required DateTime createdAt,
required DateTime updatedAt,
}) = _SnPublicationPage;
factory SnPublicationPage.fromJson(Map<String, dynamic> json) =>
_$SnPublicationPageFromJson(json);
}
enum PublicationPagePreset { landing, profile, posts, custom }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'publication_site.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnPublicationSiteNavItems _$SnPublicationSiteNavItemsFromJson(
Map<String, dynamic> json,
) => _SnPublicationSiteNavItems(
label: json['label'] as String,
href: json['href'] as String,
);
Map<String, dynamic> _$SnPublicationSiteNavItemsToJson(
_SnPublicationSiteNavItems instance,
) => <String, dynamic>{'label': instance.label, 'href': instance.href};
_SnPublicationSiteConfig _$SnPublicationSiteConfigFromJson(
Map<String, dynamic> json,
) => _SnPublicationSiteConfig(
styleOverride: json['style_override'] as String?,
navItems: (json['nav_items'] as List<dynamic>?)
?.map(
(e) => SnPublicationSiteNavItems.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
Map<String, dynamic> _$SnPublicationSiteConfigToJson(
_SnPublicationSiteConfig instance,
) => <String, dynamic>{
'style_override': instance.styleOverride,
'nav_items': instance.navItems?.map((e) => e.toJson()).toList(),
};
_SnPublicationSite _$SnPublicationSiteFromJson(Map<String, dynamic> json) =>
_SnPublicationSite(
id: json['id'] as String,
slug: json['slug'] as String,
name: json['name'] as String,
description: json['description'] as String?,
mode: (json['mode'] as num?)?.toInt(),
publisherId: json['publisher_id'] as String,
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
pages: (json['pages'] as List<dynamic>)
.map((e) => SnPublicationPage.fromJson(e as Map<String, dynamic>))
.toList(),
config: SnPublicationSiteConfig.fromJson(
json['config'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$SnPublicationSiteToJson(_SnPublicationSite instance) =>
<String, dynamic>{
'id': instance.id,
'slug': instance.slug,
'name': instance.name,
'description': instance.description,
'mode': instance.mode,
'publisher_id': instance.publisherId,
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'pages': instance.pages.map((e) => e.toJson()).toList(),
'config': instance.config.toJson(),
};
_SnPublicationPage _$SnPublicationPageFromJson(Map<String, dynamic> json) =>
_SnPublicationPage(
id: json['id'] as String,
preset: json['preset'] as String?,
path: json['path'] as String?,
config: json['config'] as Map<String, dynamic>?,
siteId: json['site_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
Map<String, dynamic> _$SnPublicationPageToJson(_SnPublicationPage instance) =>
<String, dynamic>{
'id': instance.id,
'preset': instance.preset,
'path': instance.path,
'config': instance.config,
'site_id': instance.siteId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
};