diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1a522ad..f453656 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 02b5d74..8623767 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -64,6 +64,8 @@ Камера нужна для съёмки фото и видео в чатах, сканирования QR-кода входа и работы веб-приложений. NSMicrophoneUsageDescription Микрофон нужен для записи голосовых сообщений, видео и звонков. + NSLocationWhenInUseUsageDescription + Геолокация нужна, чтобы отправлять ваше текущее местоположение в чатах. NSPhotoLibraryUsageDescription Доступ к галерее нужен, чтобы отправлять фото и видео в чатах. NSPhotoLibraryAddUsageDescription diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 856c015..1d8bab5 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -607,6 +607,76 @@ class MessagesModule { return null; } + Future?> sendLocationMessage( + int chatId, + double latitude, + double longitude, { + double zoom = 15, + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + { + '_type': 'LOCATION', + 'latitude': latitude, + 'longitude': longitude, + 'zoom': zoom, + }, + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) return null; + final data = response.payload; + if (data is Map) { + final msg = data['message']; + if (msg is Map) return Map.from(msg); + } + return null; + } + + Future?> sendPollMessage( + int chatId, + String title, + List answers, { + bool multiple = false, + bool anonymous = true, + bool notify = true, + }) async { + final settings = (anonymous ? 4 : 0) | (multiple ? 1 : 0); + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + { + '_type': 'POLL', + 'title': title, + 'settings': settings, + 'answers': [ + for (final a in answers) {'text': a}, + ], + }, + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) return null; + final data = response.payload; + if (data is Map) { + final msg = data['message']; + if (msg is Map) return Map.from(msg); + } + return null; + } + Future downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 1dede39..559e2c0 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -15,6 +16,7 @@ import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/utils/format.dart'; import 'package:komet/core/utils/logger.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; +import 'package:komet/frontend/screens/chats/poll_create_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; @@ -1849,7 +1851,14 @@ class _ChatScreenState extends State with TickerProviderStateMixin { setState(() => _keyboardReserve = keyboard); FocusManager.instance.primaryFocus?.unfocus(); } - await showAttachmentSheet(context, title: widget.name, onSend: _sendPhotos); + await showAttachmentSheet( + context, + title: widget.name, + onSend: _sendPhotos, + onPickFile: _pickAndUploadFile, + onShareLocation: _shareLocation, + onCreatePoll: _createPoll, + ); if (!mounted || !hadKeyboard) return; _messageFocusNode.requestFocus(); await Future.delayed(const Duration(milliseconds: 350)); @@ -1959,6 +1968,102 @@ class _ChatScreenState extends State with TickerProviderStateMixin { } } + Future _sendAttachMessage( + List optimistic, + Future?> Function() send, + ) async { + if (_myId == 0) return; + final tempId = _nextTempId(); + final now = DateTime.now().millisecondsSinceEpoch; + + final tempMessage = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + time: now, + status: 'sending', + attachments: optimistic, + ); + _messages.add(tempMessage); + _lastSentId = tempId; + _bumpMessages(); + Haptics.send(); + _scrollToBottom(); + + try { + final serverMsg = await send(); + if (!mounted) return; + final idx = _messages.indexWhere((m) => m.id == tempId); + if (idx == -1) return; + if (serverMsg == null) { + _updateFileMessageStatus(tempId, 'error'); + showCustomNotification(context, 'Ошибка отправки'); + return; + } + final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg); + _messages[idx] = real; + _bumpMessages(); + unawaited(_persistOutgoing(real, removeId: tempId)); + } catch (e) { + if (!mounted) return; + _updateFileMessageStatus(tempId, 'error'); + showCustomNotification(context, 'Ошибка: $e'); + } + } + + Future _shareLocation() async { + final position = await _resolveCurrentPosition(); + if (position == null || !mounted) return; + final lat = position.latitude; + final lon = position.longitude; + await _sendAttachMessage( + [LocationAttachment(latitude: lat, longitude: lon, zoom: 15)], + () => messagesModule.sendLocationMessage(widget.chatId, lat, lon), + ); + } + + Future _resolveCurrentPosition() async { + try { + if (!await Geolocator.isLocationServiceEnabled()) { + if (mounted) showCustomNotification(context, 'Включите геолокацию'); + return null; + } + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + if (mounted) showCustomNotification(context, 'Нет доступа к геолокации'); + return null; + } + return await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + } catch (e) { + if (mounted) showCustomNotification(context, 'Не удалось получить геопозицию'); + return null; + } + } + + Future _createPoll() async { + final draft = await showCreatePollSheet(context); + if (draft == null || !mounted) return; + await _sendAttachMessage( + [PollAttachment(pollId: 0, title: draft.title)], + () => messagesModule.sendPollMessage( + widget.chatId, + draft.title, + draft.answers, + multiple: draft.multiple, + anonymous: draft.anonymous, + ), + ); + } + Future _uploadOnePhoto( File file, int index, diff --git a/lib/frontend/screens/chats/poll_create_screen.dart b/lib/frontend/screens/chats/poll_create_screen.dart new file mode 100644 index 0000000..500baf9 --- /dev/null +++ b/lib/frontend/screens/chats/poll_create_screen.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; + +class PollDraft { + final String title; + final List answers; + final bool multiple; + final bool anonymous; + + const PollDraft({ + required this.title, + required this.answers, + required this.multiple, + required this.anonymous, + }); +} + +Future showCreatePollSheet(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => const _PollCreateSheet(), + ); +} + +const int _maxAnswers = 10; + +class _PollCreateSheet extends StatefulWidget { + const _PollCreateSheet(); + + @override + State<_PollCreateSheet> createState() => _PollCreateSheetState(); +} + +class _PollCreateSheetState extends State<_PollCreateSheet> { + final TextEditingController _question = TextEditingController(); + final List _answers = [ + TextEditingController(), + TextEditingController(), + ]; + bool _multiple = false; + bool _anonymous = true; + + @override + void dispose() { + _question.dispose(); + for (final c in _answers) { + c.dispose(); + } + super.dispose(); + } + + bool get _canCreate { + if (_question.text.trim().isEmpty) return false; + final filled = _answers.where((c) => c.text.trim().isNotEmpty).length; + return filled >= 2; + } + + void _addAnswer() { + if (_answers.length >= _maxAnswers) return; + setState(() => _answers.add(TextEditingController())); + } + + void _removeAnswer(int index) { + if (_answers.length <= 2) return; + setState(() => _answers.removeAt(index).dispose()); + } + + void _submit() { + final title = _question.text.trim(); + final answers = _answers + .map((c) => c.text.trim()) + .where((t) => t.isNotEmpty) + .toList(); + if (title.isEmpty || answers.length < 2) { + showCustomNotification(context, 'Введите вопрос и минимум 2 варианта'); + return; + } + Navigator.of(context).pop( + PollDraft( + title: title, + answers: answers, + multiple: _multiple, + anonymous: _anonymous, + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + const SheetGrabber(), + _buildHeader(cs), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _buildQuestionField(cs), + const SizedBox(height: 20), + Text( + 'Варианты ответа', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + for (var i = 0; i < _answers.length; i++) + _buildAnswerField(cs, i), + if (_answers.length < _maxAnswers) + _buildAddAnswerButton(cs), + const SizedBox(height: 16), + _buildToggle( + cs, + label: 'Несколько вариантов ответа', + value: _multiple, + onChanged: (v) => setState(() => _multiple = v), + ), + _buildToggle( + cs, + label: 'Анонимное голосование', + value: _anonymous, + onChanged: (v) => setState(() => _anonymous = v), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildHeader(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + onPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Text( + 'Новый опрос', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + ), + TextButton( + onPressed: _canCreate ? _submit : null, + child: const Text('Создать'), + ), + ], + ), + ); + } + + Widget _buildQuestionField(ColorScheme cs) { + return TextField( + controller: _question, + style: TextStyle(color: cs.onSurface, fontSize: 16), + cursorColor: cs.primary, + maxLength: 300, + maxLines: null, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: 'Задайте вопрос', + hintStyle: TextStyle(color: cs.onSurfaceVariant), + filled: true, + fillColor: cs.surfaceContainerHighest, + counterText: '', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ); + } + + Widget _buildAnswerField(ColorScheme cs, int index) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _answers[index], + style: TextStyle(color: cs.onSurface, fontSize: 15), + cursorColor: cs.primary, + maxLength: 100, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: 'Вариант ${index + 1}', + hintStyle: TextStyle(color: cs.onSurfaceVariant), + filled: true, + fillColor: cs.surfaceContainerHighest, + counterText: '', + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + if (_answers.length > 2) + IconButton( + icon: Icon(Symbols.remove_circle, color: cs.onSurfaceVariant), + onPressed: () => _removeAnswer(index), + ), + ], + ), + ); + } + + Widget _buildAddAnswerButton(ColorScheme cs) { + return Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _addAnswer, + icon: const Icon(Symbols.add, size: 20), + label: const Text('Добавить вариант'), + ), + ); + } + + Widget _buildToggle( + ColorScheme cs, { + required String label, + required bool value, + required ValueChanged onChanged, + }) { + return SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(label, style: TextStyle(color: cs.onSurface, fontSize: 15)), + value: value, + onChanged: (v) { + HapticFeedback.selectionClick(); + onChanged(v); + }, + ); + } +} diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 4d8009d..625c116 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -16,6 +16,7 @@ const List _navItems = [ PillNavItem(icon: Symbols.image, label: 'Галерея'), PillNavItem(icon: Symbols.description, label: 'Файл'), PillNavItem(icon: Symbols.location_on, label: 'Геопозиция'), + PillNavItem(icon: Symbols.bar_chart, label: 'Опрос'), PillNavItem(icon: Symbols.person, label: 'Контакт'), ]; @@ -23,6 +24,9 @@ Future showAttachmentSheet( BuildContext context, { String? title, void Function(List photos, String caption)? onSend, + VoidCallback? onPickFile, + VoidCallback? onShareLocation, + VoidCallback? onCreatePoll, }) { return showModalBottomSheet( context: context, @@ -30,15 +34,31 @@ Future showAttachmentSheet( requestFocus: false, backgroundColor: Colors.transparent, barrierColor: Colors.black.withValues(alpha: 0.45), - builder: (_) => AttachmentSheet(title: title, onSend: onSend), + builder: (_) => AttachmentSheet( + title: title, + onSend: onSend, + onPickFile: onPickFile, + onShareLocation: onShareLocation, + onCreatePoll: onCreatePoll, + ), ); } class AttachmentSheet extends StatefulWidget { final String? title; final void Function(List photos, String caption)? onSend; + final VoidCallback? onPickFile; + final VoidCallback? onShareLocation; + final VoidCallback? onCreatePoll; - const AttachmentSheet({super.key, this.title, this.onSend}); + const AttachmentSheet({ + super.key, + this.title, + this.onSend, + this.onPickFile, + this.onShareLocation, + this.onCreatePoll, + }); @override State createState() => _AttachmentSheetState(); @@ -253,13 +273,97 @@ class _AttachmentSheetState extends State { _KeepAlivePage( child: _buildGalleryPage(scrollController, cs, bottomReserve), ), - _buildPlaceholderPage(cs, bottomReserve), - _buildPlaceholderPage(cs, bottomReserve), + _buildActionPage( + cs, + bottomReserve, + icon: Symbols.description, + title: 'Отправить файл', + subtitle: 'Документ, архив или любой другой файл', + buttonLabel: 'Выбрать файл', + onTap: widget.onPickFile, + ), + _buildActionPage( + cs, + bottomReserve, + icon: Symbols.location_on, + title: 'Поделиться геопозицией', + subtitle: 'Отправить ваше текущее местоположение', + buttonLabel: 'Отправить геопозицию', + onTap: widget.onShareLocation, + ), + _buildActionPage( + cs, + bottomReserve, + icon: Symbols.bar_chart, + title: 'Создать опрос', + subtitle: 'Вопрос с вариантами ответа', + buttonLabel: 'Создать опрос', + onTap: widget.onCreatePoll, + ), _buildPlaceholderPage(cs, bottomReserve), ], ); } + Widget _buildActionPage( + ColorScheme cs, + double bottomReserve, { + required IconData icon, + required String title, + required String subtitle, + required String buttonLabel, + required VoidCallback? onTap, + }) { + return Padding( + padding: EdgeInsets.only(bottom: bottomReserve), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 34, color: cs.onPrimaryContainer), + ), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + subtitle, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: onTap == null + ? null + : () { + Navigator.of(context).pop(); + onTap(); + }, + child: Text(buttonLabel), + ), + ], + ), + ), + ), + ); + } + Widget _buildGalleryPage( ScrollController scrollController, ColorScheme cs, @@ -544,7 +648,7 @@ class _AttachmentSheetState extends State { _navDragAccumDx += dx; final pageT = (_navDragBasePageT + _navDragAccumDx / inactiveWidth).clamp( 0.0, - 3.0, + (_navItems.length - 1).toDouble(), ); _pageController.jumpTo(pageT * _pageController.position.viewportDimension); } @@ -552,7 +656,7 @@ class _AttachmentSheetState extends State { void _onPillDragEnd() { if (!_navDragging) return; _navDragging = false; - final target = _currentPageT().round().clamp(0, 3); + final target = _currentPageT().round().clamp(0, _navItems.length - 1); _pageController.animateToPage( target, duration: _navAnim, diff --git a/pubspec.lock b/pubspec.lock index 326cb7e..830d407 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -533,6 +533,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.12.12+hotfix.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: dde05dae7d584db6e82feb87dd9fb0b4b4c83ed68065667b4bef637be38e13a7 + url: "https://pub.dev" + source: hosted + version: "4.2.7" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index da072b1..fff9b38 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + geolocator: ^13.0.0 photo_manager: ^3.0.0 image: ^4.3.0 sqflite: ^2.4.2