Edit profile

This commit is contained in:
2024-11-10 01:04:39 +08:00
parent ed2e44cc54
commit abc1d5a9d7
20 changed files with 928 additions and 60 deletions

View File

@ -1,3 +1,4 @@
import 'package:croppy/croppy.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:easy_localization_loader/easy_localization_loader.dart';
import 'package:flutter/material.dart';
@ -48,6 +49,7 @@ class SolianApp extends StatelessWidget {
locale: context.locale,
supportedLocales: context.supportedLocales,
localizationsDelegates: [
CroppyLocalizations.delegate,
RelativeTimeLocalizations.delegate,
...context.localizationDelegates,
],

View File

@ -1,8 +1,15 @@
import 'dart:collection';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/widgets.dart';
import 'package:cross_file/cross_file.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart';
const kConcurrentUploadChunks = 5;
class SnAttachmentProvider {
late final SnNetworkProvider _sn;
final Map<String, SnAttachment> _cache = {};
@ -43,4 +50,157 @@ class SnAttachmentProvider {
}
return rids.map((rid) => _cache[rid]!).toList();
}
static Map<String, String> mimetypeOverrides = {
'mov': 'video/quicktime',
'mp4': 'video/mp4'
};
Future<SnAttachment> directUploadOne(
Uint8List data,
String filename,
String pool,
Map<String, dynamic>? metadata, {
String? mimetype,
Function(double progress)? onProgress,
}) async {
final filePayload = MultipartFile.fromBytes(data, filename: filename);
final fileAlt = filename.contains('.')
? filename.substring(0, filename.lastIndexOf('.'))
: filename;
final fileExt =
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride;
if (mimetype != null) {
mimetypeOverride = mimetype;
} else if (mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt];
}
final formData = FormData.fromMap({
'alt': fileAlt,
'file': filePayload,
'pool': pool,
'metadata': metadata,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
});
final resp = await _sn.client.post(
'/cgi/uc/attachments',
data: formData,
onSendProgress: (count, total) {
if (onProgress != null) {
onProgress(count / total);
}
},
);
return SnAttachment.fromJson(resp.data);
}
Future<(SnAttachment, int)> chunkedUploadInitialize(
int size,
String filename,
String pool,
Map<String, dynamic>? metadata,
) async {
final fileAlt = filename.contains('.')
? filename.substring(0, filename.lastIndexOf('.'))
: filename;
final fileExt =
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride;
if (mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt];
}
final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: {
'alt': fileAlt,
'name': filename,
'pool': pool,
'metadata': metadata,
'size': size,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
});
return (
SnAttachment.fromJson(resp.data['meta']),
resp.data['chunk_size'] as int
);
}
Future<SnAttachment> _chunkedUploadOnePart(
Uint8List data,
String rid,
String cid, {
Function(double progress)? onProgress,
}) async {
final resp = await _sn.client.post(
'/cgi/uc/attachments/multipart/$rid/$cid',
data: data,
options: Options(headers: {'Content-Type': 'application/octet-stream'}),
onSendProgress: (count, total) {
if (onProgress != null) {
onProgress(count / total);
}
},
);
return SnAttachment.fromJson(resp.data);
}
Future<SnAttachment> chunkedUploadParts(
XFile file,
SnAttachment place,
int chunkSize, {
Function(double progress)? onProgress,
}) async {
final Map<String, dynamic> chunks = place.fileChunks ?? {};
var currentTask = 0;
final queue = Queue<Future<void>>();
final activeTasks = <Future<void>>[];
for (final entry in chunks.entries) {
queue.add(() async {
final beginCursor = entry.value * chunkSize;
final endCursor = (entry.value + 1) * chunkSize;
final data = Uint8List.fromList(await file
.openRead(beginCursor, endCursor)
.expand((chunk) => chunk)
.toList());
place = await _chunkedUploadOnePart(
data,
place.rid,
entry.key,
onProgress: (chunkProgress) {
final overallProgress =
(currentTask + chunkProgress) / chunks.length;
if (onProgress != null) {
onProgress(overallProgress);
}
},
);
currentTask++;
}());
}
while (queue.isNotEmpty || activeTasks.isNotEmpty) {
while (activeTasks.length < kConcurrentUploadChunks && queue.isNotEmpty) {
final task = queue.removeFirst();
activeTasks.add(task);
task.then((_) => activeTasks.remove(task));
}
if (activeTasks.isNotEmpty) {
await Future.any(activeTasks);
}
}
return place;
}
}

