511 lines
16 KiB
Dart
511 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'dart:async';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||
import 'package:provider/provider.dart';
|
||
import '/core/constants.dart';
|
||
import 'dart:io';
|
||
|
||
class UserProfileScreen extends StatefulWidget {
|
||
final int userId;
|
||
final String username;
|
||
final String name;
|
||
final VoidCallback? onClose;
|
||
|
||
const UserProfileScreen({
|
||
super.key,
|
||
required this.userId,
|
||
required this.username,
|
||
required this.name,
|
||
this.onClose,
|
||
});
|
||
|
||
@override
|
||
State<UserProfileScreen> createState() => _UserProfileScreenState();
|
||
}
|
||
|
||
class _UserProfileScreenState extends State<UserProfileScreen> {
|
||
Map<String, dynamic>? _userData;
|
||
StreamSubscription<dynamic>? _socketSubscription;
|
||
bool _isLoading = true;
|
||
String? _error;
|
||
Duration? offset;
|
||
Timer? _onlineTimer;
|
||
String? firstName;
|
||
String? lastName;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadUserData();
|
||
startOnlineUpdates();
|
||
DateTime now = DateTime.now();
|
||
offset = now.timeZoneOffset;
|
||
|
||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
|
||
}
|
||
|
||
void startOnlineUpdates() {
|
||
_onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||
_loadUserData();
|
||
});
|
||
}
|
||
|
||
Future<void> _loadUserData() async {
|
||
try {
|
||
final api = ApiService();
|
||
final data = await api.getUserById(widget.userId);
|
||
final prefs = await SharedPreferences.getInstance();
|
||
firstName = prefs.getString('firstname_${widget.userId}');
|
||
lastName = prefs.getString('lastname_${widget.userId}');
|
||
if (mounted) {
|
||
setState(() {
|
||
_userData = data;
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_error = e.toString().contains('SocketFailed')
|
||
? 'Соединение разорвано'
|
||
: e.toString().replaceAll('Exception: ', '');
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_onlineTimer?.cancel();
|
||
_socketSubscription?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
String _formatLastSeen(String? lastSeenStr) {
|
||
if (lastSeenStr == null) return 'Был(а) недавно';
|
||
final lastSeen = DateTime.tryParse(lastSeenStr);
|
||
if (lastSeen == null) return 'Был(а) недавно';
|
||
|
||
// Применяем локальный офсет часового пояса, если необходимо
|
||
final localLastSeen = offset != null ? lastSeen.add(offset!) : lastSeen;
|
||
final now = DateTime.now();
|
||
final difference = now.difference(localLastSeen);
|
||
|
||
if (difference.inMinutes < 1) {
|
||
return 'Был(а) только что';
|
||
} else if (difference.inMinutes < 60) {
|
||
return 'Был(а) ${difference.inMinutes} ${_pluralize(difference.inMinutes, "минуту", "минуты", "минут")} назад';
|
||
} else if (difference.inHours < 24) {
|
||
return 'Был(а) ${difference.inHours} ${_pluralize(difference.inHours, "час", "часа", "часов")} назад';
|
||
} else if (difference.inDays < 7) {
|
||
return 'Был(а) ${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
|
||
} else if (difference.inDays < 30) {
|
||
final weeks = (difference.inDays / 7).floor();
|
||
return 'Был(а) $weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
|
||
} else {
|
||
return 'Был(а) давно';
|
||
}
|
||
}
|
||
|
||
String _pluralize(int count, String form1, String form2, String form5) {
|
||
final mod10 = count % 10;
|
||
final mod100 = count % 100;
|
||
if (mod10 == 1 && mod100 != 11) {
|
||
return form1;
|
||
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
|
||
return form2;
|
||
} else {
|
||
return form5;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
return Scaffold(
|
||
backgroundColor: colorScheme.background,
|
||
body: SafeArea(
|
||
child: Stack(
|
||
children: [
|
||
// Основное содержимое экрана
|
||
_buildMainContent(colorScheme),
|
||
|
||
if (Platform.isWindows) ...[
|
||
Positioned(
|
||
top: 12,
|
||
right: 16,
|
||
child: ClipOval(
|
||
child: Material(
|
||
child: IconButton(
|
||
icon: const Icon(Icons.close_rounded),
|
||
color: colorScheme.onSurfaceVariant,
|
||
onPressed: () {
|
||
if (widget.onClose != null) {
|
||
widget.onClose!();
|
||
} else if (Navigator.canPop(context)) {
|
||
Navigator.pop(context);
|
||
}
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
] else if (Platform.isAndroid) ...[
|
||
Positioned(
|
||
top: 12,
|
||
left: 16,
|
||
child: ClipOval(
|
||
child: Material(
|
||
child: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
color: colorScheme.onSurfaceVariant,
|
||
onPressed: () {
|
||
if (widget.onClose != null) {
|
||
widget.onClose!();
|
||
} else if (Navigator.canPop(context)) {
|
||
Navigator.pop(context);
|
||
}
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMainContent(ColorScheme colorScheme) {
|
||
if (_isLoading) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (_error != null) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24.0),
|
||
child: Text(
|
||
_error!,
|
||
style: TextStyle(
|
||
color: colorScheme.error,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return _buildUserInfo();
|
||
}
|
||
|
||
Widget _buildUserInfo() {
|
||
if (_userData == null) return const SizedBox.shrink();
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
|
||
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
|
||
final String combinedName = '$displayFN $displayLN'.trim();
|
||
final String username = _userData?['username'] ?? '';
|
||
final rawAvatarUrl = _userData?['avatar_url']?.toString();
|
||
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
|
||
? '${AppConstants.baseUrl}$rawAvatarUrl'
|
||
: rawAvatarUrl;
|
||
|
||
return ListView(
|
||
physics: const BouncingScrollPhysics(),
|
||
padding: const EdgeInsets.only(top: 44, bottom: 24),
|
||
children: [
|
||
Center(
|
||
child: Container(
|
||
width: 110,
|
||
height: 110,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: colorScheme.primaryContainer.withOpacity(0.5),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.08),
|
||
blurRadius: 16,
|
||
offset: const Offset(0, 8),
|
||
),
|
||
],
|
||
image: (avatarUrl != null && _userData?['show_avatar'] == true)
|
||
? DecorationImage(
|
||
image: NetworkImage(avatarUrl),
|
||
fit: BoxFit.cover,
|
||
)
|
||
: null,
|
||
),
|
||
child: (avatarUrl == null || _userData?['show_avatar'] != true)
|
||
? Center(
|
||
child: Text(
|
||
combinedName.isNotEmpty
|
||
? combinedName[0].toUpperCase()
|
||
: '?',
|
||
style: TextStyle(
|
||
fontSize: 36,
|
||
fontWeight: FontWeight.bold,
|
||
color: colorScheme.primary,
|
||
),
|
||
),
|
||
)
|
||
: null,
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
Center(
|
||
child: InkWell(
|
||
onTap: () => _editUserName(displayFN, displayLN),
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Flexible(
|
||
child: Text(
|
||
combinedName.isNotEmpty ? combinedName : 'Без имени',
|
||
style: const TextStyle(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Icon(
|
||
Icons.edit_rounded,
|
||
size: 16,
|
||
color: colorScheme.outline,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (username.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(
|
||
'@$username',
|
||
style: TextStyle(
|
||
color: colorScheme.primary,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 6),
|
||
_buildOnlineStatus(),
|
||
const SizedBox(height: 24),
|
||
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||
borderRadius: BorderRadius.circular(24),
|
||
border: Border.all(
|
||
color: colorScheme.outlineVariant.withOpacity(0.1),
|
||
),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
_buildInfoRow(
|
||
Icons.fingerprint_rounded,
|
||
_userData!['id'].toString(),
|
||
'ID пользователя',
|
||
true,
|
||
),
|
||
if (_userData!['about'] != null &&
|
||
_userData!['about'].toString().isNotEmpty)
|
||
_buildInfoRow(
|
||
Icons.info_outline_rounded,
|
||
_userData!['about'],
|
||
'О себе',
|
||
true,
|
||
),
|
||
if (_userData!['phone'] != null &&
|
||
_userData!['phone'].toString().isNotEmpty)
|
||
_buildInfoRow(
|
||
Icons.phone_android_rounded,
|
||
_userData!['phone'],
|
||
'Номер телефона',
|
||
true,
|
||
),
|
||
if (_userData!['email'] != null &&
|
||
_userData!['email'].toString().isNotEmpty)
|
||
_buildInfoRow(
|
||
Icons.mail_outline_rounded,
|
||
_userData!['email'],
|
||
'Электронная почта',
|
||
true,
|
||
),
|
||
_buildInfoRow(
|
||
Icons.key_rounded,
|
||
_userData!['public_key'] ?? 'Отсутствует',
|
||
'Публичный E2EE ключ',
|
||
false,
|
||
maxLines: 2,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildOnlineStatus() {
|
||
if (_userData?['online'] == true) {
|
||
return const Text(
|
||
'В сети',
|
||
style: TextStyle(
|
||
color: Colors.green,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
);
|
||
}
|
||
|
||
// Получаем строку последнего онлайна из данных сервера
|
||
final String? lastSeenStr = _userData?['last_online']?.toString();
|
||
return Text(
|
||
_formatLastSeen(lastSeenStr),
|
||
style: TextStyle(
|
||
color: Theme.of(context).colorScheme.outline,
|
||
fontSize: 13,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
);
|
||
}
|
||
|
||
Widget _buildInfoRow(
|
||
IconData icon,
|
||
String value,
|
||
String label,
|
||
bool showDivider, {
|
||
int maxLines = 1,
|
||
}) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
return Column(
|
||
children: [
|
||
ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 20,
|
||
vertical: 2,
|
||
),
|
||
leading: Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.primary.withOpacity(0.06),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(icon, color: colorScheme.primary, size: 18),
|
||
),
|
||
title: Text(
|
||
value,
|
||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||
maxLines: maxLines,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
subtitle: Text(
|
||
label,
|
||
style: TextStyle(fontSize: 12, color: colorScheme.outline),
|
||
),
|
||
),
|
||
if (showDivider)
|
||
Divider(
|
||
height: 1,
|
||
indent: 68,
|
||
color: colorScheme.outlineVariant.withOpacity(0.15),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _editUserName(String firstname, String lastname) async {
|
||
final firstnameController = TextEditingController(text: firstname);
|
||
final lastnameController = TextEditingController(text: lastname);
|
||
|
||
final result = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||
title: const Text('Задать локальное имя'),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextField(
|
||
controller: firstnameController,
|
||
decoration: const InputDecoration(labelText: 'Имя'),
|
||
textCapitalization: TextCapitalization.words,
|
||
),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: lastnameController,
|
||
decoration: const InputDecoration(labelText: 'Фамилия'),
|
||
textCapitalization: TextCapitalization.words,
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: const Text('Сбросить'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Сохранить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
final prefs = await SharedPreferences.getInstance();
|
||
if (result == true) {
|
||
await prefs.setString(
|
||
'firstname_${widget.userId}',
|
||
firstnameController.text.trim(),
|
||
);
|
||
await prefs.setString(
|
||
'lastname_${widget.userId}',
|
||
lastnameController.text.trim(),
|
||
);
|
||
} else if (result == false) {
|
||
await prefs.remove('firstname_${widget.userId}');
|
||
await prefs.remove('lastname_${widget.userId}');
|
||
}
|
||
_loadUserData();
|
||
}
|
||
|
||
void _handleIncomingMessage(Map<String, dynamic> data) {
|
||
if (data['type'] == 'user_online' && data['user_id'] == widget.userId) {
|
||
if (mounted) setState(() => _userData?['online'] = true);
|
||
}
|
||
if (data['type'] == 'user_offline' && data['user_id'] == widget.userId) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_userData?['online'] = false;
|
||
_userData?['last_online'] = DateTime.now().toIso8601String();
|
||
});
|
||
}
|
||
}
|
||
if (data['type'] == 'user_updated' && data['user_id'] == widget.userId) {
|
||
_loadUserData();
|
||
}
|
||
}
|
||
}
|