1037 lines
		
	
	
		
			44 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			1037 lines
		
	
	
		
			44 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'package:collection/collection.dart';
 | |
| import 'package:croppy/croppy.dart' hide cropImage;
 | |
| import 'package:dropdown_button2/dropdown_button2.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:image_picker/image_picker.dart';
 | |
| import 'package:island/models/file.dart';
 | |
| import 'package:island/models/account.dart';
 | |
| import 'package:island/pods/network.dart';
 | |
| import 'package:island/pods/userinfo.dart';
 | |
| import 'package:island/services/file.dart';
 | |
| import 'package:island/services/file_uploader.dart';
 | |
| import 'package:island/services/timezone.dart';
 | |
| import 'package:island/widgets/account/account_name.dart';
 | |
| import 'package:island/widgets/alert.dart';
 | |
| import 'package:island/widgets/app_scaffold.dart';
 | |
| import 'package:island/widgets/content/cloud_files.dart';
 | |
| import 'package:material_symbols_icons/symbols.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| 
 | |
| const kServerSupportedLanguages = {'en-US': 'en-us', 'zh-CN': 'zh-hans'};
 | |
| const kServerSupportedRegions = ['US', 'JP', 'CN'];
 | |
| 
 | |
| class UpdateProfileScreen extends HookConsumerWidget {
 | |
|   bool _isValidHexColor(String color) {
 | |
|     if (!color.startsWith('#')) return false;
 | |
|     if (color.length != 7) return false; // #RRGGBB format
 | |
|     try {
 | |
|       int.parse(color.substring(1), radix: 16);
 | |
|       return true;
 | |
|     } catch (_) {
 | |
|       return false;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   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 {
 | |
|       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,
 | |
|         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(
 | |
|               client: ref.read(apiClientProvider),
 | |
|               fileData: UniversalFile(
 | |
|                 data: result,
 | |
|                 type: UniversalFileType.image,
 | |
|               ),
 | |
|             ).future;
 | |
|         if (cloudFile == null) {
 | |
|           throw ArgumentError('Failed to upload the file...');
 | |
|         }
 | |
|         final client = ref.watch(apiClientProvider);
 | |
|         await client.patch(
 | |
|           '/pass/accounts/me/profile',
 | |
|           data: {'${position}_id': cloudFile.id},
 | |
|         );
 | |
|         final userNotifier = ref.read(userInfoProvider.notifier);
 | |
|         userNotifier.fetchUser();
 | |
|       } catch (err) {
 | |
|         showErrorAlert(err);
 | |
|       } finally {
 | |
|         submitting.value = false;
 | |
|         if (context.mounted) hideLoadingModal(context);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     final formKeyBasicInfo = useMemoized(GlobalKey<FormState>.new, const []);
 | |
|     final usernameController = useTextEditingController(text: user.value!.name);
 | |
|     final nicknameController = useTextEditingController(text: user.value!.nick);
 | |
|     final language = useState(user.value!.language);
 | |
|     final region = useState(user.value!.region);
 | |
|     final links = useState<List<ProfileLink>>(user.value!.profile.links);
 | |
| 
 | |
|     void updateBasicInfo() async {
 | |
|       if (!formKeyBasicInfo.currentState!.validate()) return;
 | |
| 
 | |
|       submitting.value = true;
 | |
|       try {
 | |
|         final client = ref.watch(apiClientProvider);
 | |
|         await client.patch(
 | |
|           '/pass/accounts/me',
 | |
|           data: {
 | |
|             'name': usernameController.text,
 | |
|             'nick': nicknameController.text,
 | |
|             'language': language.value,
 | |
|             'region': region.value,
 | |
|           },
 | |
|         );
 | |
|         final userNotifier = ref.read(userInfoProvider.notifier);
 | |
|         userNotifier.fetchUser();
 | |
|       } catch (err) {
 | |
|         showErrorAlert(err);
 | |
|       } finally {
 | |
|         submitting.value = false;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     final formKeyProfile = useMemoized(GlobalKey<FormState>.new, const []);
 | |
|     final birthday = useState<DateTime?>(
 | |
|       user.value!.profile.birthday?.toLocal(),
 | |
|     );
 | |
|     final firstNameController = useTextEditingController(
 | |
|       text: user.value!.profile.firstName,
 | |
|     );
 | |
|     final middleNameController = useTextEditingController(
 | |
|       text: user.value!.profile.middleName,
 | |
|     );
 | |
|     final lastNameController = useTextEditingController(
 | |
|       text: user.value!.profile.lastName,
 | |
|     );
 | |
|     final bioController = useTextEditingController(
 | |
|       text: user.value!.profile.bio,
 | |
|     );
 | |
|     final genderController = useTextEditingController(
 | |
|       text: user.value!.profile.gender,
 | |
|     );
 | |
|     final pronounsController = useTextEditingController(
 | |
|       text: user.value!.profile.pronouns,
 | |
|     );
 | |
|     final locationController = useTextEditingController(
 | |
|       text: user.value!.profile.location,
 | |
|     );
 | |
|     final timeZoneController = useTextEditingController(
 | |
|       text: user.value!.profile.timeZone,
 | |
|     );
 | |
| 
 | |
|     // Username color state
 | |
|     final usernameColorType = useState<String>(
 | |
|       user.value!.profile.usernameColor?.type ?? 'plain',
 | |
|     );
 | |
|     final usernameColorValue = useTextEditingController(
 | |
|       text: user.value!.profile.usernameColor?.value,
 | |
|     );
 | |
|     final usernameColorDirection = useTextEditingController(
 | |
|       text: user.value!.profile.usernameColor?.direction,
 | |
|     );
 | |
|     final usernameColorColors = useState<List<String>>(
 | |
|       user.value!.profile.usernameColor?.colors ?? [],
 | |
|     );
 | |
| 
 | |
|     void updateProfile() async {
 | |
|       if (!formKeyProfile.currentState!.validate()) return;
 | |
| 
 | |
|       submitting.value = true;
 | |
|       try {
 | |
|         final client = ref.watch(apiClientProvider);
 | |
|         final usernameColorData = {
 | |
|           'type': usernameColorType.value,
 | |
|           if (usernameColorType.value == 'plain' &&
 | |
|               usernameColorValue.text.isNotEmpty)
 | |
|             'value': usernameColorValue.text,
 | |
|           if (usernameColorType.value == 'gradient') ...{
 | |
|             if (usernameColorDirection.text.isNotEmpty)
 | |
|               'direction': usernameColorDirection.text,
 | |
|             'colors':
 | |
|                 usernameColorColors.value.where((c) => c.isNotEmpty).toList(),
 | |
|           },
 | |
|         };
 | |
| 
 | |
|         await client.patch(
 | |
|           '/pass/accounts/me/profile',
 | |
|           data: {
 | |
|             'bio': bioController.text,
 | |
|             'first_name': firstNameController.text,
 | |
|             'middle_name': middleNameController.text,
 | |
|             'last_name': lastNameController.text,
 | |
|             'gender': genderController.text,
 | |
|             'pronouns': pronounsController.text,
 | |
|             'location': locationController.text,
 | |
|             'time_zone': timeZoneController.text,
 | |
|             'birthday': birthday.value?.toUtc().toIso8601String(),
 | |
|             'username_color': usernameColorData,
 | |
|             'links':
 | |
|                 links.value
 | |
|                     .where((e) => e.name.isNotEmpty && e.url.isNotEmpty)
 | |
|                     .toList(),
 | |
|           },
 | |
|         );
 | |
|         final userNotifier = ref.read(userInfoProvider.notifier);
 | |
|         userNotifier.fetchUser();
 | |
|         links.value =
 | |
|             links.value
 | |
|                 .where((e) => e.name.isNotEmpty && e.url.isNotEmpty)
 | |
|                 .toList();
 | |
|       } catch (err) {
 | |
|         showErrorAlert(err);
 | |
|       } finally {
 | |
|         submitting.value = false;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return AppScaffold(
 | |
|       appBar: AppBar(
 | |
|         title: Text('updateYourProfile').tr(),
 | |
|         leading: const PageBackButton(),
 | |
|       ),
 | |
|       body: SingleChildScrollView(
 | |
|         padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
 | |
|         child: 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?.id != null
 | |
|                               ? CloudImageWidget(
 | |
|                                 fileId: user.value!.profile.background!.id,
 | |
|                                 fit: BoxFit.cover,
 | |
|                               )
 | |
|                               : const SizedBox.shrink(),
 | |
|                     ),
 | |
|                     onTap: () {
 | |
|                       updateProfilePicture('background');
 | |
|                     },
 | |
|                   ),
 | |
|                   Positioned(
 | |
|                     left: 20,
 | |
|                     bottom: -32,
 | |
|                     child: GestureDetector(
 | |
|                       child: ProfilePictureWidget(
 | |
|                         fileId: user.value!.profile.picture?.id,
 | |
|                         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.stretch,
 | |
|                 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(),
 | |
|                   ),
 | |
|                   DropdownButtonFormField2<String>(
 | |
|                     decoration: InputDecoration(
 | |
|                       labelText: 'language'.tr(),
 | |
|                       helperText: 'accountLanguageHint'.tr(),
 | |
|                     ),
 | |
|                     items: [
 | |
|                       ...kServerSupportedLanguages.values.map(
 | |
|                         (e) => DropdownMenuItem(value: e, child: Text(e)),
 | |
|                       ),
 | |
|                       if (!kServerSupportedLanguages.containsValue(
 | |
|                         language.value,
 | |
|                       ))
 | |
|                         DropdownMenuItem(
 | |
|                           value: language.value,
 | |
|                           child: Text(language.value),
 | |
|                         ),
 | |
|                     ],
 | |
|                     value: language.value,
 | |
|                     onChanged: (value) {
 | |
|                       language.value = value ?? language.value;
 | |
|                     },
 | |
|                     customButton: Row(
 | |
|                       children: [
 | |
|                         Expanded(child: Text(language.value)),
 | |
|                         Icon(Symbols.arrow_drop_down),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                   DropdownButtonFormField2<String>(
 | |
|                     decoration: InputDecoration(
 | |
|                       labelText: 'region'.tr(),
 | |
|                       helperText: 'accountRegionHint'.tr(),
 | |
|                     ),
 | |
|                     items: [
 | |
|                       ...kServerSupportedRegions.map(
 | |
|                         (e) => DropdownMenuItem(value: e, child: Text(e)),
 | |
|                       ),
 | |
|                       if (!kServerSupportedRegions.contains(region.value))
 | |
|                         DropdownMenuItem(
 | |
|                           value: region.value,
 | |
|                           child: Text(region.value),
 | |
|                         ),
 | |
|                     ],
 | |
|                     value: region.value,
 | |
|                     onChanged: (value) {
 | |
|                       region.value = value ?? region.value;
 | |
|                     },
 | |
|                     customButton: Row(
 | |
|                       children: [
 | |
|                         Expanded(child: Text(region.value)),
 | |
|                         Icon(Symbols.arrow_drop_down),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                   Align(
 | |
|                     alignment: Alignment.centerRight,
 | |
|                     child: TextButton.icon(
 | |
|                       onPressed: submitting.value ? null : updateBasicInfo,
 | |
|                       label: Text('saveChanges').tr(),
 | |
|                       icon: const Icon(Symbols.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: [
 | |
|                   Row(
 | |
|                     spacing: 16,
 | |
|                     children: [
 | |
|                       Expanded(
 | |
|                         child: TextFormField(
 | |
|                           decoration: InputDecoration(
 | |
|                             labelText: 'firstName'.tr(),
 | |
|                           ),
 | |
|                           controller: firstNameController,
 | |
|                           onTapOutside:
 | |
|                               (_) =>
 | |
|                                   FocusManager.instance.primaryFocus?.unfocus(),
 | |
|                         ),
 | |
|                       ),
 | |
|                       Expanded(
 | |
|                         child: TextFormField(
 | |
|                           decoration: InputDecoration(
 | |
|                             labelText: 'middleName'.tr(),
 | |
|                           ),
 | |
|                           controller: middleNameController,
 | |
|                           onTapOutside:
 | |
|                               (_) =>
 | |
|                                   FocusManager.instance.primaryFocus?.unfocus(),
 | |
|                         ),
 | |
|                       ),
 | |
|                       Expanded(
 | |
|                         child: TextFormField(
 | |
|                           decoration: InputDecoration(
 | |
|                             labelText: 'lastName'.tr(),
 | |
|                           ),
 | |
|                           controller: lastNameController,
 | |
|                           onTapOutside:
 | |
|                               (_) =>
 | |
|                                   FocusManager.instance.primaryFocus?.unfocus(),
 | |
|                         ),
 | |
|                       ),
 | |
|                     ],
 | |
|                   ),
 | |
| 
 | |
|                   TextFormField(
 | |
|                     decoration: InputDecoration(
 | |
|                       labelText: 'bio'.tr(),
 | |
|                       alignLabelWithHint: true,
 | |
|                     ),
 | |
|                     maxLines: null,
 | |
|                     minLines: 3,
 | |
|                     controller: bioController,
 | |
|                     onTapOutside:
 | |
|                         (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | |
|                   ),
 | |
|                   Row(
 | |
|                     spacing: 16,
 | |
|                     children: [
 | |
|                       Expanded(
 | |
|                         child: Autocomplete<String>(
 | |
|                           optionsBuilder: (TextEditingValue textEditingValue) {
 | |
|                             final options = ['Male', 'Female'];
 | |
|                             if (textEditingValue.text == '') {
 | |
|                               return options;
 | |
|                             }
 | |
|                             return options.where(
 | |
|                               (option) => option.toLowerCase().contains(
 | |
|                                 textEditingValue.text.toLowerCase(),
 | |
|                               ),
 | |
|                             );
 | |
|                           },
 | |
|                           onSelected: (String selection) {
 | |
|                             genderController.text = selection;
 | |
|                           },
 | |
|                           fieldViewBuilder: (
 | |
|                             context,
 | |
|                             controller,
 | |
|                             focusNode,
 | |
|                             onFieldSubmitted,
 | |
|                           ) {
 | |
|                             // Initialize the controller with the current value
 | |
|                             if (controller.text.isEmpty &&
 | |
|                                 genderController.text.isNotEmpty) {
 | |
|                               controller.text = genderController.text;
 | |
|                             }
 | |
| 
 | |
|                             return TextFormField(
 | |
|                               controller: controller,
 | |
|                               focusNode: focusNode,
 | |
|                               decoration: InputDecoration(
 | |
|                                 labelText: 'gender'.tr(),
 | |
|                               ),
 | |
|                               onChanged: (value) {
 | |
|                                 genderController.text = value;
 | |
|                               },
 | |
|                               onTapOutside:
 | |
|                                   (_) =>
 | |
|                                       FocusManager.instance.primaryFocus
 | |
|                                           ?.unfocus(),
 | |
|                             );
 | |
|                           },
 | |
|                         ),
 | |
|                       ),
 | |
|                       Expanded(
 | |
|                         child: TextFormField(
 | |
|                           decoration: InputDecoration(
 | |
|                             labelText: 'pronouns'.tr(),
 | |
|                           ),
 | |
|                           controller: pronounsController,
 | |
|                           onTapOutside:
 | |
|                               (_) =>
 | |
|                                   FocusManager.instance.primaryFocus?.unfocus(),
 | |
|                         ),
 | |
|                       ),
 | |
|                     ],
 | |
|                   ),
 | |
|                   Row(
 | |
|                     spacing: 16,
 | |
|                     children: [
 | |
|                       Expanded(
 | |
|                         child: TextFormField(
 | |
|                           decoration: InputDecoration(
 | |
|                             labelText: 'location'.tr(),
 | |
|                           ),
 | |
|                           controller: locationController,
 | |
|                           onTapOutside:
 | |
|                               (_) =>
 | |
|                                   FocusManager.instance.primaryFocus?.unfocus(),
 | |
|                         ),
 | |
|                       ),
 | |
|                       Expanded(
 | |
|                         child: Autocomplete<String>(
 | |
|                           optionsBuilder: (TextEditingValue textEditingValue) {
 | |
|                             if (textEditingValue.text.isEmpty) {
 | |
|                               return const Iterable<String>.empty();
 | |
|                             }
 | |
|                             final lowercaseQuery =
 | |
|                                 textEditingValue.text.toLowerCase();
 | |
|                             return getAvailableTz().where((tz) {
 | |
|                               return tz.toLowerCase().contains(lowercaseQuery);
 | |
|                             });
 | |
|                           },
 | |
|                           onSelected: (String selection) {
 | |
|                             timeZoneController.text = selection;
 | |
|                           },
 | |
|                           fieldViewBuilder: (
 | |
|                             context,
 | |
|                             controller,
 | |
|                             focusNode,
 | |
|                             onFieldSubmitted,
 | |
|                           ) {
 | |
|                             // Sync the controller with timeZoneController when the widget is built
 | |
|                             if (controller.text != timeZoneController.text) {
 | |
|                               controller.text = timeZoneController.text;
 | |
|                             }
 | |
| 
 | |
|                             return TextFormField(
 | |
|                               controller: controller,
 | |
|                               focusNode: focusNode,
 | |
|                               decoration: InputDecoration(
 | |
|                                 labelText: 'timeZone'.tr(),
 | |
|                                 suffix: InkWell(
 | |
|                                   child: const Icon(
 | |
|                                     Symbols.my_location,
 | |
|                                     size: 18,
 | |
|                                   ),
 | |
|                                   onTap: () async {
 | |
|                                     try {
 | |
|                                       showLoadingModal(context);
 | |
|                                       final machineTz = await getMachineTz();
 | |
|                                       controller.text = machineTz;
 | |
|                                       timeZoneController.text = machineTz;
 | |
|                                     } finally {
 | |
|                                       if (context.mounted) {
 | |
|                                         hideLoadingModal(context);
 | |
|                                       }
 | |
|                                     }
 | |
|                                   },
 | |
|                                 ),
 | |
|                               ),
 | |
|                               onChanged: (value) {
 | |
|                                 timeZoneController.text = value;
 | |
|                               },
 | |
|                             );
 | |
|                           },
 | |
|                           optionsViewBuilder: (context, onSelected, options) {
 | |
|                             return Align(
 | |
|                               alignment: Alignment.topLeft,
 | |
|                               child: Material(
 | |
|                                 elevation: 4.0,
 | |
|                                 child: ConstrainedBox(
 | |
|                                   constraints: const BoxConstraints(
 | |
|                                     maxHeight: 200,
 | |
|                                     maxWidth: 300,
 | |
|                                   ),
 | |
|                                   child: ListView.builder(
 | |
|                                     padding: const EdgeInsets.all(8.0),
 | |
|                                     itemCount: options.length,
 | |
|                                     itemBuilder: (
 | |
|                                       BuildContext context,
 | |
|                                       int index,
 | |
|                                     ) {
 | |
|                                       final option = options.elementAt(index);
 | |
|                                       return ListTile(
 | |
|                                         title: Text(
 | |
|                                           option,
 | |
|                                           overflow: TextOverflow.ellipsis,
 | |
|                                         ),
 | |
|                                         onTap: () {
 | |
|                                           onSelected(option);
 | |
|                                         },
 | |
|                                       );
 | |
|                                     },
 | |
|                                   ),
 | |
|                                 ),
 | |
|                               ),
 | |
|                             );
 | |
|                           },
 | |
|                         ),
 | |
|                       ),
 | |
|                     ],
 | |
|                   ),
 | |
|                   GestureDetector(
 | |
|                     onTap: () async {
 | |
|                       final date = await showDatePicker(
 | |
|                         context: context,
 | |
|                         initialDate: birthday.value ?? DateTime.now(),
 | |
|                         firstDate: DateTime(1900),
 | |
|                         lastDate: DateTime.now(),
 | |
|                       );
 | |
|                       if (date != null) {
 | |
|                         birthday.value = date;
 | |
|                       }
 | |
|                     },
 | |
|                     child: Container(
 | |
|                       padding: const EdgeInsets.symmetric(vertical: 8),
 | |
|                       decoration: BoxDecoration(
 | |
|                         border: Border(
 | |
|                           bottom: BorderSide(
 | |
|                             color: Theme.of(context).dividerColor,
 | |
|                             width: 1,
 | |
|                           ),
 | |
|                         ),
 | |
|                       ),
 | |
|                       child: Column(
 | |
|                         crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|                         children: [
 | |
|                           Text(
 | |
|                             'birthday'.tr(),
 | |
|                             style: TextStyle(
 | |
|                               color: Theme.of(context).hintColor,
 | |
|                             ),
 | |
|                           ),
 | |
|                           Text(
 | |
|                             birthday.value != null
 | |
|                                 ? DateFormat.yMMMd().format(birthday.value!)
 | |
|                                 : 'Select a date'.tr(),
 | |
|                           ),
 | |
|                         ],
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|                   Text(
 | |
|                     'usernameColor',
 | |
|                   ).tr().bold().fontSize(18).padding(top: 16),
 | |
|                   Column(
 | |
|                     spacing: 16,
 | |
|                     crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                     children: [
 | |
|                       // Preview section
 | |
|                       Container(
 | |
|                         padding: const EdgeInsets.all(16),
 | |
|                         decoration: BoxDecoration(
 | |
|                           color:
 | |
|                               Theme.of(
 | |
|                                 context,
 | |
|                               ).colorScheme.surfaceContainerHighest,
 | |
|                           borderRadius: BorderRadius.circular(8),
 | |
|                         ),
 | |
|                         child: Column(
 | |
|                           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                           children: [
 | |
|                             Text('preview').tr().bold().fontSize(14),
 | |
|                             const Gap(8),
 | |
|                             // Create a preview account with the current settings
 | |
|                             Builder(
 | |
|                               builder: (context) {
 | |
|                                 final previewAccount = user.value!.copyWith(
 | |
|                                   profile: user.value!.profile.copyWith(
 | |
|                                     usernameColor: UsernameColor(
 | |
|                                       type: usernameColorType.value,
 | |
|                                       value:
 | |
|                                           usernameColorType.value == 'plain' &&
 | |
|                                                   usernameColorValue
 | |
|                                                       .text
 | |
|                                                       .isNotEmpty
 | |
|                                               ? usernameColorValue.text
 | |
|                                               : null,
 | |
|                                       direction:
 | |
|                                           usernameColorType.value ==
 | |
|                                                       'gradient' &&
 | |
|                                                   usernameColorDirection
 | |
|                                                       .text
 | |
|                                                       .isNotEmpty
 | |
|                                               ? usernameColorDirection.text
 | |
|                                               : null,
 | |
|                                       colors:
 | |
|                                           usernameColorType.value == 'gradient'
 | |
|                                               ? usernameColorColors.value
 | |
|                                                   .where((c) => c.isNotEmpty)
 | |
|                                                   .toList()
 | |
|                                               : null,
 | |
|                                     ),
 | |
|                                   ),
 | |
|                                 );
 | |
| 
 | |
|                                 // Check if user can use this color
 | |
|                                 final tier =
 | |
|                                     user.value!.perkSubscription?.identifier;
 | |
|                                 final canUseColor = switch (tier) {
 | |
|                                   'solian.stellar.primary' =>
 | |
|                                     usernameColorType.value == 'plain' &&
 | |
|                                         (kUsernamePlainColors.containsKey(
 | |
|                                               usernameColorValue.text,
 | |
|                                             ) ||
 | |
|                                             (usernameColorValue.text.startsWith(
 | |
|                                                   '#',
 | |
|                                                 ) &&
 | |
|                                                 _isValidHexColor(
 | |
|                                                   usernameColorValue.text,
 | |
|                                                 ))),
 | |
|                                   'solian.stellar.nova' =>
 | |
|                                     usernameColorType.value == 'plain',
 | |
|                                   'solian.stellar.supernova' => true,
 | |
|                                   _ => false,
 | |
|                                 };
 | |
| 
 | |
|                                 return Column(
 | |
|                                   crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                                   children: [
 | |
|                                     AccountName(
 | |
|                                       account: previewAccount,
 | |
|                                       style: const TextStyle(fontSize: 18),
 | |
|                                       ignorePermissions: true,
 | |
|                                     ),
 | |
|                                     const Gap(4),
 | |
|                                     Row(
 | |
|                                       children: [
 | |
|                                         Icon(
 | |
|                                           canUseColor
 | |
|                                               ? Symbols.check_circle
 | |
|                                               : Symbols.error,
 | |
|                                           size: 16,
 | |
|                                           color:
 | |
|                                               canUseColor
 | |
|                                                   ? Colors.green
 | |
|                                                   : Colors.red,
 | |
|                                         ),
 | |
|                                         const Gap(4),
 | |
|                                         Text(
 | |
|                                           canUseColor
 | |
|                                               ? 'availableWithYourPlan'.tr()
 | |
|                                               : 'upgradeRequired'.tr(),
 | |
|                                           style: TextStyle(
 | |
|                                             fontSize: 12,
 | |
|                                             color:
 | |
|                                                 canUseColor
 | |
|                                                     ? Colors.green
 | |
|                                                     : Colors.red,
 | |
|                                           ),
 | |
|                                         ),
 | |
|                                       ],
 | |
|                                     ),
 | |
|                                   ],
 | |
|                                 );
 | |
|                               },
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                       ),
 | |
|                       DropdownButtonFormField2<String>(
 | |
|                         decoration: InputDecoration(
 | |
|                           labelText: 'colorType'.tr(),
 | |
|                         ),
 | |
|                         items: [
 | |
|                           DropdownMenuItem(
 | |
|                             value: 'plain',
 | |
|                             child: Text('plain'.tr()),
 | |
|                           ),
 | |
|                           DropdownMenuItem(
 | |
|                             value: 'gradient',
 | |
|                             child: Text('gradient'.tr()),
 | |
|                           ),
 | |
|                         ],
 | |
|                         value: usernameColorType.value,
 | |
|                         onChanged: (value) {
 | |
|                           usernameColorType.value = value ?? 'plain';
 | |
|                         },
 | |
|                         customButton: Row(
 | |
|                           children: [
 | |
|                             Expanded(child: Text(usernameColorType.value).tr()),
 | |
|                             Icon(Symbols.arrow_drop_down),
 | |
|                           ],
 | |
|                         ),
 | |
|                       ),
 | |
|                       if (usernameColorType.value == 'plain')
 | |
|                         Autocomplete<String>(
 | |
|                           optionsBuilder: (TextEditingValue textEditingValue) {
 | |
|                             final options = kUsernamePlainColors.keys.toList();
 | |
|                             if (textEditingValue.text == '') {
 | |
|                               return options;
 | |
|                             }
 | |
|                             return options.where(
 | |
|                               (option) => option.toLowerCase().contains(
 | |
|                                 textEditingValue.text.toLowerCase(),
 | |
|                               ),
 | |
|                             );
 | |
|                           },
 | |
|                           onSelected: (String selection) {
 | |
|                             usernameColorValue.text = selection;
 | |
|                           },
 | |
|                           fieldViewBuilder: (
 | |
|                             context,
 | |
|                             controller,
 | |
|                             focusNode,
 | |
|                             onFieldSubmitted,
 | |
|                           ) {
 | |
|                             // Initialize the controller with the current value
 | |
|                             if (controller.text.isEmpty &&
 | |
|                                 usernameColorValue.text.isNotEmpty) {
 | |
|                               controller.text = usernameColorValue.text;
 | |
|                             }
 | |
| 
 | |
|                             return TextFormField(
 | |
|                               controller: controller,
 | |
|                               focusNode: focusNode,
 | |
|                               decoration: InputDecoration(
 | |
|                                 labelText: 'colorValue'.tr(),
 | |
|                                 hintText: 'e.g. red or #ff6600',
 | |
|                               ),
 | |
|                               onChanged: (value) {
 | |
|                                 usernameColorValue.text = value;
 | |
|                               },
 | |
|                               onTapOutside:
 | |
|                                   (_) =>
 | |
|                                       FocusManager.instance.primaryFocus
 | |
|                                           ?.unfocus(),
 | |
|                             );
 | |
|                           },
 | |
|                         ),
 | |
|                       if (usernameColorType.value == 'gradient') ...[
 | |
|                         DropdownButtonFormField2<String>(
 | |
|                           decoration: InputDecoration(
 | |
|                             labelText: 'gradientDirection'.tr(),
 | |
|                           ),
 | |
|                           items: [
 | |
|                             DropdownMenuItem(
 | |
|                               value: 'to right',
 | |
|                               child: Text('gradientDirectionToRight'.tr()),
 | |
|                             ),
 | |
|                             DropdownMenuItem(
 | |
|                               value: 'to left',
 | |
|                               child: Text('gradientDirectionToLeft'.tr()),
 | |
|                             ),
 | |
|                             DropdownMenuItem(
 | |
|                               value: 'to bottom',
 | |
|                               child: Text('gradientDirectionToBottom'.tr()),
 | |
|                             ),
 | |
|                             DropdownMenuItem(
 | |
|                               value: 'to top',
 | |
|                               child: Text('gradientDirectionToTop'.tr()),
 | |
|                             ),
 | |
|                             DropdownMenuItem(
 | |
|                               value: 'to bottom right',
 | |
|                               child: Text(
 | |
|                                 'gradientDirectionToBottomRight'.tr(),
 | |
|                               ),
 | |
|                             ),
 | |
|                             DropdownMenuItem(
 | |
|                               value: 'to bottom left',
 | |
|                               child: Text('gradientDirectionToBottomLeft'.tr()),
 | |
|                             ),
 | |
|                             DropdownMenuItem(
 | |
|                               value: 'to top right',
 | |
|                               child: Text('gradientDirectionToTopRight'.tr()),
 | |
|                             ),
 | |
|                             DropdownMenuItem(
 | |
|                               value: 'to top left',
 | |
|                               child: Text('gradientDirectionToTopLeft'.tr()),
 | |
|                             ),
 | |
|                           ],
 | |
|                           value:
 | |
|                               usernameColorDirection.text.isNotEmpty
 | |
|                                   ? usernameColorDirection.text
 | |
|                                   : 'to right',
 | |
|                           onChanged: (value) {
 | |
|                             usernameColorDirection.text = value ?? 'to right';
 | |
|                           },
 | |
|                           customButton: Row(
 | |
|                             children: [
 | |
|                               Expanded(
 | |
|                                 child: Text(
 | |
|                                   usernameColorDirection.text.isNotEmpty
 | |
|                                       ? usernameColorDirection.text
 | |
|                                       : 'to right',
 | |
|                                 ),
 | |
|                               ),
 | |
|                               Icon(Symbols.arrow_drop_down),
 | |
|                             ],
 | |
|                           ),
 | |
|                         ),
 | |
|                         Text(
 | |
|                           'gradientColors',
 | |
|                         ).tr().bold().fontSize(14).padding(top: 8),
 | |
|                         Column(
 | |
|                           spacing: 8,
 | |
|                           children: [
 | |
|                             for (
 | |
|                               var i = 0;
 | |
|                               i < usernameColorColors.value.length;
 | |
|                               i++
 | |
|                             )
 | |
|                               Row(
 | |
|                                 key: ValueKey(
 | |
|                                   usernameColorColors.value[i].hashCode,
 | |
|                                 ),
 | |
|                                 crossAxisAlignment: CrossAxisAlignment.end,
 | |
|                                 children: [
 | |
|                                   Expanded(
 | |
|                                     child: TextFormField(
 | |
|                                       initialValue:
 | |
|                                           usernameColorColors.value[i],
 | |
|                                       decoration: InputDecoration(
 | |
|                                         labelText: 'color'.tr(),
 | |
|                                         hintText: 'e.g. #ff0000',
 | |
|                                         isDense: true,
 | |
|                                       ),
 | |
|                                       onChanged: (value) {
 | |
|                                         usernameColorColors.value[i] = value;
 | |
|                                       },
 | |
|                                       onTapOutside:
 | |
|                                           (_) =>
 | |
|                                               FocusManager.instance.primaryFocus
 | |
|                                                   ?.unfocus(),
 | |
|                                     ),
 | |
|                                   ),
 | |
|                                   IconButton(
 | |
|                                     icon: const Icon(Symbols.delete),
 | |
|                                     onPressed: () {
 | |
|                                       usernameColorColors.value =
 | |
|                                           usernameColorColors.value
 | |
|                                               .whereIndexed(
 | |
|                                                 (idx, _) => idx != i,
 | |
|                                               )
 | |
|                                               .toList();
 | |
|                                     },
 | |
|                                   ),
 | |
|                                 ],
 | |
|                               ),
 | |
|                             Align(
 | |
|                               alignment: Alignment.centerRight,
 | |
|                               child: FilledButton.icon(
 | |
|                                 onPressed: () {
 | |
|                                   usernameColorColors.value = List.from(
 | |
|                                     usernameColorColors.value,
 | |
|                                   )..add('');
 | |
|                                 },
 | |
|                                 label: Text('addColor').tr(),
 | |
|                                 icon: const Icon(Symbols.add),
 | |
|                               ).padding(top: 8),
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                       ],
 | |
|                     ],
 | |
|                   ),
 | |
|                   Text('links').tr().bold().fontSize(18).padding(top: 16),
 | |
|                   Column(
 | |
|                     spacing: 8,
 | |
|                     children: [
 | |
|                       for (var i = 0; i < links.value.length; i++)
 | |
|                         Row(
 | |
|                           key: ValueKey(links.value[i].hashCode),
 | |
|                           crossAxisAlignment: CrossAxisAlignment.end,
 | |
|                           children: [
 | |
|                             Expanded(
 | |
|                               child: TextFormField(
 | |
|                                 initialValue: links.value[i].name,
 | |
|                                 decoration: InputDecoration(
 | |
|                                   labelText: 'linkKey'.tr(),
 | |
|                                   isDense: true,
 | |
|                                 ),
 | |
|                                 onChanged: (value) {
 | |
|                                   links.value[i] = links.value[i].copyWith(
 | |
|                                     name: value,
 | |
|                                   );
 | |
|                                 },
 | |
|                                 onTapOutside:
 | |
|                                     (_) =>
 | |
|                                         FocusManager.instance.primaryFocus
 | |
|                                             ?.unfocus(),
 | |
|                               ),
 | |
|                             ),
 | |
|                             const Gap(8),
 | |
|                             Expanded(
 | |
|                               child: TextFormField(
 | |
|                                 initialValue: links.value[i].url,
 | |
|                                 decoration: InputDecoration(
 | |
|                                   labelText: 'linkValue'.tr(),
 | |
|                                   isDense: true,
 | |
|                                 ),
 | |
|                                 onChanged: (value) {
 | |
|                                   links.value[i] = links.value[i].copyWith(
 | |
|                                     url: value,
 | |
|                                   );
 | |
|                                 },
 | |
|                                 onTapOutside:
 | |
|                                     (_) =>
 | |
|                                         FocusManager.instance.primaryFocus
 | |
|                                             ?.unfocus(),
 | |
|                               ),
 | |
|                             ),
 | |
|                             IconButton(
 | |
|                               icon: const Icon(Symbols.delete),
 | |
|                               onPressed: () {
 | |
|                                 links.value =
 | |
|                                     links.value
 | |
|                                         .whereIndexed((idx, _) => idx != i)
 | |
|                                         .toList();
 | |
|                               },
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                       Align(
 | |
|                         alignment: Alignment.centerRight,
 | |
|                         child: FilledButton.icon(
 | |
|                           onPressed: () {
 | |
|                             links.value = List.from(links.value)
 | |
|                               ..add(ProfileLink(name: '', url: ''));
 | |
|                           },
 | |
|                           label: Text('addLink').tr(),
 | |
|                           icon: const Icon(Symbols.add),
 | |
|                         ).padding(top: 8),
 | |
|                       ),
 | |
|                     ],
 | |
|                   ),
 | |
|                   Align(
 | |
|                     alignment: Alignment.centerRight,
 | |
|                     child: TextButton.icon(
 | |
|                       onPressed: submitting.value ? null : updateProfile,
 | |
|                       label: Text('saveChanges').tr(),
 | |
|                       icon: const Icon(Symbols.save),
 | |
|                     ),
 | |
|                   ),
 | |
|                 ],
 | |
|               ).padding(horizontal: 24),
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |