♻️ Refactored cloud file picker, stickers

This commit is contained in:
2025-10-12 02:21:39 +08:00
parent c660a419e2
commit bbb07d574a
5 changed files with 485 additions and 482 deletions

View File

@@ -43,7 +43,6 @@ import 'package:island/screens/chat/search_messages.dart';
import 'package:island/screens/creators/hub.dart';
import 'package:island/screens/creators/posts/post_manage_list.dart';
import 'package:island/screens/creators/stickers/stickers.dart';
import 'package:island/screens/creators/stickers/pack_detail.dart';
import 'package:island/screens/stickers/sticker_marketplace.dart';
import 'package:island/screens/stickers/pack_detail.dart';
import 'package:island/screens/discovery/feeds/feed_marketplace.dart';
@@ -232,49 +231,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return StickersScreen(pubName: name);
},
),
GoRoute(
name: 'creatorStickerPackNew',
path: ':name/stickers/new',
builder: (context, state) {
final name = state.pathParameters['name']!;
return NewStickerPacksScreen(pubName: name);
},
),
GoRoute(
name: 'creatorStickerPackEdit',
path: ':name/stickers/:packId/edit',
builder: (context, state) {
final name = state.pathParameters['name']!;
final packId = state.pathParameters['packId']!;
return EditStickerPacksScreen(pubName: name, packId: packId);
},
),
GoRoute(
name: 'creatorStickerPackDetail',
path: ':name/stickers/:packId',
builder: (context, state) {
final name = state.pathParameters['name']!;
final packId = state.pathParameters['packId']!;
return StickerPackDetailScreen(pubName: name, id: packId);
},
),
GoRoute(
name: 'creatorStickerNew',
path: ':name/stickers/:packId/new',
builder: (context, state) {
final packId = state.pathParameters['packId']!;
return NewStickersScreen(packId: packId);
},
),
GoRoute(
name: 'creatorStickerEdit',
path: ':name/stickers/:packId/:id/edit',
builder: (context, state) {
final packId = state.pathParameters['packId']!;
final id = state.pathParameters['id']!;
return EditStickersScreen(id: id, packId: packId);
},
),
GoRoute(
name: 'creatorNew',
path: 'new',

View File

@@ -8,13 +8,14 @@ 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/models/file.dart';
import 'package:island/models/sticker.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/creators/stickers/stickers.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_file_picker.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/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';
@@ -33,13 +34,13 @@ Future<List<SnSticker>> stickerPackContent(Ref ref, String packId) async {
.toList();
}
class StickerPackDetailScreen extends HookConsumerWidget {
class StickerPackDetailContent extends HookConsumerWidget {
final String id;
final String pubName;
const StickerPackDetailScreen({
const StickerPackDetailContent({
super.key,
required this.pubName,
required this.id,
required this.pubName,
});
@override
@@ -67,35 +68,7 @@ class StickerPackDetailScreen extends HookConsumerWidget {
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(pack.value?.name ?? 'loading'.tr()),
actions: [
IconButton(
icon: const Icon(Symbols.add_circle),
onPressed: () {
context
.pushNamed(
'creatorStickerNew',
pathParameters: {'name': pubName, 'packId': id},
)
.then((value) {
if (value != null) {
ref.invalidate(stickerPackContentProvider(id));
}
});
},
),
_StickerPackActionMenu(
pubName: pubName,
packId: id,
iconShadow: Shadow(),
),
const Gap(8),
],
),
body: pack.when(
return pack.when(
data:
(pack) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -125,10 +98,13 @@ class StickerPackDetailScreen extends HookConsumerWidget {
spacing: 4,
children: [
const Icon(Symbols.tag, size: 16),
SelectableText(
Flexible(
child: SelectableText(
pack.id,
maxLines: 1,
style: GoogleFonts.robotoMono(),
),
),
],
).opacity(0.85),
],
@@ -149,7 +125,7 @@ class StickerPackDetailScreen extends HookConsumerWidget {
),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 48,
maxCrossAxisExtent: 80,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
@@ -167,7 +143,7 @@ class StickerPackDetailScreen extends HookConsumerWidget {
Clipboard.setData(
ClipboardData(
text:
':${pack.prefix}${sticker.slug}:',
':${pack.prefix}+${sticker.slug}:',
),
);
},
@@ -177,21 +153,21 @@ class StickerPackDetailScreen extends HookConsumerWidget {
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () {
context
.pushNamed(
'creatorStickerEdit',
pathParameters: {
'name': pubName,
'packId': id,
'id': sticker.id,
},
)
.then((value) {
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,
),
stickerPackContentProvider(id),
);
}
});
@@ -223,6 +199,7 @@ class StickerPackDetailScreen extends HookConsumerWidget {
),
child: CloudImageWidget(
fileId: sticker.imageId,
fit: BoxFit.contain,
),
),
),
@@ -244,17 +221,17 @@ class StickerPackDetailScreen extends HookConsumerWidget {
(err, _) =>
Text('Error: $err').textAlignment(TextAlign.center).center(),
loading: () => const CircularProgressIndicator().center(),
),
);
}
}
class _StickerPackActionMenu extends HookConsumerWidget {
class StickerPackActionMenu extends HookConsumerWidget {
final String pubName;
final String packId;
final Shadow iconShadow;
const _StickerPackActionMenu({
const StickerPackActionMenu({
super.key,
required this.pubName,
required this.packId,
required this.iconShadow,
@@ -268,7 +245,22 @@ class _StickerPackActionMenu extends HookConsumerWidget {
(context) => [
PopupMenuItem(
onTap: () {
context.push('/creators/$pubName/stickers/$packId/edit');
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: [
@@ -311,42 +303,10 @@ class _StickerPackActionMenu extends HookConsumerWidget {
}
}
@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);
}
class NewStickersScreen extends StatelessWidget {
final String packId;
const NewStickersScreen({super.key, required this.packId});
@override
Widget build(BuildContext context) {
return EditStickersScreen(packId: packId, id: null);
}
}
class EditStickersScreen extends HookConsumerWidget {
class StickerForm extends HookConsumerWidget {
final String packId;
final String? id;
const EditStickersScreen({super.key, required this.packId, required this.id});
const StickerForm({super.key, required this.packId, this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -396,17 +356,16 @@ class EditStickersScreen extends HookConsumerWidget {
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(id == null ? 'createSticker' : 'editSticker').tr(),
),
body: Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(
height: 96,
width: 96,
height: 80,
width: 80,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
@@ -421,45 +380,39 @@ class EditStickersScreen extends HookConsumerWidget {
),
),
),
const Gap(16),
Form(
key: formKey,
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: imageController,
decoration: InputDecoration(
labelText: 'stickerImage'.tr(),
border: const UnderlineInputBorder(),
suffix: InkWell(
onTap: () {
IconButton.filledTonal(
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => CloudFilePicker(),
builder:
(context) => CloudFilePicker(
allowedTypes: {UniversalFileType.image},
),
).then((value) {
if (value == null) return;
image.value = value[0].id;
imageController.text = image.value!;
});
},
borderRadius: BorderRadius.all(Radius.circular(8)),
child: const Icon(
Symbols.cloud_upload,
).padding(horizontal: 4),
icon: const Icon(Symbols.cloud_upload),
),
],
),
readOnly: true,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
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 UnderlineInputBorder(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
@@ -477,7 +430,28 @@ class EditStickersScreen extends HookConsumerWidget {
),
),
],
).padding(horizontal: 24, vertical: 24),
);
).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

@@ -1,14 +1,16 @@
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_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/sticker.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/screens/creators/stickers/pack_detail.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -22,30 +24,50 @@ class StickersScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final content = SliverStickerPacksList(pubName: pubName);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: const Text('stickers').tr(),
actions: [
IconButton(
actions: [const Gap(8)],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context
.pushNamed(
'creatorStickerPackNew',
pathParameters: {'name': pubName},
)
.then((value) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'createStickerPack'.tr(),
child: StickerPackForm(pubName: pubName),
),
).then((value) {
if (value != null) {
ref.invalidate(stickerPacksNotifierProvider(pubName));
}
});
},
icon: const Icon(Symbols.add_circle),
child: const Icon(Symbols.add),
),
const Gap(8),
],
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),
),
body: SliverStickerPacksList(pubName: pubName),
),
margin: const EdgeInsets.only(top: 16),
child: content,
),
),
)
: content,
);
}
}
@@ -71,13 +93,52 @@ class SliverStickerPacksList extends HookConsumerWidget {
final sticker = data.items[index];
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: () {
context.pushNamed(
'creatorStickerPackDetail',
pathParameters: {'name': pubName, 'packId': sticker.id},
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,
),
),
);
},
);
@@ -136,20 +197,10 @@ Future<SnStickerPack?> stickerPack(Ref ref, String? packId) async {
return SnStickerPack.fromJson(resp.data);
}
class NewStickerPacksScreen extends HookConsumerWidget {
final String pubName;
const NewStickerPacksScreen({super.key, required this.pubName});
@override
Widget build(BuildContext context, WidgetRef ref) {
return EditStickerPacksScreen(pubName: pubName);
}
}
class EditStickerPacksScreen extends HookConsumerWidget {
class StickerPackForm extends HookConsumerWidget {
final String pubName;
final String? packId;
const EditStickerPacksScreen({super.key, required this.pubName, this.packId});
const StickerPackForm({super.key, required this.pubName, this.packId});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -188,7 +239,7 @@ class EditStickerPacksScreen extends HookConsumerWidget {
options: Options(method: packId == null ? 'POST' : 'PATCH'),
);
if (!context.mounted) return;
context.pop(SnStickerPack.fromJson(resp.data));
Navigator.of(context).pop(SnStickerPack.fromJson(resp.data));
} catch (err) {
showErrorAlert(err);
} finally {
@@ -196,25 +247,21 @@ class EditStickerPacksScreen extends HookConsumerWidget {
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title:
Text(packId == null ? 'createStickerPack' : 'editStickerPack').tr(),
),
body: Column(
return Column(
children: [
Form(
key: formKey,
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16,
children: [
TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: 'name'.tr(),
border: const UnderlineInputBorder(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
@@ -229,7 +276,9 @@ class EditStickerPacksScreen extends HookConsumerWidget {
controller: descriptionController,
decoration: InputDecoration(
labelText: 'description'.tr(),
border: const UnderlineInputBorder(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
alignLabelWithHint: true,
),
minLines: 3,
@@ -241,8 +290,10 @@ class EditStickerPacksScreen extends HookConsumerWidget {
controller: prefixController,
decoration: InputDecoration(
labelText: 'stickerPackPrefix'.tr(),
border: const UnderlineInputBorder(),
helperText: 'deleteStickerHint'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
helperText: 'stickerPackPrefixHint'.tr(),
),
validator: (value) {
if (value == null || value.isEmpty) {
@@ -266,7 +317,6 @@ class EditStickerPacksScreen extends HookConsumerWidget {
),
),
],
).padding(horizontal: 24, vertical: 16),
);
).padding(horizontal: 24, vertical: 16);
}
}

View File

@@ -15,7 +15,16 @@ import 'package:styled_widget/styled_widget.dart';
class CloudFilePicker extends HookConsumerWidget {
final bool allowMultiple;
const CloudFilePicker({super.key, this.allowMultiple = false});
final Set<UniversalFileType> allowedTypes;
const CloudFilePicker({
super.key,
this.allowMultiple = false,
this.allowedTypes = const {
UniversalFileType.image,
UniversalFileType.video,
UniversalFileType.file,
},
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -71,7 +80,7 @@ class CloudFilePicker extends HookConsumerWidget {
void pickFile() async {
showLoadingModal(context);
final result = await FilePickerIO().pickFiles(
final result = await FilePicker.platform.pickFiles(
allowMultiple: allowMultiple,
);
if (result == null) {
@@ -80,9 +89,13 @@ class CloudFilePicker extends HookConsumerWidget {
}
final newFiles =
result.files
.map((e) => UniversalFile(data: e, type: UniversalFileType.file))
.toList();
result.files.map((e) {
final xfile =
e.bytes != null
? XFile.fromData(e.bytes!, name: e.name)
: XFile(e.path!);
return UniversalFile(data: xfile, type: UniversalFileType.file);
}).toList();
if (!allowMultiple) {
files.value = newFiles;
@@ -99,23 +112,23 @@ class CloudFilePicker extends HookConsumerWidget {
void pickImage() async {
showLoadingModal(context);
final result =
allowMultiple
? await ref.read(imagePickerProvider).pickMultiImage()
: [
await ref
.read(imagePickerProvider)
.pickImage(source: ImageSource.gallery),
];
if (result.isEmpty) {
final result = await FilePicker.platform.pickFiles(
allowMultiple: allowMultiple,
type: FileType.image,
);
if (result == null || result.files.isEmpty) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFiles =
result
.map((e) => UniversalFile(data: e, type: UniversalFileType.image))
.toList();
result.files.map((e) {
final xfile =
e.bytes != null
? XFile.fromData(e.bytes!, name: e.name)
: XFile(e.path!);
return UniversalFile(data: xfile, type: UniversalFileType.image);
}).toList();
if (!allowMultiple) {
files.value = newFiles;
@@ -132,21 +145,26 @@ class CloudFilePicker extends HookConsumerWidget {
void pickVideo() async {
showLoadingModal(context);
final result = await ref
.read(imagePickerProvider)
.pickVideo(source: ImageSource.gallery);
if (result == null) {
final result = await FilePicker.platform.pickFiles(
allowMultiple: allowMultiple,
type: FileType.video,
);
if (result == null || result.files.isEmpty) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFile = UniversalFile(
data: result,
type: UniversalFileType.video,
);
final newFiles =
result.files.map((e) {
final xfile =
e.bytes != null
? XFile.fromData(e.bytes!, name: e.name)
: XFile(e.path!);
return UniversalFile(data: xfile, type: UniversalFileType.video);
}).toList();
if (!allowMultiple) {
files.value = [newFile];
files.value = newFiles;
if (context.mounted) {
hideLoadingModal(context);
startUpload();
@@ -154,7 +172,7 @@ class CloudFilePicker extends HookConsumerWidget {
return;
}
files.value = [...files.value, newFile];
files.value = [...files.value, ...newFiles];
if (context.mounted) hideLoadingModal(context);
}
@@ -252,6 +270,7 @@ class CloudFilePicker extends HookConsumerWidget {
margin: EdgeInsets.zero,
child: Column(
children: [
if (allowedTypes.contains(UniversalFileType.image))
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
@@ -260,6 +279,7 @@ class CloudFilePicker extends HookConsumerWidget {
title: Text('addPhoto'.tr()),
onTap: () => pickImage(),
),
if (allowedTypes.contains(UniversalFileType.video))
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
@@ -268,6 +288,7 @@ class CloudFilePicker extends HookConsumerWidget {
title: Text('addVideo'.tr()),
onTap: () => pickVideo(),
),
if (allowedTypes.contains(UniversalFileType.file))
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),

View File

@@ -30,6 +30,8 @@ class SheetScaffold extends StatelessWidget {
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
return Container(
@@ -43,7 +45,7 @@ class SheetScaffold extends StatelessWidget {
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
titleWidget,
Expanded(child: titleWidget),
const Spacer(),
...actions,
IconButton(