← Назад к вопросам

Реализовать профиль пользователя с аватаром

2.0 Middle🔥 201 комментариев
#Flutter виджеты#Нативная интеграция#Хранение данных

Условие

Создайте экран профиля пользователя с возможностью выбора аватара.

Требования

  1. Отображение аватара пользователя (круглое изображение)
  2. Возможность выбрать фото из галереи
  3. Возможность сделать фото с камеры
  4. Кроппинг изображения (квадрат)
  5. Сохранение изображения локально
  6. Отображение имени и email пользователя
  7. Возможность редактирования данных

Дополнительные баллы

  • Сжатие изображения перед сохранением
  • Placeholder аватара с инициалами
  • Анимация при смене аватара
  • Загрузка аватара на сервер

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение: Профиль пользователя с аватаром

Полнофункциональный экран профиля с выбором, кроппингом и загрузкой аватара.

Модель пользователя

class User {
  final String id;
  final String name;
  final String email;
  final String? avatarPath;
  final DateTime createdAt;
  final DateTime? updatedAt;

  User({
    required this.id,
    required this.name,
    required this.email,
    this.avatarPath,
    required this.createdAt,
    this.updatedAt,
  });

  User copyWith({
    String? id,
    String? name,
    String? email,
    String? avatarPath,
    DateTime? createdAt,
    DateTime? updatedAt,
  }) {
    return User(
      id: id ?? this.id,
      name: name ?? this.name,
      email: email ?? this.email,
      avatarPath: avatarPath ?? this.avatarPath,
      createdAt: createdAt ?? this.createdAt,
      updatedAt: updatedAt ?? this.updatedAt,
    );
  }
}

Контроллер профиля

class ProfileController extends GetxController {
  final UserRepository _repository = UserRepository();
  final ImageService _imageService = ImageService();

  final user = Rxn<User>();
  final isLoading = false.obs;
  final nameController = TextEditingController();
  final emailController = TextEditingController();

  @override
  void onInit() {
    super.onInit();
    loadUserProfile();
  }

  Future<void> loadUserProfile() async {
    isLoading.value = true;
    try {
      final userData = await _repository.getUserProfile();
      user.value = userData;
      _updateControllers();
    } catch (e) {
      Get.snackbar('Ошибка', 'Не удалось загрузить профиль');
    } finally {
      isLoading.value = false;
    }
  }

  void _updateControllers() {
    if (user.value != null) {
      nameController.text = user.value!.name;
      emailController.text = user.value!.email;
    }
  }

  Future<void> selectAvatarFromGallery() async {
    try {
      final pickedFile = await ImagePicker().pickImage(
        source: ImageSource.gallery,
      );

      if (pickedFile != null) {
        await _cropAndSaveAvatar(pickedFile.path);
      }
    } catch (e) {
      Get.snackbar('Ошибка', 'Не удалось выбрать фото');
    }
  }

  Future<void> selectAvatarFromCamera() async {
    try {
      final pickedFile = await ImagePicker().pickImage(
        source: ImageSource.camera,
      );

      if (pickedFile != null) {
        await _cropAndSaveAvatar(pickedFile.path);
      }
    } catch (e) {
      Get.snackbar('Ошибка', 'Не удалось сделать фото');
    }
  }

  Future<void> _cropAndSaveAvatar(String imagePath) async {
    try {
      final croppedFile = await ImageCropper().cropImage(
        sourcePath: imagePath,
        aspectRatio: CropAspectRatio(ratioX: 1, ratioY: 1),
        uiSettings: [
          AndroidUiSettings(
            toolbarTitle: 'Обрезать аватар',
            toolbarColor: Color(0xFF667EEA),
            toolbarWidgetColor: Colors.white,
            initAspectRatio: CropAspectRatioPreset.square,
            lockAspectRatio: true,
          ),
          IOSUiSettings(
            title: 'Обрезать аватар',
          ),
        ],
      );

      if (croppedFile != null) {
        await _processAndSaveAvatar(croppedFile.path);
      }
    } catch (e) {
      Get.snackbar('Ошибка', 'Не удалось обрезать изображение');
    }
  }

