Profile editing & file upload

This commit is contained in:
2025-04-25 23:04:26 +08:00
parent 7b8ee81f03
commit aed2160760
20 changed files with 611 additions and 39 deletions

View File

@ -60,7 +60,6 @@ class IslandApp extends HookConsumerWidget {
final userNotifier = ref.read(userInfoProvider.notifier);
Future(() {
userNotifier.fetchUser();
print('user fetched');
});
return null;
}, []);

View File

@ -6,6 +6,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:island/models/auth.dart';
@ -13,6 +14,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'config.dart';
final imagePickerProvider = Provider((ref) => ImagePicker());
final userAgentProvider = FutureProvider<String>((ref) async {
final String platformInfo;
if (kIsWeb) {
@ -64,7 +67,14 @@ final apiClientProvider = Provider<Dio>((ref) {
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final atk = await getFreshAtk(ref);
final atk = await getFreshAtk(
ref.watch(tokenPairProvider),
ref.watch(serverUrlProvider),
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk != null) {
options.headers['Authorization'] = 'Bearer $atk';
}
@ -87,11 +97,9 @@ final tokenPairProvider = Provider<AppTokenPair?>((ref) {
return AppTokenPair.fromJson(jsonDecode(tkPairString));
});
Future<(String, String)?> refreshToken(Ref ref, String? rtk) async {
Future<(String, String)?> refreshToken(String baseUrl, String? rtk) async {
if (rtk == null) return null;
final baseUrl = ref.watch(serverUrlProvider);
final dio = Dio();
dio.options.baseUrl = baseUrl;
@ -102,16 +110,17 @@ Future<(String, String)?> refreshToken(Ref ref, String? rtk) async {
final String atk = resp.data['access_token'];
final String nRtk = resp.data['refresh_token'];
setTokenPair(ref.watch(sharedPreferencesProvider), atk, nRtk);
ref.invalidate(tokenPairProvider);
return (atk, nRtk);
}
Completer<String?>? _refreshCompleter;
Future<String?> getFreshAtk(Ref ref) async {
final tkPair = ref.watch(tokenPairProvider);
Future<String?> getFreshAtk(
AppTokenPair? tkPair,
String baseUrl, {
Function(String, String)? onRefreshed,
}) async {
var atk = tkPair?.accessToken;
var rtk = tkPair?.refreshToken;
@ -147,10 +156,11 @@ Future<String?> getFreshAtk(Ref ref) async {
final exp = jsonDecode(payload)['exp'];
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
log('[Auth] Access token need refresh, doing it at ${DateTime.now()}');
final result = await refreshToken(ref, rtk);
final result = await refreshToken(baseUrl, rtk);
if (result == null) {
atk = null;
} else {
onRefreshed?.call(result.$1, result.$2);
atk = result.$1;
}
}

View File

@ -121,8 +121,8 @@ Future<ThemeData> createAppTheme(
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
),
progressIndicatorTheme: ProgressIndicatorThemeData(year2023: false),
sliderTheme: SliderThemeData(year2023: false),
progressIndicatorTheme: ProgressIndicatorThemeData(),
sliderTheme: SliderThemeData(),
);
}

View File

@ -15,7 +15,6 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
}
Future<void> fetchUser() async {
state = const AsyncValue.loading();
try {
final client = _ref.read(apiClientProvider);
final response = await client.get('/accounts/me');

View File

@ -19,5 +19,7 @@ class AppRouter extends RootStackRouter {
),
AutoRoute(page: LoginRoute.page, path: '/auth/login'),
AutoRoute(page: CreateAccountRoute.page, path: '/auth/create-account'),
AutoRoute(page: MyselfProfileRoute.page, path: '/account/me'),
AutoRoute(page: UpdateProfileRoute.page, path: '/account/me/update'),
];
}

View File

@ -9,22 +9,24 @@
// coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:auto_route/auto_route.dart' as _i6;
import 'package:auto_route/auto_route.dart' as _i8;
import 'package:island/screens/account.dart' as _i1;
import 'package:island/screens/auth/account/me.dart' as _i5;
import 'package:island/screens/auth/account/me/update.dart' as _i7;
import 'package:island/screens/auth/create_account.dart' as _i2;
import 'package:island/screens/auth/login.dart' as _i4;
import 'package:island/screens/auth/tabs.dart' as _i5;
import 'package:island/screens/auth/tabs.dart' as _i6;
import 'package:island/screens/explore.dart' as _i3;
/// generated route for
/// [_i1.AccountScreen]
class AccountRoute extends _i6.PageRouteInfo<void> {
const AccountRoute({List<_i6.PageRouteInfo>? children})
class AccountRoute extends _i8.PageRouteInfo<void> {
const AccountRoute({List<_i8.PageRouteInfo>? children})
: super(AccountRoute.name, initialChildren: children);
static const String name = 'AccountRoute';
static _i6.PageInfo page = _i6.PageInfo(
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i1.AccountScreen();
@ -34,13 +36,13 @@ class AccountRoute extends _i6.PageRouteInfo<void> {
/// generated route for
/// [_i2.CreateAccountScreen]
class CreateAccountRoute extends _i6.PageRouteInfo<void> {
const CreateAccountRoute({List<_i6.PageRouteInfo>? children})
class CreateAccountRoute extends _i8.PageRouteInfo<void> {
const CreateAccountRoute({List<_i8.PageRouteInfo>? children})
: super(CreateAccountRoute.name, initialChildren: children);
static const String name = 'CreateAccountRoute';
static _i6.PageInfo page = _i6.PageInfo(
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i2.CreateAccountScreen();
@ -50,13 +52,13 @@ class CreateAccountRoute extends _i6.PageRouteInfo<void> {
/// generated route for
/// [_i3.ExploreScreen]
class ExploreRoute extends _i6.PageRouteInfo<void> {
const ExploreRoute({List<_i6.PageRouteInfo>? children})
class ExploreRoute extends _i8.PageRouteInfo<void> {
const ExploreRoute({List<_i8.PageRouteInfo>? children})
: super(ExploreRoute.name, initialChildren: children);
static const String name = 'ExploreRoute';
static _i6.PageInfo page = _i6.PageInfo(
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i3.ExploreScreen();
@ -66,13 +68,13 @@ class ExploreRoute extends _i6.PageRouteInfo<void> {
/// generated route for
/// [_i4.LoginScreen]
class LoginRoute extends _i6.PageRouteInfo<void> {
const LoginRoute({List<_i6.PageRouteInfo>? children})
class LoginRoute extends _i8.PageRouteInfo<void> {
const LoginRoute({List<_i8.PageRouteInfo>? children})
: super(LoginRoute.name, initialChildren: children);
static const String name = 'LoginRoute';
static _i6.PageInfo page = _i6.PageInfo(
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i4.LoginScreen();
@ -81,17 +83,49 @@ class LoginRoute extends _i6.PageRouteInfo<void> {
}
/// generated route for
/// [_i5.TabsScreen]
class TabsRoute extends _i6.PageRouteInfo<void> {
const TabsRoute({List<_i6.PageRouteInfo>? children})
/// [_i5.MyselfProfileScreen]
class MyselfProfileRoute extends _i8.PageRouteInfo<void> {
const MyselfProfileRoute({List<_i8.PageRouteInfo>? children})
: super(MyselfProfileRoute.name, initialChildren: children);
static const String name = 'MyselfProfileRoute';
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i5.MyselfProfileScreen();
},
);
}
/// generated route for
/// [_i6.TabsScreen]
class TabsRoute extends _i8.PageRouteInfo<void> {
const TabsRoute({List<_i8.PageRouteInfo>? children})
: super(TabsRoute.name, initialChildren: children);
static const String name = 'TabsRoute';
static _i6.PageInfo page = _i6.PageInfo(
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i5.TabsScreen();
return const _i6.TabsScreen();
},
);
}
/// generated route for
/// [_i7.UpdateProfileScreen]
class UpdateProfileRoute extends _i8.PageRouteInfo<void> {
const UpdateProfileRoute({List<_i8.PageRouteInfo>? children})
: super(UpdateProfileRoute.name, initialChildren: children);
static const String name = 'UpdateProfileRoute';
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i7.UpdateProfileScreen();
},
);
}

View File

@ -72,6 +72,16 @@ class AccountScreen extends HookConsumerWidget {
],
),
),
ListTile(
leading: const Icon(LucideIcons.edit),
trailing: const Icon(LucideIcons.chevronRight),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('accountProfile').tr(),
subtitle: Text('Update your profile.'),
onTap: () {
context.router.push(UpdateProfileRoute());
},
),
ListTile(
leading: const Icon(LucideIcons.logOut),
trailing: const Icon(LucideIcons.chevronRight),

View File

@ -0,0 +1,13 @@
import 'package:auto_route/annotations.dart';
import 'package:flutter/material.dart';
import 'package:island/widgets/app_scaffold.dart';
@RoutePage()
class MyselfProfileScreen extends StatelessWidget {
const MyselfProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return AppScaffold(appBar: AppBar(leading: const PageBackButton()));
}
}

View File

@ -0,0 +1,238 @@
import 'package:auto_route/auto_route.dart';
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:image_picker/image_picker.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/services/file.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:styled_widget/styled_widget.dart';
@RoutePage()
class UpdateProfileScreen extends HookConsumerWidget {
const UpdateProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userInfoProvider);
final submitting = useState(false);
void updateProfilePicture(String position) async {
final result = await ref
.read(imagePickerProvider)
.pickImage(source: ImageSource.gallery);
if (result == null) return;
submitting.value = true;
try {
final baseUrl = ref.watch(serverUrlProvider);
final atk = await getFreshAtk(
ref.watch(tokenPairProvider),
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) throw ArgumentError('Access token is null');
final cloudFile =
await putMediaToCloud(
fileData: result,
atk: atk,
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
final client = ref.watch(apiClientProvider);
await client.patch(
'/accounts/me/profile',
data: {'${position}_id': cloudFile.id},
);
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser();
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
final formKeyBasicInfo = useMemoized(GlobalKey<FormState>.new, const []);
final usernameController = useTextEditingController(text: user.value!.name);
final nicknameController = useTextEditingController(text: user.value!.nick);
void updateBasicInfo() async {
if (!formKeyBasicInfo.currentState!.validate()) return;
submitting.value = true;
try {
final client = ref.watch(apiClientProvider);
await client.patch(
'/accounts/me',
data: {
'name': usernameController.text,
'nick': nicknameController.text,
},
);
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser();
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
final formKeyProfile = useMemoized(GlobalKey<FormState>.new, const []);
final bioController = useTextEditingController(
text: user.value!.profile.bio,
);
void updateProfile() async {
if (!formKeyProfile.currentState!.validate()) return;
submitting.value = true;
try {
final client = ref.watch(apiClientProvider);
await client.patch(
'/accounts/me/profile',
data: {'bio': bioController.text},
);
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser();
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return AppScaffold(
appBar: AppBar(
title: Text('updateYourProfile').tr(),
leading: const PageBackButton(),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child:
user.value!.profile.background != null
? CloudFileWidget(
item: user.value!.profile.background!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
updateProfilePicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
item: user.value!.profile.picture,
radius: 40,
),
onTap: () {
updateProfilePicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Text('accountBasicInfo')
.tr()
.bold()
.fontSize(18)
.padding(horizontal: 24, top: 16, bottom: 12),
Form(
key: formKeyBasicInfo,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
TextFormField(
decoration: InputDecoration(
labelText: 'username'.tr(),
helperText: 'usernameCannotChangeHint'.tr(),
prefixText: '@',
),
controller: usernameController,
readOnly: true,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
TextFormField(
decoration: InputDecoration(labelText: 'nickname'.tr()),
controller: nicknameController,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : updateBasicInfo,
label: Text('saveChanges').tr(),
icon: const Icon(LucideIcons.save),
),
),
],
).padding(horizontal: 24),
),
Text('accountProfile')
.tr()
.bold()
.fontSize(18)
.padding(horizontal: 24, top: 16, bottom: 8),
Form(
key: formKeyProfile,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
TextFormField(
decoration: InputDecoration(labelText: 'bio'.tr()),
maxLines: null,
minLines: 3,
controller: bioController,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : updateProfile,
label: Text('saveChanges').tr(),
icon: const Icon(LucideIcons.save),
),
),
],
).padding(horizontal: 24),
),
],
),
);
}
}

65
lib/services/file.dart Normal file
View File

@ -0,0 +1,65 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:island/models/file.dart';
import 'package:tus_client_dart/tus_client_dart.dart';
Completer<SnCloudFile?> putMediaToCloud({
required dynamic fileData, // Can be XFile or List<int> (Uint8List)
required String atk,
required String baseUrl,
String? filename,
String? mimetype,
Function(double progress, Duration estimate)? onProgress,
}) {
XFile file;
String actualFilename = filename ?? 'randomly_file';
String actualMimetype = mimetype ?? '';
Uint8List? byteData;
if (fileData is XFile) {
file = fileData;
actualFilename = filename ?? fileData.name;
actualMimetype = mimetype ?? fileData.mimeType ?? '';
} else if (fileData is List<int> || fileData is Uint8List) {
byteData = fileData is List<int> ? Uint8List.fromList(fileData) : fileData;
actualFilename = filename ?? 'uploaded_file';
actualMimetype = mimetype ?? 'application/octet-stream';
if (mimetype == null) {
throw ArgumentError('Mimetype is required when providing raw bytes.');
}
file = XFile.fromData(byteData!, mimeType: actualMimetype);
} else {
throw ArgumentError(
'Invalid fileData type. Expected XFile or List<int> (Uint8List).',
);
}
final Map<String, String> metadata = {
'filename': actualFilename,
'content-type': actualMimetype,
};
final completer = Completer<SnCloudFile?>();
final client = TusClient(file);
client
.upload(
uri: Uri.parse('$baseUrl/files/tus'),
headers: {'Authorization': 'Bearer $atk'},
metadata: metadata,
onComplete: (lastResponse) {
final resp = jsonDecode(lastResponse!.headers['x-fileinfo']!);
completer.complete(SnCloudFile.fromJson(resp));
},
onProgress: (double progress, Duration estimate) {
onProgress?.call(progress, estimate);
},
measureUploadSpeed: true,
)
.catchError(completer.completeError);
return completer;
}