edit_user_profile_page.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import 'dart:convert';
  2. import 'dart:io';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:flutter_screenutil/flutter_screenutil.dart';
  6. import 'package:go_router/go_router.dart';
  7. import 'package:image/image.dart' as img;
  8. import 'package:image_picker/image_picker.dart';
  9. import 'package:news_app/constant/color_res.dart';
  10. import 'package:news_app/model/user_model.dart';
  11. import 'package:news_app/util/log.util.dart';
  12. import 'package:news_app/util/toast_util.dart';
  13. import 'package:news_app/widget/my_txt.dart';
  14. import 'package:path_provider/path_provider.dart';
  15. import '../../main.dart';
  16. import '../../util/permission_util.dart';
  17. import '../../widget/load_image.dart';
  18. /// @author: bo.zeng
  19. /// @email: cnhbwds@gmail.com
  20. /// @date: 2025 2025/4/9 16:00
  21. /// @description:
  22. class EditProfilePage extends ConsumerStatefulWidget {
  23. const EditProfilePage({super.key});
  24. @override
  25. ConsumerState<EditProfilePage> createState() => _EditProfilePageState();
  26. }
  27. class _EditProfilePageState extends ConsumerState<EditProfilePage> {
  28. final TextEditingController _nicknameController = TextEditingController();
  29. final TextEditingController _descriptionController = TextEditingController();
  30. @override
  31. void dispose() {
  32. _nicknameController.dispose();
  33. _descriptionController.dispose();
  34. super.dispose();
  35. }
  36. // 从相册选择图片
  37. Future<void> _pickImageFromGallery() async {
  38. final XFile? pickedFile = await ImagePicker().pickImage(
  39. source: ImageSource.gallery,
  40. imageQuality: 80, // 原生压缩(可选)
  41. );
  42. consoleLog(pickedFile);
  43. if (pickedFile != null) {
  44. _uploadToServer(pickedFile);
  45. }
  46. }
  47. // 从相机拍照
  48. Future<void> _pickImageFromCamera() async {
  49. final XFile? pickedFile = await ImagePicker().pickImage(
  50. source: ImageSource.camera,
  51. imageQuality: 80, // 原生压缩(可选)
  52. );
  53. consoleLog(pickedFile);
  54. if (pickedFile != null) {
  55. _uploadToServer(pickedFile);
  56. }
  57. }
  58. Future<void> saveToFile(String text) async {
  59. final dir = await getTemporaryDirectory();
  60. final file = File('${dir.path}/base64_output.txt');
  61. await file.writeAsString(text);
  62. consoleLog('已保存到: ${file.path}');
  63. }
  64. // 示例上传方法
  65. void _uploadToServer(XFile? pickedFile) async {
  66. if (pickedFile != null) {
  67. final String? base64Image = await pickCompressAndConvertToBase64(
  68. pickedFile,
  69. );
  70. // saveToFile(base64Image ?? "");
  71. if (base64Image != null) {
  72. bool result = await ref
  73. .read(globalUserProvider.notifier)
  74. .fetchUserAvatarInfo(base64Image: base64Image);
  75. if (!mounted) return;
  76. if (result) {
  77. context.pop();
  78. }
  79. }
  80. } else {
  81. consoleLog("获取相册错误");
  82. }
  83. }
  84. // 2. 压缩图片
  85. Future<File?> compressImage(
  86. XFile file, {
  87. int maxWidth = 200,
  88. int quality = 80,
  89. }) async {
  90. try {
  91. // 读取原始图片数据
  92. final originalBytes = await file.readAsBytes();
  93. // 解码图片
  94. final originalImage = img.decodeImage(originalBytes);
  95. if (originalImage == null) return null;
  96. // 计算缩放比例
  97. int width = originalImage.width;
  98. int height = originalImage.height;
  99. if (width > maxWidth) {
  100. height = (height * maxWidth / width).toInt();
  101. width = maxWidth;
  102. }
  103. // 缩放图片
  104. final resizedImage = img.copyResize(
  105. originalImage,
  106. width: width,
  107. height: height,
  108. );
  109. // 获取临时目录
  110. final tempDir = await getTemporaryDirectory();
  111. final compressedFile = File(
  112. '${tempDir.path}/compressed_${DateTime.now().millisecondsSinceEpoch}.jpg',
  113. );
  114. // 保存压缩后的图片
  115. await compressedFile.writeAsBytes(
  116. img.encodeJpg(resizedImage, quality: quality),
  117. );
  118. return compressedFile;
  119. } catch (e) {
  120. debugPrint('图片压缩错误: $e');
  121. return null;
  122. }
  123. }
  124. Future<String?> convertImageToBase64WithPrefix(File imageFile) async {
  125. try {
  126. // 1. 读取图片文件
  127. final bytes = await imageFile.readAsBytes();
  128. // 2. 获取图片格式(根据文件扩展名判断)
  129. final format = _getImageFormat(imageFile.path);
  130. if (format == null) return null;
  131. // 3. 编码为Base64
  132. final base64Str = base64Encode(bytes);
  133. // 4. 添加数据URI前缀
  134. return base64Str;
  135. // return 'data:image/$format;base64,$base64Str';
  136. } catch (e) {
  137. consoleLog('转换失败: $e');
  138. return null;
  139. }
  140. }
  141. /// 获取图片格式
  142. String? _getImageFormat(String filePath) {
  143. final extension = filePath.split('.').last.toLowerCase();
  144. switch (extension) {
  145. case 'png':
  146. return 'png';
  147. case 'jpg':
  148. case 'jpeg':
  149. return 'jpeg';
  150. case 'gif':
  151. return 'gif';
  152. case 'webp':
  153. return 'webp';
  154. default:
  155. return null;
  156. }
  157. }
  158. // 完整流程:选择图片 -> 压缩 -> 转Base64
  159. Future<String?> pickCompressAndConvertToBase64(XFile pickedFile) async {
  160. try {
  161. // 2. 压缩图片
  162. final File? compressedFile = await compressImage(pickedFile);
  163. if (compressedFile == null) return null;
  164. // 3. 转换为Base64
  165. final String? base64String = await convertImageToBase64WithPrefix(
  166. compressedFile,
  167. );
  168. // 删除临时文件
  169. await compressedFile.delete();
  170. return base64String;
  171. } catch (e) {
  172. debugPrint('完整流程错误: $e');
  173. return null;
  174. }
  175. }
  176. void saveAction(BuildContext context, WidgetRef ref) async {
  177. if (_nicknameController.text.isEmpty) {
  178. showToast("昵称不能为空");
  179. return;
  180. }
  181. if (_descriptionController.text.isEmpty) {
  182. showToast("描述不能为空");
  183. return;
  184. }
  185. bool result = await ref
  186. .read(globalUserProvider.notifier)
  187. .fetchUpdateUserInfo(
  188. nickname: _nicknameController.text,
  189. description: _descriptionController.text,
  190. );
  191. if (result && context.mounted) {
  192. context.pop();
  193. }
  194. }
  195. void _changeAvatar(BuildContext context) {
  196. showDialog(
  197. useRootNavigator: true,
  198. barrierDismissible: true, // 点击背景可关闭
  199. context: context,
  200. builder: (context) {
  201. return SimpleDialog(
  202. title: myTxt(text: "请选择头像来源"),
  203. children: [
  204. SimpleDialogOption(
  205. onPressed: () {
  206. Navigator.pop(context);
  207. _pickImageFromCamera();
  208. },
  209. child: Padding(
  210. padding: const EdgeInsets.symmetric(vertical: 6),
  211. child: const Text('拍照'),
  212. ),
  213. ),
  214. SimpleDialogOption(
  215. onPressed: () {
  216. Navigator.pop(context);
  217. _pickImageFromGallery();
  218. },
  219. child: Padding(
  220. padding: const EdgeInsets.symmetric(vertical: 6),
  221. child: const Text('从相册选择'),
  222. ),
  223. ),
  224. ],
  225. );
  226. },
  227. );
  228. }
  229. @override
  230. Widget build(BuildContext context) {
  231. UserModel user = ref.watch(globalUserProvider);
  232. _nicknameController.text = user.nickName ?? "";
  233. _descriptionController.text = user.description ?? "";
  234. return Scaffold(
  235. resizeToAvoidBottomInset: false,
  236. backgroundColor: Colors.white,
  237. appBar: AppBar(
  238. title: myTxt(
  239. text: '个人资料',
  240. fontSize: 18.sp,
  241. fontWeight: FontWeight.bold,
  242. ),
  243. actions: [
  244. TextButton(
  245. child: Text(
  246. '保存',
  247. style: TextStyle(color: Colors.black, fontSize: 16),
  248. ),
  249. onPressed: () {
  250. saveAction(context, ref);
  251. },
  252. ),
  253. ],
  254. centerTitle: true,
  255. ),
  256. body: Container(
  257. padding: EdgeInsets.all(20.w),
  258. child: Column(
  259. crossAxisAlignment: CrossAxisAlignment.center,
  260. children: [
  261. // Avatar section
  262. GestureDetector(
  263. onTap: () async {
  264. if (Platform.isIOS) {
  265. _changeAvatar(context);
  266. } else {
  267. // Handle avatar change
  268. bool granted =
  269. await PermissionManager.requestCameraPermission();
  270. if (granted) {
  271. // 执行拍照逻辑
  272. if (context.mounted) _changeAvatar(context);
  273. } else {
  274. // 提示用户权限被拒绝
  275. showToast("需要开启存储权限");
  276. }
  277. }
  278. },
  279. child: Column(
  280. children: [
  281. Container(
  282. width: 80.w,
  283. height: 80.w,
  284. decoration: BoxDecoration(
  285. border: Border.all(color: Colors.white, width: 2.w),
  286. borderRadius: BorderRadius.circular(33.r),
  287. ),
  288. child: ClipOval(
  289. child: LoadImage(
  290. user.avatar ?? '',
  291. width: 80.w,
  292. height: 80.w,
  293. holderImg: "user_avatar",
  294. ),
  295. ),
  296. ),
  297. const SizedBox(height: 8),
  298. Text(
  299. '点击更换头像',
  300. style: Theme.of(
  301. context,
  302. ).textTheme.bodySmall?.copyWith(color: color666666),
  303. ),
  304. ],
  305. ),
  306. ),
  307. SizedBox(height: 24.h),
  308. // Nickname section
  309. ProfileSection(
  310. title: '昵称',
  311. content: user.nickName ?? "",
  312. maxLines: 1,
  313. controller: _nicknameController,
  314. ),
  315. SizedBox(height: 24.h),
  316. // Bio section
  317. ProfileSection(
  318. title: '个人简介',
  319. content: user.description ?? "",
  320. maxLines: 5,
  321. controller: _descriptionController,
  322. ),
  323. ],
  324. ),
  325. ),
  326. );
  327. }
  328. }
  329. class ProfileSection extends StatelessWidget {
  330. final String title;
  331. final String content;
  332. final int maxLines;
  333. final TextEditingController controller;
  334. const ProfileSection({
  335. super.key,
  336. required this.title,
  337. required this.content,
  338. required this.maxLines,
  339. required this.controller,
  340. });
  341. @override
  342. Widget build(BuildContext context) {
  343. return Column(
  344. crossAxisAlignment: CrossAxisAlignment.start,
  345. children: [
  346. Text(
  347. title,
  348. style: Theme.of(
  349. context,
  350. ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
  351. ),
  352. const SizedBox(height: 8),
  353. TextField(
  354. maxLines: maxLines,
  355. controller: controller,
  356. decoration: InputDecoration(
  357. border: InputBorder.none,
  358. filled: true,
  359. fillColor: Colors.grey[200],
  360. hintText: content,
  361. hintStyle: Theme.of(
  362. context,
  363. ).textTheme.bodyMedium?.copyWith(color: Colors.grey),
  364. ),
  365. ),
  366. ],
  367. );
  368. }
  369. }