  Future<void> _processAndSaveAvatar(String imagePath) async {
    isLoading.value = true;
    try {
      // Сжимаем изображение
      final compressedPath = await _imageService.compressImage(imagePath);

      // Сохраняем локально
      final savedPath = await _imageService.saveImageLocally(compressedPath);

      // Обновляем пользователя
      final updatedUser = user.value!.copyWith(
        avatarPath: savedPath,
        updatedAt: DateTime.now(),
      );

      // Загружаем на сервер
      await _repository.updateUserProfile(updatedUser);

      user.value = updatedUser;
      Get.snackbar('Успех', 'Аватар обновлён');
    } catch (e) {
      Get.snackbar('Ошибка', 'Не удалось сохранить аватар');
    } finally {
      isLoading.value = false;
    }
  }

  Future<void> updateProfile() async {
    isLoading.value = true;
    try {
      final updatedUser = user.value!.copyWith(
        name: nameController.text,
        email: emailController.text,
        updatedAt: DateTime.now(),
      );

      await _repository.updateUserProfile(updatedUser);
      user.value = updatedUser;
      Get.snackbar('Успех', 'Профиль обновлён');
    } catch (e) {
      Get.snackbar('Ошибка', 'Не удалось обновить профиль');
    } finally {
      isLoading.value = false;
    }
  }

  @override
  void onClose() {
    nameController.dispose();
    emailController.dispose();
    super.onClose();
  }
}

Сервис обработки изображений

class ImageService {
  Future<String> compressImage(String imagePath) async {
    final imageFile = File(imagePath);
    final tempDir = await getTemporaryDirectory();
    final targetPath = '${tempDir.absolute.path}/compressed_${DateTime.now().millisecondsSinceEpoch}.jpg';

    final result = await FlutterImageCompress.compressAndGetFile(
      imagePath,
      targetPath,
      quality: 85,
      format: CompressFormat.jpeg,
    );

    return result?.path ?? imagePath;
  }

  Future<String> saveImageLocally(String imagePath) async {
    final appDir = await getApplicationDocumentsDirectory();
    final fileName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg';
    final savedPath = '${appDir.path}/$fileName';

    await File(imagePath).copy(savedPath);
    return savedPath;
  }
}

Главная страница профиля

class ProfilePage extends StatefulWidget {
  @override
  _ProfilePageState createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage> with TickerProviderStateMixin {
  final ProfileController _controller = Get.put(ProfileController());
  late AnimationController _scaleController;

  @override
  void initState() {
    super.initState();
    _scaleController = AnimationController(
      duration: Duration(milliseconds: 500),
      vsync: this,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Профиль'),
        elevation: 0,
        backgroundColor: Color(0xFF667EEA),
      ),
      body: Obx(() {
        if (_controller.isLoading.value && _controller.user.value == null) {
          return Center(child: CircularProgressIndicator());
        }

        return SingleChildScrollView(
          child: Column(
            children: [
              SizedBox(height: 24),
              _buildAvatarSection(),
              SizedBox(height: 32),
              _buildProfileForm(),
              SizedBox(height: 32),
              _buildActionButtons(),
              SizedBox(height: 24),
            ],
          ),
        );
      }),
    );
  }

