diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index aed44e5..d59f5e2 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -62,6 +62,15 @@ class PhoneLookupResult { const PhoneLookupResult({required this.id, this.name, this.avatarUrl}); } +class ContactPhotos { + final List urls; + final int total; + + const ContactPhotos({required this.urls, required this.total}); + + static const empty = ContactPhotos(urls: [], total: 0); +} + class ContactsModule { static final ValueNotifier revision = ValueNotifier(0); @@ -194,6 +203,26 @@ class ContactsModule { } } + static Future fetchPhotos( + Api api, + int contactId, { + int from = 0, + int count = 25, + }) async { + final map = await api.sendRequestMap(Opcode.contactPhotos, { + 'contactId': contactId, + 'from': from, + 'count': count, + }); + if (map == null) return ContactPhotos.empty; + final rawUrls = map['urls']; + final urls = rawUrls is List + ? rawUrls.whereType().toList() + : []; + final total = map['total'] is int ? map['total'] as int : urls.length; + return ContactPhotos(urls: urls, total: total); + } + static Future> getContacts(int accountId) async { final rows = await AppDatabase.loadContacts(accountId); return rows.map(CachedContact.fromDbRow).toList(); diff --git a/lib/core/utils/media_saver.dart b/lib/core/utils/media_saver.dart new file mode 100644 index 0000000..39968a2 --- /dev/null +++ b/lib/core/utils/media_saver.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:photo_manager/photo_manager.dart'; + +import 'media_cache.dart'; + +class MediaSaveResult { + final bool ok; + final bool toGallery; + final String? location; + final String? error; + + const MediaSaveResult({ + required this.ok, + this.toGallery = false, + this.location, + this.error, + }); +} + +Future saveImageFromUrl(String url) async { + if (url.isEmpty) { + return const MediaSaveResult(ok: false, error: 'нет ссылки'); + } + try { + final cacheName = 'avatar_${url.hashCode & 0x7fffffff}.jpg'; + final file = await MediaCache.getOrDownload(cacheName, url); + if (file == null) { + return const MediaSaveResult(ok: false, error: 'не удалось загрузить'); + } + final saveName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg'; + + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { + final state = await PhotoManager.requestPermissionExtend(); + if (!state.isAuth && !state.hasAccess) { + return const MediaSaveResult(ok: false, error: 'нет доступа к галерее'); + } + final bytes = await file.readAsBytes(); + await PhotoManager.editor.saveImage(bytes, filename: saveName); + return const MediaSaveResult(ok: true, toGallery: true); + } + + final dir = await _targetDirectory(); + final target = File('${dir.path}${Platform.pathSeparator}$saveName'); + await file.copy(target.path); + return MediaSaveResult(ok: true, location: target.path); + } catch (e) { + return MediaSaveResult(ok: false, error: e.toString()); + } +} + +Future _targetDirectory() async { + try { + final downloads = await getDownloadsDirectory(); + if (downloads != null) return downloads; + } catch (_) {} + return getApplicationDocumentsDirectory(); +} diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 7627c01..94eaea4 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -10,6 +10,7 @@ import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/chat_info.dart'; import '../../../models/contact_info.dart'; +import '../../widgets/avatar_history_screen.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; @@ -1130,12 +1131,23 @@ class _ChatInfoScreenState extends State { } Widget _avatar() { - return KometAvatar( + final avatar = KometAvatar( name: widget.name, imageUrl: widget.imageUrl, size: 96, fontSize: 36, ); + final peerId = widget.chatType == 'DIALOG' ? _otherId : null; + if (peerId == null || widget.imageUrl.isEmpty) return avatar; + return GestureDetector( + onTap: () => AvatarHistoryScreen.open( + context, + contactId: peerId, + name: widget.name, + currentAvatarUrl: widget.imageUrl, + ), + child: avatar, + ); } Widget _buildShimmer(ColorScheme cs) { diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index 67001da..609397c 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -7,6 +7,7 @@ import '../../../core/storage/token_storage.dart'; import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/contact_info.dart'; +import '../../widgets/avatar_history_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; @@ -146,11 +147,19 @@ class _ContactProfileScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ - KometAvatar( - name: _displayName(), - imageUrl: _avatarUrl(), - size: 96, - fontSize: 36, + GestureDetector( + onTap: () => AvatarHistoryScreen.open( + context, + contactId: widget.contactId, + name: _displayName(), + currentAvatarUrl: _avatarUrl(), + ), + child: KometAvatar( + name: _displayName(), + imageUrl: _avatarUrl(), + size: 96, + fontSize: 36, + ), ), const SizedBox(height: 14), _buildNameRow(cs), @@ -206,7 +215,11 @@ class _ContactProfileScreenState extends State { label: l10n.contactProfileActionChat, onTap: _openChat, ), - (icon: Symbols.notifications, label: l10n.contactProfileActionSound, onTap: null), + ( + icon: Symbols.notifications, + label: l10n.contactProfileActionSound, + onTap: null, + ), if (!_isBot) (icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null), ]; @@ -250,17 +263,23 @@ class _ContactProfileScreenState extends State { final phoneStr = formatPhone(c.raw['phone']); if (phoneStr != null) { - rows.add(_infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr)); + rows.add( + _infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr), + ); } final country = c.raw['country'] as String?; if (country != null && country.isNotEmpty) { - rows.add(_infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country)); + rows.add( + _infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country), + ); } final genderStr = formatGender(c.raw['gender']); if (genderStr != null) { - rows.add(_infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr)); + rows.add( + _infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr), + ); } final regTime = c.raw['registrationTime'] as int?; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 24c3448..5691271 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -11,6 +11,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../../widgets/avatar_history_screen.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/info_action_sheet.dart'; import '../../widgets/komet_avatar.dart'; @@ -557,21 +558,29 @@ class _SettingsTabState extends State { ], ), const SizedBox(height: 8), - Container( - width: 88, - height: 88, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: cs.primary.withValues(alpha: 0.5), - width: 2.5, - ), - ), - child: KometAvatar( + GestureDetector( + onTap: () => AvatarHistoryScreen.open( + context, + contactId: _profile?.id ?? 0, name: name, - imageUrl: _profile?.baseUrl, - size: 88, - fontSize: 32, + currentAvatarUrl: _profile?.baseUrl, + ), + child: Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: KometAvatar( + name: name, + imageUrl: _profile?.baseUrl, + size: 88, + fontSize: 32, + ), ), ), const SizedBox(height: 8), diff --git a/lib/frontend/widgets/avatar_history_screen.dart b/lib/frontend/widgets/avatar_history_screen.dart new file mode 100644 index 0000000..1f57b89 --- /dev/null +++ b/lib/frontend/widgets/avatar_history_screen.dart @@ -0,0 +1,350 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../backend/modules/contacts.dart'; +import '../../core/utils/media_saver.dart'; +import '../../main.dart'; +import 'custom_notification.dart'; + +class AvatarHistoryScreen extends StatefulWidget { + final int contactId; + final String? name; + final String? currentAvatarUrl; + + const AvatarHistoryScreen({ + super.key, + required this.contactId, + this.name, + this.currentAvatarUrl, + }); + + static Future open( + BuildContext context, { + required int contactId, + String? name, + String? currentAvatarUrl, + }) { + final url = currentAvatarUrl; + if (url == null || url.isEmpty) return Future.value(); + return Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AvatarHistoryScreen( + contactId: contactId, + name: name, + currentAvatarUrl: url, + ), + ), + ); + } + + @override + State createState() => _AvatarHistoryScreenState(); +} + +class _AvatarHistoryScreenState extends State { + static const int _pageSize = 50; + static const int _maxDots = 10; + + final PageController _pageController = PageController(); + String? _current; + final List _history = []; + List _pages = const []; + int _historyTotal = 0; + int _index = 0; + bool _loading = true; + bool _loadingMore = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + final current = widget.currentAvatarUrl; + _current = (current != null && current.isNotEmpty) ? current : null; + _rebuildPages(); + _load(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _rebuildPages() { + _pages = _current == null ? List.of(_history) : [_current!, ..._history]; + } + + void _addHistory(List urls) { + for (final url in urls) { + if (url == _current || _history.contains(url)) continue; + _history.add(url); + } + } + + Future _load() async { + final photos = await ContactsModule.fetchPhotos( + api, + widget.contactId, + count: _pageSize, + ); + if (!mounted) return; + setState(() { + _addHistory(photos.urls); + _historyTotal = photos.total; + _rebuildPages(); + _loading = false; + }); + } + + Future _loadMore() async { + if (_loadingMore || _history.length >= _historyTotal) return; + _loadingMore = true; + final photos = await ContactsModule.fetchPhotos( + api, + widget.contactId, + from: _history.length, + count: _pageSize, + ); + if (!mounted) { + _loadingMore = false; + return; + } + setState(() { + _addHistory(photos.urls); + if (photos.total > _historyTotal) _historyTotal = photos.total; + _rebuildPages(); + }); + _loadingMore = false; + } + + void _onPageChanged(int index) { + setState(() => _index = index); + if (index >= _pages.length - 2) _loadMore(); + } + + void _prev() { + if (_index <= 0) return; + _pageController.previousPage( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + void _next() { + if (_index >= _pages.length - 1) return; + _pageController.nextPage( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + Future _save() async { + if (_saving || _index >= _pages.length) return; + setState(() => _saving = true); + final result = await saveImageFromUrl(_pages[_index]); + if (!mounted) return; + setState(() => _saving = false); + final message = result.ok + ? (result.toGallery + ? 'Сохранено в галерею' + : 'Сохранено: ${result.location}') + : 'Не удалось сохранить: ${result.error}'; + showCustomNotification(context, message); + } + + int get _count { + final total = _historyTotal + (_current != null ? 1 : 0); + return total > _pages.length ? total : _pages.length; + } + + @override + Widget build(BuildContext context) { + final topPad = MediaQuery.of(context).padding.top; + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill(child: _buildBody()), + if (_pages.length > 1 && _index > 0) + _navButton( + alignLeft: true, + icon: Symbols.chevron_left, + onTap: _prev, + ), + if (_pages.length > 1 && _index < _pages.length - 1) + _navButton( + alignLeft: false, + icon: Symbols.chevron_right, + onTap: _next, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + height: topPad + 76, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.55), + Colors.transparent, + ], + ), + ), + ), + ), + Positioned( + top: topPad + 4, + left: 4, + right: 4, + child: Row( + children: [ + IconButton( + icon: const Icon(Symbols.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + Expanded(child: _buildCounter()), + IconButton( + icon: _saving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : const Icon(Symbols.download, color: Colors.white), + onPressed: _pages.isEmpty || _saving ? null : _save, + ), + ], + ), + ), + if (_pages.length > 1 && _pages.length <= _maxDots) + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 18, + left: 0, + right: 0, + child: _buildDots(), + ), + ], + ), + ); + } + + Widget _buildCounter() { + final hasName = widget.name != null && widget.name!.isNotEmpty; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_pages.length > 1) + Text( + '${_index + 1} из $_count', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ) + else if (hasName) + Text( + widget.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + if (_pages.length > 1 && hasName) + Text( + widget.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ); + } + + Widget _buildBody() { + if (_pages.isEmpty) { + return Center( + child: _loading + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + 'Нет фотографий', + style: TextStyle(color: Colors.white54, fontSize: 15), + ), + ); + } + return PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _pages.length, + itemBuilder: (context, i) => Center( + child: CachedNetworkImage( + imageUrl: _pages[i], + fit: BoxFit.contain, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + errorWidget: (_, _, _) => + const Icon(Symbols.broken_image, color: Colors.white54, size: 64), + ), + ), + ); + } + + Widget _navButton({ + required bool alignLeft, + required IconData icon, + required VoidCallback onTap, + }) { + return Positioned( + top: 0, + bottom: 0, + left: alignLeft ? 8 : null, + right: alignLeft ? null : 8, + child: Center( + child: Material( + color: Colors.black.withValues(alpha: 0.35), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(icon, color: Colors.white, size: 30), + ), + ), + ), + ), + ); + } + + Widget _buildDots() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < _pages.length; i++) + AnimatedContainer( + duration: const Duration(milliseconds: 180), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: i == _index ? 8 : 6, + height: i == _index ? 8 : 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: i == _index ? Colors.white : Colors.white38, + ), + ), + ], + ); + } +}