добавлены анимации на экране сообщений, добавлено редактирование профиля (локально), изменена панель сообщений

добавлен баг с незагрузкой аватарок в чатах
This commit is contained in:
needle10
2025-11-27 20:06:11 +03:00
parent ad943e0936
commit 9745370613
26 changed files with 3782 additions and 1008 deletions

View File

@@ -0,0 +1,139 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:gwid/services/contact_local_names_service.dart';
class ContactAvatarWidget extends StatefulWidget {
final int contactId;
final String? originalAvatarUrl;
final double radius;
final String? fallbackText;
final Color? backgroundColor;
final Color? textColor;
const ContactAvatarWidget({
super.key,
required this.contactId,
this.originalAvatarUrl,
this.radius = 24,
this.fallbackText,
this.backgroundColor,
this.textColor,
});
@override
State<ContactAvatarWidget> createState() => _ContactAvatarWidgetState();
}
class _ContactAvatarWidgetState extends State<ContactAvatarWidget> {
String? _localAvatarPath;
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_loadLocalAvatar();
_subscription = ContactLocalNamesService().changes.listen((contactId) {
if (contactId == widget.contactId && mounted) {
_loadLocalAvatar();
}
});
}
@override
void didUpdateWidget(ContactAvatarWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.contactId != widget.contactId ||
oldWidget.originalAvatarUrl != widget.originalAvatarUrl) {
_loadLocalAvatar();
}
}
void _loadLocalAvatar() {
final localPath = ContactLocalNamesService().getContactAvatarPath(
widget.contactId,
);
if (localPath != null) {
final file = File(localPath);
if (file.existsSync()) {
setState(() {
_localAvatarPath = localPath;
});
return;
}
}
if (mounted) {
setState(() {
_localAvatarPath = null;
});
}
}
ImageProvider? _getAvatarImage() {
if (_localAvatarPath != null) {
return FileImage(File(_localAvatarPath!));
} else if (widget.originalAvatarUrl != null) {
if (widget.originalAvatarUrl!.startsWith('file://')) {
final path = widget.originalAvatarUrl!.replaceFirst('file://', '');
return FileImage(File(path));
}
return NetworkImage(widget.originalAvatarUrl!);
}
return null;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final avatarImage = _getAvatarImage();
return CircleAvatar(
radius: widget.radius,
backgroundColor:
widget.backgroundColor ?? theme.colorScheme.secondaryContainer,
backgroundImage: avatarImage,
child: avatarImage == null
? Text(
widget.fallbackText ?? '?',
style: TextStyle(
color:
widget.textColor ?? theme.colorScheme.onSecondaryContainer,
fontSize: widget.radius * 0.8,
),
)
: null,
);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
ImageProvider? getContactAvatarImage({
required int contactId,
String? originalAvatarUrl,
}) {
final localPath = ContactLocalNamesService().getContactAvatarPath(contactId);
if (localPath != null) {
final file = File(localPath);
if (file.existsSync()) {
return FileImage(file);
}
}
if (originalAvatarUrl != null) {
if (originalAvatarUrl.startsWith('file://')) {
final path = originalAvatarUrl.replaceFirst('file://', '');
return FileImage(File(path));
}
return NetworkImage(originalAvatarUrl);
}
return null;
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:gwid/screens/edit_contact_screen.dart';
class ContactDisplayName extends StatefulWidget {
final int contactId;
final String? originalFirstName;
final String? originalLastName;
final String? fallbackName;
final TextStyle? style;
final int? maxLines;
final TextOverflow? overflow;
const ContactDisplayName({
super.key,
required this.contactId,
this.originalFirstName,
this.originalLastName,
this.fallbackName,
this.style,
this.maxLines,
this.overflow,
});
@override
State<ContactDisplayName> createState() => _ContactDisplayNameState();
}
class _ContactDisplayNameState extends State<ContactDisplayName> {
String? _localFirstName;
String? _localLastName;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadLocalData();
}
@override
void didUpdateWidget(ContactDisplayName oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.contactId != widget.contactId) {
_loadLocalData();
}
}
Future<void> _loadLocalData() async {
setState(() {
_isLoading = true;
});
final localData = await ContactLocalDataHelper.getContactData(
widget.contactId,
);
if (mounted) {
setState(() {
_localFirstName = localData?['firstName'] as String?;
_localLastName = localData?['lastName'] as String?;
_isLoading = false;
});
}
}
String get _displayName {
final firstName = _localFirstName ?? widget.originalFirstName ?? '';
final lastName = _localLastName ?? widget.originalLastName ?? '';
final fullName = '$firstName $lastName'.trim();
if (fullName.isNotEmpty) {
return fullName;
}
return widget.fallbackName ?? 'ID ${widget.contactId}';
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Text(
widget.fallbackName ?? '...',
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
);
}
return Text(
_displayName,
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
);
}
}

View File