  Widget _buildAvatarSection() {
    return Center(
      child: Stack(
        children: [
          Obx(() {
            final avatarPath = _controller.user.value?.avatarPath;
            final name = _controller.user.value?.name ?? 'User';

            return ScaleTransition(
              scale: Tween(begin: 0.8, end: 1.0).animate(
                CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut),
              ),
              child: Container(
                width: 150,
                height: 150,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: Color(0xFF667EEA),
                  boxShadow: [
                    BoxShadow(
                      color: Color(0xFF667EEA).withOpacity(0.3),
                      blurRadius: 8,
                      offset: Offset(0, 4),
                    ),
                  ],
                ),
                child: avatarPath != null && avatarPath.isNotEmpty
                    ? ClipRRect(
                        borderRadius: BorderRadius.circular(75),
                        child: Image.file(
                          File(avatarPath),
                          fit: BoxFit.cover,
                        ),
                      )
                    : _buildAvatarPlaceholder(name),
              ),
            );
          }),
          Positioned(
            bottom: 0,
            right: 0,
            child: GestureDetector(
              onTap: _showAvatarOptions,
              child: Container(
                width: 50,
                height: 50,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: Colors.white,
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.1),
                      blurRadius: 4,
                    ),
                  ],
                ),
                child: Icon(Icons.camera_alt, color: Color(0xFF667EEA)),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildAvatarPlaceholder(String name) {
    final initials = name.split(' ').map((e) => e[0]).join('').toUpperCase();
    return Center(
      child: Text(
        initials.length > 2 ? initials.substring(0, 2) : initials,
        style: TextStyle(
          fontSize: 48,
          fontWeight: FontWeight.bold,
          color: Colors.white,
        ),
      ),
    );
  }

  void _showAvatarOptions() {
    Get.bottomSheet(
      Container(
        color: Colors.white,
        child: Wrap(
          children: [
            ListTile(
              leading: Icon(Icons.photo_library),
              title: Text('Выбрать из галереи'),
              onTap: () {
                Get.back();
                _controller.selectAvatarFromGallery();
              },
            ),
            ListTile(
              leading: Icon(Icons.camera_alt),
              title: Text('Сделать фото'),
              onTap: () {
                Get.back();
                _controller.selectAvatarFromCamera();
              },
            ),
            ListTile(
              leading: Icon(Icons.close),
              title: Text('Закрыть'),
              onTap: () => Get.back(),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildProfileForm() {
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 16),
      child: Column(
        children: [
          TextField(
            controller: _controller.nameController,
            decoration: InputDecoration(
              labelText: 'Имя',
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(8),
              ),
              prefixIcon: Icon(Icons.person),
            ),
          ),
          SizedBox(height: 16),
          TextField(
            controller: _controller.emailController,
            decoration: InputDecoration(
              labelText: 'Email',
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(8),
              ),
              prefixIcon: Icon(Icons.email),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildActionButtons() {
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 16),
      child: Column(
        children: [
          Obx(() {
            return SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: _controller.isLoading.value
                    ? null
                    : () {
                        _scaleController.forward(from: 0.0);
                        _controller.updateProfile();
                      },
                style: ElevatedButton.styleFrom(
                  backgroundColor: Color(0xFF667EEA),
                  padding: EdgeInsets.symmetric(vertical: 14),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(8),
                  ),
                ),
                child: _controller.isLoading.value
                    ? SizedBox(
                        height: 20,
                        width: 20,
                        child: CircularProgressIndicator(
                          valueColor:
                              AlwaysStoppedAnimation(Colors.white),
                        ),
                      )
                    : Text(
                        'Сохранить',
                        style: TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                          color: Colors.white,
                        ),
                      ),
              ),
            );
          }),
          SizedBox(height: 12),
          SizedBox(
            width: double.infinity,
            child: ElevatedButton(
              onPressed: () => Get.back(),
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.grey[400],
                padding: EdgeInsets.symmetric(vertical: 14),
              ),
              child: Text(
                'Назад',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _scaleController.dispose();
    super.dispose();
  }
}

Dependencies в pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  get: ^4.6.5
  image_picker: ^1.0.0
  image_cropper: ^4.0.0
  flutter_image_compress: ^2.1.0
  path_provider: ^2.1.0

Ключевые особенности

  • Выбор фото — из галереи или камеры
  • Кроппинг — квадратное изображение
  • Сжатие — оптимизация размера файла
  • Локальное сохранение — в документах приложения
  • Placeholder — инициалы при отсутствии аватара
  • Анимация — масштабирование при обновлении
  • Редактирование данных — имя и email
  • Загрузка на сервер — синхронизация с backend