Allow profile picture (avatar & banner) upload gif

This commit is contained in:
LittleSheep 2025-03-03 20:53:42 +08:00
parent 73777fe74e
commit 2b61c372f5
3 changed files with 143 additions and 198 deletions

View File

@ -68,38 +68,35 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
_banner = prof.banner; _banner = prof.banner;
_links = prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList(); _links = prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
_birthday = prof.profile!.birthday?.toLocal(); _birthday = prof.profile!.birthday?.toLocal();
if(_birthday != null) { if (_birthday != null) {
_birthdayController.text = DateFormat(_kDateFormat).format( _birthdayController.text = DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
prof.profile!.birthday!.toLocal(),
);
} }
} }
void _selectBirthday() async { void _selectBirthday() async {
await showCupertinoModalPopup<DateTime?>( await showCupertinoModalPopup<DateTime?>(
context: context, context: context,
builder: (BuildContext context) => Container( builder:
height: 216, (BuildContext context) => Container(
padding: const EdgeInsets.only(top: 6.0), height: 216,
margin: EdgeInsets.only( padding: const EdgeInsets.only(top: 6.0),
bottom: MediaQuery.of(context).viewInsets.bottom, margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
), color: Theme.of(context).colorScheme.surface,
color: Theme.of(context).colorScheme.surface, child: SafeArea(
child: SafeArea( top: false,
top: false, child: CupertinoDatePicker(
child: CupertinoDatePicker( initialDateTime: _birthday?.toLocal(),
initialDateTime: _birthday?.toLocal(), mode: CupertinoDatePickerMode.date,
mode: CupertinoDatePickerMode.date, use24hFormat: true,
use24hFormat: true, onDateTimeChanged: (DateTime newDate) {
onDateTimeChanged: (DateTime newDate) { setState(() {
setState(() { _birthday = newDate;
_birthday = newDate; _birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
_birthdayController.text = DateFormat(_kDateFormat).format(_birthday!); });
}); },
}, ),
),
), ),
),
),
); );
} }
@ -108,32 +105,41 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
if (image == null) return; if (image == null) return;
if (!mounted) return; if (!mounted) return;
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); final skipCrop = image.path.endsWith('.gif');
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return; Uint8List? rawBytes;
if (!skipCrop) {
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result =
(!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return;
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
} else {
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = await image.readAsBytes();
}
if (!mounted) return;
final attach = context.read<SnAttachmentProvider>(); final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
try { try {
final attachment = await attach.directUploadOne( final attachment = await attach.directUploadOne(
rawBytes, rawBytes,
@ -145,10 +151,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
if (!mounted) return; if (!mounted) return;
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.put( await sn.client.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
'/cgi/id/users/me/$place',
data: {'attachment': attachment.rid},
);
if (!mounted) return; if (!mounted) return;
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
@ -184,7 +187,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
'location': _locationController.value.text, 'location': _locationController.value.text,
'birthday': _birthday?.toUtc().toIso8601String(), 'birthday': _birthday?.toUtc().toIso8601String(),
'links': { 'links': {
for (final link in _links!.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty)) link.$1: link.$2 for (final link in _links!.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty)) link.$1: link.$2,
}, },
}, },
); );
@ -231,10 +234,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(leading: const PageBackButton(), title: Text('screenAccountProfileEdit').tr()),
leading: const PageBackButton(),
title: Text('screenAccountProfileEdit').tr(),
),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -253,12 +253,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: Container( child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null child:
? AutoResizeUniversalImage( _banner != null
sn.getAttachmentUrl(_banner!), ? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
fit: BoxFit.cover, : const SizedBox.shrink(),
)
: const SizedBox.shrink(),
), ),
), ),
), ),
@ -299,10 +297,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
), ),
TextField( TextField(
controller: _nicknameController, controller: _nicknameController,
decoration: InputDecoration( decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldNickname'.tr()),
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
Row( Row(
@ -364,10 +359,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
maxLines: null, maxLines: null,
minLines: 3, minLines: 3,
decoration: InputDecoration( decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldDescription'.tr()),
border: const UnderlineInputBorder(),
labelText: 'fieldDescription'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
Row( Row(
@ -384,42 +376,40 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
), ),
), ),
const Gap(4), const Gap(4),
StyledWidget(IconButton( StyledWidget(
icon: const Icon(Symbols.calendar_month), IconButton(
visualDensity: VisualDensity(horizontal: -4, vertical: -4), icon: const Icon(Symbols.calendar_month),
padding: EdgeInsets.zero, visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(), padding: EdgeInsets.zero,
onPressed: () async { constraints: const BoxConstraints(),
_timezoneController.text = await FlutterTimezone.getLocalTimezone(); onPressed: () async {
}, _timezoneController.text = await FlutterTimezone.getLocalTimezone();
)).padding(top: 6), },
),
).padding(top: 6),
const Gap(4), const Gap(4),
StyledWidget(IconButton( StyledWidget(
icon: const Icon(Symbols.clear), IconButton(
visualDensity: VisualDensity(horizontal: -4, vertical: -4), icon: const Icon(Symbols.clear),
padding: EdgeInsets.zero, visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(), padding: EdgeInsets.zero,
onPressed: () { constraints: const BoxConstraints(),
_timezoneController.clear(); onPressed: () {
}, _timezoneController.clear();
)).padding(top: 6), },
),
).padding(top: 6),
], ],
), ),
TextField( TextField(
controller: _locationController, controller: _locationController,
decoration: InputDecoration( decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldLocation'.tr()),
border: const UnderlineInputBorder(),
labelText: 'fieldLocation'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
TextField( TextField(
controller: _birthdayController, controller: _birthdayController,
readOnly: true, readOnly: true,
decoration: InputDecoration( decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldBirthday'.tr()),
border: const UnderlineInputBorder(),
labelText: 'fieldBirthday'.tr(),
),
onTap: () => _selectBirthday(), onTap: () => _selectBirthday(),
), ),
if (_links != null) if (_links != null)

View File

@ -108,32 +108,41 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
if (image == null) return; if (image == null) return;
if (!mounted) return; if (!mounted) return;
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); final skipCrop = image.path.endsWith('.gif');
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return; Uint8List? rawBytes;
if (!skipCrop) {
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result =
(!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return;
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
} else {
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = await image.readAsBytes();
}
if (!mounted) return;
final attach = context.read<SnAttachmentProvider>(); final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
try { try {
final attachment = await attach.directUploadOne( final attachment = await attach.directUploadOne(
rawBytes, rawBytes,

View File

@ -58,18 +58,12 @@ class _NotificationScreenState extends State<NotificationScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final nty = context.read<NotificationProvider>(); final nty = context.read<NotificationProvider>();
final resp = final resp = await sn.client.get(
await sn.client.get('/cgi/id/notifications', queryParameters: { '/cgi/id/notifications',
'take': 10, queryParameters: {'take': 10, 'offset': _notifications.length},
'offset': _notifications.length,
});
_totalCount = resp.data['count'];
_notifications.addAll(
resp.data['data']
?.map((e) => SnNotification.fromJson(e))
.cast<SnNotification>() ??
[],
); );
_totalCount = resp.data['count'];
_notifications.addAll(resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? []);
nty.updateTray(); nty.updateTray();
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
@ -104,9 +98,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
nty.clear(); nty.clear();
if (!mounted) return; if (!mounted) return;
context.showSnackbar( context.showSnackbar('notificationMarkAllReadPrompt'.plural(resp.data['count']));
'notificationMarkAllReadPrompt'.plural(resp.data['count']),
);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -130,9 +122,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
_fetchNotifications(); _fetchNotifications();
if (!mounted) return; if (!mounted) return;
context.showSnackbar( context.showSnackbar('notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -153,13 +143,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenNotification').tr()),
leading: AutoAppBarLeading(), body: Center(child: UnauthorizedHint()),
title: Text('screenNotification').tr(),
),
body: Center(
child: UnauthorizedHint(),
),
); );
} }
@ -168,10 +153,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenNotification').tr(), title: Text('screenNotification').tr(),
actions: [ actions: [
IconButton( IconButton(icon: const Icon(Symbols.checklist), onPressed: _isSubmitting ? null : _markAllAsRead),
icon: const Icon(Symbols.checklist),
onPressed: _isSubmitting ? null : _markAllAsRead,
),
const Gap(8), const Gap(8),
], ],
), ),
@ -185,17 +167,13 @@ class _NotificationScreenState extends State<NotificationScreen> {
return _fetchNotifications(); return _fetchNotifications();
}, },
child: InfiniteList( child: InfiniteList(
padding: EdgeInsets.only( padding: EdgeInsets.only(top: 16, bottom: math.max(MediaQuery.of(context).padding.bottom, 16)),
top: 16,
bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
),
itemCount: _notifications.length, itemCount: _notifications.length,
onFetchData: () { onFetchData: () {
_fetchNotifications(); _fetchNotifications();
}, },
isLoading: _isBusy, isLoading: _isBusy,
hasReachedMax: _totalCount != null && hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!,
_notifications.length >= _totalCount!,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
final nty = _notifications[idx]; final nty = _notifications[idx];
return Row( return Row(
@ -208,45 +186,26 @@ class _NotificationScreenState extends State<NotificationScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (nty.readAt == null) if (nty.readAt == null)
StyledWidget(Badge( StyledWidget(Badge(label: Text('notificationUnread').tr())).padding(bottom: 4),
label: Text('notificationUnread').tr(), Text(nty.title, style: Theme.of(context).textTheme.titleMedium),
)).padding(bottom: 4),
Text(
nty.title,
style: Theme.of(context).textTheme.titleMedium,
),
if (nty.subtitle != null) if (nty.subtitle != null)
Text( Text(nty.subtitle!, style: Theme.of(context).textTheme.titleSmall),
nty.subtitle!,
style: Theme.of(context).textTheme.titleSmall,
),
if (nty.subtitle != null) const Gap(4), if (nty.subtitle != null) const Gap(4),
SelectionArea( SelectionArea(child: MarkdownTextContent(content: nty.body, isAutoWarp: true)),
child: MarkdownTextContent(
content: nty.body,
isAutoWarp: true,
),
),
if ([ if ([
'interactive.reply', 'interactive.reply',
'interactive.feedback', 'interactive.feedback',
'interactive.subscription' 'interactive.subscription',
].contains(nty.topic) && ].contains(nty.topic) &&
nty.metadata['related_post'] != null) nty.metadata['related_post'] != null)
GestureDetector( GestureDetector(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all( borderRadius: const BorderRadius.all(Radius.circular(8)),
Radius.circular(8)), border: Border.all(color: Theme.of(context).dividerColor, width: 1),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
), ),
child: PostItem( child: PostItem(
data: SnPost.fromJson( data: SnPost.fromJson(nty.metadata['related_post']!),
nty.metadata['related_post']!,
),
showComments: false, showComments: false,
showReactions: false, showReactions: false,
showMenu: false, showMenu: false,
@ -255,29 +214,18 @@ class _NotificationScreenState extends State<NotificationScreen> {
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postDetail', 'postDetail',
pathParameters: { pathParameters: {'slug': nty.metadata['related_post']!['id'].toString()},
'slug': nty
.metadata['related_post']!['id']
.toString(),
},
); );
}, },
).padding(top: 8), ).padding(top: 8),
const Gap(8), const Gap(8),
Row( Row(
children: [ children: [
Text( Text(DateFormat('yy/MM/dd').format(nty.createdAt)).fontSize(12),
DateFormat('yy/MM/dd').format(nty.createdAt),
).fontSize(12),
const Gap(4), const Gap(4),
Text( Text('·', style: TextStyle(fontSize: 12)),
'·',
style: TextStyle(fontSize: 12),
),
const Gap(4), const Gap(4),
Text( Text(RelativeTime(context).format(nty.createdAt)).fontSize(12),
RelativeTime(context).format(nty.createdAt),
).fontSize(12),
], ],
).opacity(0.75), ).opacity(0.75),
], ],
@ -287,10 +235,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
IconButton( IconButton(
icon: const Icon(Symbols.check), icon: const Icon(Symbols.check),
padding: EdgeInsets.all(0), padding: EdgeInsets.all(0),
visualDensity: visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
const VisualDensity(horizontal: -4, vertical: -4), onPressed: _isSubmitting ? null : () => _markOneAsRead(nty),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
), ),
], ],
).padding(horizontal: 16); ).padding(horizontal: 16);