feat: отправка вложений — файлы, опросы и геопозиция

- FILE/POLL/LOCATION через MSG_SEND (opcode 64) с inline-attach
  - sendLocationMessage / sendPollMessage в MessagesModule
  - экран создания опроса + рабочие вкладки «Файл/Геопозиция/Опрос» в шторке
  - geolocator + разрешения геолокации (Android/iOS) для текущей позиции
This commit is contained in:
klockky
2026-06-11 18:27:09 +03:00
parent b5a91e56aa
commit 82df24c38d
8 changed files with 609 additions and 7 deletions
+2
View File
@@ -2,6 +2,8 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
+2
View File
@@ -64,6 +64,8 @@
<string>Камера нужна для съёмки фото и видео в чатах, сканирования QR-кода входа и работы веб-приложений.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Микрофон нужен для записи голосовых сообщений, видео и звонков.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Геолокация нужна, чтобы отправлять ваше текущее местоположение в чатах.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Доступ к галерее нужен, чтобы отправлять фото и видео в чатах.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
+70
View File
@@ -607,6 +607,76 @@ class MessagesModule {
return null;
}
Future<Map<String, dynamic>?> 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<String, dynamic>.from(msg);
}
return null;
}
Future<Map<String, dynamic>?> sendPollMessage(
int chatId,
String title,
List<String> 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<String, dynamic>.from(msg);
}
return null;
}
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
+106 -1
View File
@@ -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<ChatScreen> 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<ChatScreen> with TickerProviderStateMixin {
}
}
Future<void> _sendAttachMessage(
List<MessageAttachment> optimistic,
Future<Map<String, dynamic>?> 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<void> _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<Position?> _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<void> _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<String?> _uploadOnePhoto(
File file,
int index,
@@ -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<String> answers;
final bool multiple;
final bool anonymous;
const PollDraft({
required this.title,
required this.answers,
required this.multiple,
required this.anonymous,
});
}
Future<PollDraft?> showCreatePollSheet(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return showModalBottomSheet<PollDraft>(
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<TextEditingController> _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<bool> onChanged,
}) {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(label, style: TextStyle(color: cs.onSurface, fontSize: 15)),
value: value,
onChanged: (v) {
HapticFeedback.selectionClick();
onChanged(v);
},
);
}
}
@@ -16,6 +16,7 @@ const List<PillNavItem> _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<void> showAttachmentSheet(
BuildContext context, {
String? title,
void Function(List<PickedPhoto> photos, String caption)? onSend,
VoidCallback? onPickFile,
VoidCallback? onShareLocation,
VoidCallback? onCreatePoll,
}) {
return showModalBottomSheet<void>(
context: context,
@@ -30,15 +34,31 @@ Future<void> 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<PickedPhoto> 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<AttachmentSheet> createState() => _AttachmentSheetState();
@@ -253,13 +273,97 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
_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<AttachmentSheet> {
_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<AttachmentSheet> {
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,
+48
View File
@@ -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:
+1
View File
@@ -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