feat: отправка вложений — файлы, опросы и геопозиция
- FILE/POLL/LOCATION через MSG_SEND (opcode 64) с inline-attach - sendLocationMessage / sendPollMessage в MessagesModule - экран создания опроса + рабочие вкладки «Файл/Геопозиция/Опрос» в шторке - geolocator + разрешения геолокации (Android/iOS) для текущей позиции
This commit is contained in:
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user