View File

@ -1,8 +1,9 @@
import 'package:go_router/go_router.dart';
import 'package:surface/screens/account.dart';
import 'package:surface/screens/account/publisher_edit.dart';
import 'package:surface/screens/account/publisher_new.dart';
import 'package:surface/screens/account/publishers.dart';
import 'package:surface/screens/account/profile_edit.dart';
import 'package:surface/screens/account/publishers/publisher_edit.dart';
import 'package:surface/screens/account/publishers/publisher_new.dart';
import 'package:surface/screens/account/publishers/publishers.dart';
import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart';
import 'package:surface/screens/explore.dart';
@ -50,6 +51,11 @@ final appRouter = GoRouter(
name: 'authRegister',
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: '/account/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => const ProfileEditScreen(),
),
GoRoute(
path: '/account/publishers',
name: 'accountPublishers',

View File

@ -74,6 +74,16 @@ class _AuthorizedAccountScreen extends StatelessWidget {
);
}).padding(all: 20),
).padding(horizontal: 8, top: 16, bottom: 4),
ListTile(
title: Text('accountProfileEdit').tr(),
subtitle: Text('accountProfileEditSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.contact_page),
trailing: const Icon(Icons.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountProfileEdit');
},
),
ListTile(
title: Text('accountPublishers').tr(),
subtitle: Text('accountPublishersSubtitle').tr(),

View File

@ -0,0 +1,348 @@
import 'dart:io';
import 'dart:ui';
import 'package:croppy/croppy.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show basename;
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/universal_image.dart';
class ProfileEditScreen extends StatefulWidget {
const ProfileEditScreen({super.key});
@override
State<ProfileEditScreen> createState() => _ProfileEditScreenState();
}
class _ProfileEditScreenState extends State<ProfileEditScreen> {
final _imagePicker = ImagePicker();
final _usernameController = TextEditingController();
final _nicknameController = TextEditingController();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _descriptionController = TextEditingController();
final _birthdayController = TextEditingController();
String? _avatar;
String? _banner;
DateTime? _birthday;
bool _isBusy = false;
static const _kDateFormat = 'y/M/d';
void _syncWidget() async {
final ua = context.read<UserProvider>();
final prof = ua.user!;
_usernameController.text = prof.name;
_nicknameController.text = prof.nick;
_descriptionController.text = prof.description;
_firstNameController.text = prof.profile!.firstName;
_lastNameController.text = prof.profile!.lastName;
_avatar = prof.avatar;
_banner = prof.banner;
if (prof.profile!.birthday != null) {
_birthdayController.text = DateFormat(_kDateFormat).format(
prof.profile!.birthday!.toLocal(),
);
}
}
void _selectBirthday() async {
await showCupertinoModalPopup<DateTime?>(
context: context,
builder: (BuildContext context) => Container(
height: 216,
padding: const EdgeInsets.only(top: 6.0),
margin: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
top: false,
child: CupertinoDatePicker(
initialDateTime: _birthday?.toLocal(),
mode: CupertinoDatePickerMode.date,
use24hFormat: true,
onDateTimeChanged: (DateTime newDate) {
setState(() {
_birthday = newDate;
_birthdayController.text =
DateFormat(_kDateFormat).format(_birthday!);
});
},
),
),
),
);
}
Future<void> _updateImage(String place) async {
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return;
if (!mounted) return;
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;
final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
final rawBytes =
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
try {
final attachment = await attach.directUploadOne(
rawBytes,
basename(image.path),
'avatar',
null,
mimetype: 'image/png',
);
if (!mounted) return;
final sn = context.read<SnNetworkProvider>();
await sn.client.put(
'/cgi/id/users/me/$place',
data: {'attachment': attachment.rid},
);
if (!mounted) return;
final ua = context.read<UserProvider>();
await ua.refreshUser();
if (!mounted) return;
context.showSnackbar('accountProfileEditApplied'.tr());
_syncWidget();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _updateUserInfo() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
try {
await sn.client.put(
'/cgi/id/users/me',
data: {
'nick': _nicknameController.value.text,
'description': _descriptionController.value.text,
'first_name': _firstNameController.value.text,
'last_name': _lastNameController.value.text,
'birthday': _birthday?.toUtc().toIso8601String(),
},
);
if (!mounted) return;
final ua = context.read<UserProvider>();
await ua.refreshUser();
if (!mounted) return;
context.showSnackbar('accountProfileEditApplied'.tr());
_syncWidget();
} catch (err) {
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_syncWidget();
}
@override
void dispose() {
_usernameController.dispose();
_nicknameController.dispose();
_firstNameController.dispose();
_lastNameController.dispose();
_descriptionController.dispose();
_birthdayController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const double padding = 24;
final sn = context.read<SnNetworkProvider>();
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoadingIndicator(isActive: _isBusy),
const Gap(24),
Stack(
clipBehavior: Clip.none,
children: [
Material(
elevation: 0,
child: InkWell(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color:
Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null
? UniversalImage(
sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
),
),
onTap: () {
_updateImage('banner');
},
),
),
Positioned(
bottom: -28,
left: 16,
child: Material(
elevation: 2,
borderRadius: const BorderRadius.all(Radius.circular(40)),
child: InkWell(
child: AccountImage(content: _avatar, radius: 40),
onTap: () {
_updateImage('avatar');
},
),
),
),
],
).padding(horizontal: padding),
const Gap(8 + 28),
Column(
children: [
TextField(
readOnly: true,
controller: _usernameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(),
),
),
const Gap(4),
TextField(
controller: _nicknameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
),
const Gap(4),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
controller: _firstNameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldFirstName'.tr(),
),
),
),
const Gap(8),
Flexible(
flex: 1,
child: TextField(
controller: _lastNameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldLastName'.tr(),
),
),
),
],
),
const Gap(4),
TextField(
controller: _descriptionController,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldDescription'.tr(),
),
),
const Gap(4),
TextField(
controller: _birthdayController,
readOnly: true,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldBirthday'.tr(),
),
onTap: () => _selectBirthday(),
),
],
).padding(horizontal: padding + 8),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
onPressed: _isBusy ? null : _updateUserInfo,
icon: const Icon(Symbols.save),
label: Text('apply').tr(),
),
],
).padding(horizontal: padding),
],
),
);
}
}

View File

@ -134,7 +134,7 @@ class _AccountPublisherEditScreenState
const Gap(4),
TextField(
controller: _descriptionController,
maxLines: 3,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
labelText: 'fieldDescription'.tr(),

View File

@ -4,7 +4,7 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/universal_image.dart';
class AccountImage extends StatelessWidget {
final String content;
final String? content;
final Color? backgroundColor;
final Color? foregroundColor;
final double? radius;
@ -22,21 +22,21 @@ class AccountImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
final url = sn.getAttachmentUrl(content);
final url = sn.getAttachmentUrl(content ?? '');
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return CircleAvatar(
key: Key('attachment-${content.hashCode}'),
radius: radius,
backgroundColor: backgroundColor,
backgroundImage: content.isNotEmpty
backgroundImage: (content?.isNotEmpty ?? false)
? ResizeImage(
UniversalImage.provider(url),
width: ((radius ?? 20) * devicePixelRatio * 2).round(),
height: ((radius ?? 20) * devicePixelRatio * 2).round(),
)
: null,
child: content.isEmpty
child: (content?.isEmpty ?? true)
? (fallbackWidget ??
Icon(
Icons.account_circle,