@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gwid/services/contact_local_names_service.dart';
class ContactNameWidget extends StatefulWidget {
final int contactId;
final String? originalName;
final String? originalFirstName;
final String? originalLastName;
final TextStyle? style;
final int? maxLines;
final TextOverflow? overflow;
const ContactNameWidget({
super.key,
required this.contactId,
this.originalName,
this.originalFirstName,
this.originalLastName,
this.style,
this.maxLines,
this.overflow,
});
@override
State<ContactNameWidget> createState() => _ContactNameWidgetState();
}
class _ContactNameWidgetState extends State<ContactNameWidget> {
late String _displayName;
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_updateDisplayName();
_subscription = ContactLocalNamesService().changes.listen((contactId) {
if (contactId == widget.contactId && mounted) {
_updateDisplayName();
}
});
}
@override
void didUpdateWidget(ContactNameWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.contactId != widget.contactId ||
oldWidget.originalName != widget.originalName ||
oldWidget.originalFirstName != widget.originalFirstName ||
oldWidget.originalLastName != widget.originalLastName) {
_updateDisplayName();
}
}
void _updateDisplayName() {
setState(() {
_displayName = ContactLocalNamesService().getDisplayName(
contactId: widget.contactId,
originalName: widget.originalName,
originalFirstName: widget.originalFirstName,
originalLastName: widget.originalLastName,
);
});
}
@override
Widget build(BuildContext context) {
return Text(
_displayName,
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
String getContactDisplayName({
required int contactId,
String? originalName,
String? originalFirstName,
String? originalLastName,
}) {
return ContactLocalNamesService().getDisplayName(
contactId: contactId,
originalName: originalName,
originalFirstName: originalFirstName,
originalLastName: originalLastName,
);
}

View File

@@ -6,6 +6,7 @@ import 'package:gwid/models/contact.dart';
import 'package:gwid/models/profile.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/widgets/chat_message_bubble.dart';
import 'package:gwid/widgets/contact_name_widget.dart';
import 'package:gwid/chat_screen.dart';
class ControlMessageChip extends StatelessWidget {
@@ -26,8 +27,15 @@ class ControlMessageChip extends StatelessWidget {
);
final eventType = controlAttach['event'];
final senderName =
contacts[message.senderId]?.name ?? 'ID ${message.senderId}';
final senderContact = contacts[message.senderId];
final senderName = senderContact != null
? getContactDisplayName(
contactId: senderContact.id,
originalName: senderContact.name,
originalFirstName: senderContact.firstName,
originalLastName: senderContact.lastName,
)
: 'ID ${message.senderId}';
final isMe = message.senderId == myId;
final senderDisplayName = isMe ? 'Вы' : senderName;
@@ -40,7 +48,16 @@ class ControlMessageChip extends StatelessWidget {
if (id == myId) {
return 'Вы';
}
return contacts[id]?.name ?? 'участник с ID $id';
final contact = contacts[id];
if (contact != null) {
return getContactDisplayName(
contactId: contact.id,
originalName: contact.name,
originalFirstName: contact.firstName,
originalLastName: contact.lastName,
);
}
return 'участник с ID $id';
})
.where((name) => name.isNotEmpty)
.join(', ');

View File

@@ -1,5 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gwid/services/avatar_cache_service.dart';
import 'package:gwid/widgets/contact_name_widget.dart';
import 'package:gwid/widgets/contact_avatar_widget.dart';
import 'package:gwid/services/contact_local_names_service.dart';
class UserProfilePanel extends StatefulWidget {
final int userId;
@@ -33,27 +37,62 @@ class UserProfilePanel extends StatefulWidget {
class _UserProfilePanelState extends State<UserProfilePanel> {
final ScrollController _nameScrollController = ScrollController();
String? _localDescription;
StreamSubscription? _changesSubscription;
String get _displayName {
if (widget.firstName != null || widget.lastName != null) {
final firstName = widget.firstName ?? '';
final lastName = widget.lastName ?? '';
final fullName = '$firstName $lastName'.trim();
return fullName.isNotEmpty
? fullName
: (widget.name ?? 'ID ${widget.userId}');
final displayName = getContactDisplayName(
contactId: widget.userId,
originalName: widget.name,
originalFirstName: widget.firstName,
originalLastName: widget.lastName,
);
return displayName;
}
String? get _displayDescription {
if (_localDescription != null && _localDescription!.isNotEmpty) {
return _localDescription;
}
return widget.name ?? 'ID ${widget.userId}';
return widget.description;
}
@override
void initState() {
super.initState();
_loadLocalDescription();
_changesSubscription = ContactLocalNamesService().changes.listen((
contactId,
) {
if (contactId == widget.userId && mounted) {
_loadLocalDescription();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkNameLength();
});
}
Future<void> _loadLocalDescription() async {
final localData = await ContactLocalNamesService().getContactData(
widget.userId,
);
if (mounted) {
setState(() {
_localDescription = localData?['notes'] as String?;
});
}
}
@override
void dispose() {
_changesSubscription?.cancel();
_nameScrollController.dispose();
super.dispose();
}
void _checkNameLength() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_nameScrollController.hasClients) {
@@ -99,12 +138,6 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
});
}
@override
void dispose() {
_nameScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
@@ -130,11 +163,13 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
padding: const EdgeInsets.all(20),
child: Column(
children: [
AvatarCacheService().getAvatarWidget(
widget.avatarUrl,
userId: widget.userId,
size: 80,
fallbackText: _displayName,
ContactAvatarWidget(
contactId: widget.userId,
originalAvatarUrl: widget.avatarUrl,
radius: 40,
fallbackText: _displayName.isNotEmpty
? _displayName[0].toUpperCase()
: '?',
backgroundColor: colors.primaryContainer,
textColor: colors.onPrimaryContainer,
),
@@ -213,11 +248,11 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
),
],
),
if (widget.description != null &&
widget.description!.isNotEmpty) ...[
if (_displayDescription != null &&
_displayDescription!.isNotEmpty) ...[
const SizedBox(height: 24),
Text(
widget.description!,
_displayDescription!,
style: TextStyle(
color: colors.onSurfaceVariant,
fontSize: 14,