728 lines
26 KiB
Dart
728 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:gwid/api/api_service.dart';
|
||
import 'package:gwid/models/profile.dart';
|
||
import 'package:gwid/screens/phone_entry_screen.dart';
|
||
|
||
class ManageAccountScreen extends StatefulWidget {
|
||
final Profile? myProfile;
|
||
const ManageAccountScreen({super.key, this.myProfile});
|
||
|
||
@override
|
||
State<ManageAccountScreen> createState() => _ManageAccountScreenState();
|
||
}
|
||
|
||
class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||
late final TextEditingController _firstNameController;
|
||
late final TextEditingController _lastNameController;
|
||
late final TextEditingController _descriptionController;
|
||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||
|
||
Profile? _actualProfile;
|
||
bool _isLoading = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_initializeProfileData();
|
||
}
|
||
|
||
Future<void> _initializeProfileData() async {
|
||
// Берём только серверный профиль без локальных оверрайдов
|
||
_actualProfile = widget.myProfile;
|
||
|
||
_firstNameController = TextEditingController(
|
||
text: _actualProfile?.firstName ?? '',
|
||
);
|
||
_lastNameController = TextEditingController(
|
||
text: _actualProfile?.lastName ?? '',
|
||
);
|
||
_descriptionController = TextEditingController(
|
||
text: _actualProfile?.description ?? '',
|
||
);
|
||
}
|
||
|
||
Future<void> _saveProfile() async {
|
||
if (!_formKey.currentState!.validate()) {
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_isLoading = true;
|
||
});
|
||
|
||
try {
|
||
final firstName = _firstNameController.text.trim();
|
||
final lastName = _lastNameController.text.trim();
|
||
final description = _descriptionController.text.trim();
|
||
|
||
// Отправляем изменения сразу на сервер (opcode 16)
|
||
final updatedProfile = await ApiService.instance.updateProfileText(
|
||
firstName,
|
||
lastName,
|
||
description,
|
||
);
|
||
|
||
if (updatedProfile != null) {
|
||
_actualProfile = updatedProfile;
|
||
}
|
||
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text("Профиль обновлен"),
|
||
behavior: SnackBarBehavior.floating,
|
||
duration: Duration(seconds: 2),
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text("Ошибка сохранения: $e"),
|
||
behavior: SnackBarBehavior.floating,
|
||
backgroundColor: Theme.of(context).colorScheme.error,
|
||
),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
void _logout() async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Выйти из аккаунта?'),
|
||
content: const Text('Вы уверены, что хотите выйти из аккаунта?'),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(false),
|
||
child: const Text('Отмена'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(context).pop(true),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: Colors.red.shade400,
|
||
foregroundColor: Colors.white,
|
||
),
|
||
child: const Text('Выйти'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed == true && mounted) {
|
||
try {
|
||
await ApiService.instance.logout();
|
||
if (mounted) {
|
||
Navigator.of(context).pushAndRemoveUntil(
|
||
MaterialPageRoute(builder: (context) => const PhoneEntryScreen()),
|
||
(route) => false,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Ошибка выхода: $e'),
|
||
backgroundColor: Theme.of(context).colorScheme.error,
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _pickAndUpdateProfilePhoto() async {
|
||
try {
|
||
setState(() {
|
||
_isLoading = true;
|
||
});
|
||
|
||
final firstName = _firstNameController.text.trim();
|
||
final lastName = _lastNameController.text.trim();
|
||
|
||
// Полный серверный флоу: opcode 80 (url) + загрузка + opcode 16 (photoToken)
|
||
final updatedProfile =
|
||
await ApiService.instance.updateProfilePhoto(firstName, lastName);
|
||
|
||
if (updatedProfile != null && mounted) {
|
||
setState(() {
|
||
_actualProfile = updatedProfile;
|
||
});
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text("Фотография профиля обновлена"),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text("Ошибка загрузки фото: $e"),
|
||
behavior: SnackBarBehavior.floating,
|
||
backgroundColor: Theme.of(context).colorScheme.error,
|
||
),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text("Изменить профиль"),
|
||
centerTitle: true,
|
||
scrolledUnderElevation: 0,
|
||
actions: [
|
||
TextButton(
|
||
onPressed: _saveProfile,
|
||
child: const Text(
|
||
"Сохранить",
|
||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
body: SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0),
|
||
child: Form(
|
||
key: _formKey,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_buildAvatarSection(theme),
|
||
const SizedBox(height: 32),
|
||
|
||
Card(
|
||
elevation: 2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16.0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"Основная информация",
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
TextFormField(
|
||
controller: _firstNameController,
|
||
maxLength: 60, // Ограничение по символам
|
||
decoration: _buildInputDecoration(
|
||
"Имя",
|
||
Icons.person_outline,
|
||
).copyWith(counterText: ""), // Скрываем счетчик
|
||
validator: (value) =>
|
||
value!.isEmpty ? 'Введите ваше имя' : null,
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextFormField(
|
||
controller: _lastNameController,
|
||
maxLength: 60, // Ограничение по символам
|
||
decoration: _buildInputDecoration(
|
||
"Фамилия",
|
||
Icons.person_outline,
|
||
).copyWith(counterText: ""), // Скрываем счетчик
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
Card(
|
||
elevation: 2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16.0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"Дополнительно",
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
TextFormField(
|
||
controller: _descriptionController,
|
||
maxLines: 4,
|
||
maxLength: 400,
|
||
decoration: _buildInputDecoration(
|
||
"О себе",
|
||
Icons.edit_note_outlined,
|
||
alignLabel: true,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
if (widget.myProfile != null)
|
||
Card(
|
||
elevation: 2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
children: [
|
||
_buildInfoTile(
|
||
icon: Icons.phone_outlined,
|
||
title: "Телефон",
|
||
subtitle: widget.myProfile!.formattedPhone,
|
||
),
|
||
const Divider(height: 1),
|
||
_buildTappableInfoTile(
|
||
icon: Icons.tag,
|
||
title: "Ваш ID",
|
||
subtitle: widget.myProfile!.id.toString(),
|
||
onTap: () {
|
||
Clipboard.setData(
|
||
ClipboardData(
|
||
text: widget.myProfile!.id.toString(),
|
||
),
|
||
);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('ID скопирован в буфер обмена'),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 32),
|
||
_buildLogoutButton(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildAvatarSection(ThemeData theme) {
|
||
ImageProvider? avatarImage;
|
||
|
||
final photoUrl =
|
||
_actualProfile?.photoBaseUrl ?? widget.myProfile?.photoBaseUrl;
|
||
if (photoUrl != null) {
|
||
avatarImage = NetworkImage(photoUrl);
|
||
}
|
||
|
||
return Center(
|
||
child: GestureDetector(
|
||
onTap: _showAvatarOptions,
|
||
child: Stack(
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 60,
|
||
backgroundColor: theme.colorScheme.secondaryContainer,
|
||
backgroundImage: avatarImage,
|
||
child: avatarImage == null
|
||
? Icon(
|
||
Icons.person,
|
||
size: 60,
|
||
color: theme.colorScheme.onSecondaryContainer,
|
||
)
|
||
: null,
|
||
),
|
||
if (_isLoading)
|
||
Positioned.fill(
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.black54,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Center(
|
||
child: CircularProgressIndicator(
|
||
color: Colors.white,
|
||
strokeWidth: 3,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
bottom: 4,
|
||
right: 4,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Padding(
|
||
padding: EdgeInsets.all(8.0),
|
||
child: Icon(Icons.camera_alt, color: Colors.white, size: 20),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showAvatarOptions() {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||
),
|
||
builder: (context) {
|
||
return SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.image_outlined),
|
||
title: const Text('Выбрать из заготовленных аватаров'),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_choosePresetAvatar();
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.photo_library_outlined),
|
||
title: const Text('Загрузить своё фото'),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_pickAndUpdateProfilePhoto();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _choosePresetAvatar() {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||
),
|
||
builder: (context) {
|
||
final theme = Theme.of(context);
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(
|
||
left: 16,
|
||
right: 16,
|
||
top: 12,
|
||
bottom: 16,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Выбор аватара',
|
||
style: theme.textTheme.titleMedium
|
||
?.copyWith(fontWeight: FontWeight.w600),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Выбери картинку из коллекции, потом при желании можно загрузить своё фото.',
|
||
style: theme.textTheme.bodySmall
|
||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||
),
|
||
const SizedBox(height: 16),
|
||
FutureBuilder<Map<String, dynamic>>(
|
||
future: ApiService.instance.fetchPresetAvatars(),
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||
return const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 32),
|
||
child: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
if (snapshot.hasError) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||
child: Center(
|
||
child: Text(
|
||
'Не удалось загрузить аватары: ${snapshot.error}',
|
||
textAlign: TextAlign.center,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.colorScheme.error,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
final data = snapshot.data ?? {};
|
||
final List<dynamic> categories =
|
||
data['presetAvatars'] as List<dynamic>? ?? [];
|
||
|
||
if (categories.isEmpty) {
|
||
return const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 24),
|
||
child: Center(
|
||
child: Text('Список заготовленных аватаров пуст.'),
|
||
),
|
||
);
|
||
}
|
||
|
||
final scrollController = ScrollController();
|
||
|
||
return SizedBox(
|
||
height: MediaQuery.of(context).size.height * 0.6,
|
||
child: Scrollbar(
|
||
controller: scrollController,
|
||
child: ListView.builder(
|
||
controller: scrollController,
|
||
itemCount: categories.length,
|
||
itemBuilder: (context, index) {
|
||
final cat =
|
||
categories[index] as Map<String, dynamic>? ??
|
||
{};
|
||
final String name = cat['name']?.toString() ?? '';
|
||
final List<dynamic> avatars =
|
||
cat['avatars'] as List<dynamic>? ?? [];
|
||
|
||
if (avatars.isEmpty) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (name.isNotEmpty) ...[
|
||
Text(
|
||
name,
|
||
style: theme.textTheme.bodyMedium
|
||
?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
GridView.builder(
|
||
shrinkWrap: true,
|
||
physics:
|
||
const NeverScrollableScrollPhysics(),
|
||
gridDelegate:
|
||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 4,
|
||
mainAxisSpacing: 12,
|
||
crossAxisSpacing: 12,
|
||
),
|
||
itemCount: avatars.length,
|
||
itemBuilder: (context, i) {
|
||
final a = avatars[i]
|
||
as Map<String, dynamic>? ??
|
||
{};
|
||
final String url =
|
||
a['url']?.toString() ?? '';
|
||
final int? photoId = a['id'] as int?;
|
||
|
||
if (url.isEmpty || photoId == null) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
return InkWell(
|
||
borderRadius:
|
||
BorderRadius.circular(999),
|
||
onTap: () async {
|
||
final firstName =
|
||
_firstNameController.text.trim();
|
||
final lastName =
|
||
_lastNameController.text.trim();
|
||
|
||
try {
|
||
setState(() {
|
||
_isLoading = true;
|
||
});
|
||
final updatedProfile =
|
||
await ApiService.instance
|
||
.setPresetAvatar(
|
||
firstName: firstName,
|
||
lastName: lastName,
|
||
photoId: photoId,
|
||
);
|
||
if (!mounted) return;
|
||
|
||
if (updatedProfile != null) {
|
||
setState(() {
|
||
_actualProfile =
|
||
updatedProfile;
|
||
});
|
||
Navigator.of(context).pop();
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
'Аватар обновлён',
|
||
),
|
||
behavior: SnackBarBehavior
|
||
.floating,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
'Ошибка смены аватара: $e',
|
||
),
|
||
behavior: SnackBarBehavior
|
||
.floating,
|
||
backgroundColor: theme
|
||
.colorScheme.error,
|
||
),
|
||
);
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
}
|
||
},
|
||
child: CircleAvatar(
|
||
backgroundImage: NetworkImage(url),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
InputDecoration _buildInputDecoration(
|
||
String label,
|
||
IconData icon, {
|
||
bool alignLabel = false,
|
||
}) {
|
||
final prefixIcon = (label == "О себе")
|
||
? Padding(
|
||
padding: const EdgeInsets.only(bottom: 60), // Смещаем иконку вверх
|
||
child: Icon(icon),
|
||
)
|
||
: Icon(icon);
|
||
|
||
return InputDecoration(
|
||
labelText: label,
|
||
prefixIcon: prefixIcon,
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
alignLabelWithHint: alignLabel,
|
||
);
|
||
}
|
||
|
||
Widget _buildInfoTile({
|
||
required IconData icon,
|
||
required String title,
|
||
required String subtitle,
|
||
}) {
|
||
return ListTile(
|
||
leading: Icon(icon, color: Theme.of(context).colorScheme.primary),
|
||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||
subtitle: Text(subtitle),
|
||
);
|
||
}
|
||
|
||
Widget _buildTappableInfoTile({
|
||
required IconData icon,
|
||
required String title,
|
||
required String subtitle,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
return InkWell(
|
||
onTap: onTap,
|
||
child: ListTile(
|
||
leading: Icon(icon, color: Theme.of(context).colorScheme.primary),
|
||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||
subtitle: Text(subtitle),
|
||
trailing: const Icon(Icons.copy_outlined, size: 20),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLogoutButton() {
|
||
return OutlinedButton.icon(
|
||
icon: const Icon(Icons.logout),
|
||
label: const Text('Выйти из аккаунта'),
|
||
onPressed: _logout,
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: Colors.red.shade400,
|
||
side: BorderSide(color: Colors.red.shade200),
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_firstNameController.dispose();
|
||
_lastNameController.dispose();
|
||
_descriptionController.dispose();
|
||
super.dispose();
|
||
}
|
||
}
|