mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Improve image zoom quality
This commit is contained in:
@@ -25,6 +25,11 @@ import '../services/voice_codec_service.dart';
|
||||
import '../utils/toast_logger.dart';
|
||||
import '../utils/key_comparison.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
import '../providers/image_provider.dart' as ip;
|
||||
import '../services/image_codec_service.dart';
|
||||
import '../services/image_preferences.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class MessagesTab extends StatefulWidget {
|
||||
@@ -50,6 +55,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
MessageDestinationPreferences.destinationTypeChannel;
|
||||
Contact? _selectedRecipient;
|
||||
|
||||
// Image sending state
|
||||
bool _isSendingImage = false;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
// Voice recording state
|
||||
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
||||
bool _isRecording = false;
|
||||
@@ -57,7 +66,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
static const int _maxVoicePackets = 10;
|
||||
static const double _silenceRmsThreshold = 500.0;
|
||||
static const double _silencePeakThreshold = 1400.0;
|
||||
static const int _maxInteriorSilentChunks = 1;
|
||||
static const int _maxInteriorSilentChunks = 2;
|
||||
bool get _voiceSupported => Platform.isIOS || Platform.isAndroid;
|
||||
StreamSubscription<Int16List>? _voiceStreamSub;
|
||||
String? _currentVoiceSessionId;
|
||||
@@ -418,6 +427,137 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Image sending ───────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _pickAndSendImage({
|
||||
ImageSource source = ImageSource.gallery,
|
||||
}) async {
|
||||
if (_isSendingImage) return;
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
ToastLogger.error(context, 'Not connected to device');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick image.
|
||||
final picked = await _imagePicker.pickImage(source: source);
|
||||
if (picked == null) return;
|
||||
final rawBytes = await picked.readAsBytes();
|
||||
|
||||
setState(() => _isSendingImage = true);
|
||||
try {
|
||||
// Compress to grayscale AVIF using user-selected size and compression.
|
||||
final maxSize = await ImagePreferences.getMaxSize();
|
||||
final compression = await ImagePreferences.getCompression();
|
||||
final result = await ImageCodecService.compress(
|
||||
rawBytes,
|
||||
maxDimension: maxSize,
|
||||
compression: compression,
|
||||
);
|
||||
if (result == null) {
|
||||
ToastLogger.error(context, 'Image compression failed');
|
||||
return;
|
||||
}
|
||||
final compressed = result.bytes;
|
||||
|
||||
// Generate session ID (4 random bytes → 8 hex chars).
|
||||
final sessionId = List.generate(
|
||||
8,
|
||||
(_) => math.Random().nextInt(16).toRadixString(16),
|
||||
).join();
|
||||
|
||||
// Fragment.
|
||||
final fragments = fragmentImage(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.avif,
|
||||
bytes: compressed,
|
||||
);
|
||||
|
||||
if (fragments.isEmpty) {
|
||||
ToastLogger.error(context, 'Image fragmentation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build envelope.
|
||||
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
||||
if (deviceKey == null || deviceKey.length < 6) {
|
||||
ToastLogger.error(context, 'Device key unavailable');
|
||||
return;
|
||||
}
|
||||
final senderKey6 = deviceKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
final envelope = ImageEnvelope(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.avif,
|
||||
total: fragments.length,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
sizeBytes: compressed.length,
|
||||
senderKey6: senderKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
// Cache for deferred serving.
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
imageProvider.cacheOutgoingSession(sessionId, fragments, envelope);
|
||||
|
||||
// Add local placeholder message.
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final msgId = 'img_${sessionId}_sent';
|
||||
final isChannel =
|
||||
_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel;
|
||||
final placeholder = Message(
|
||||
id: msgId,
|
||||
messageType: isChannel ? MessageType.channel : MessageType.contact,
|
||||
channelIdx: isChannel ? 0 : null,
|
||||
senderPublicKeyPrefix: deviceKey.sublist(0, 6),
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
text: envelope.encode(),
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
);
|
||||
messagesProvider.addSentMessage(placeholder);
|
||||
|
||||
// Send IE1 envelope via normal message path.
|
||||
final envelopeText = envelope.encode();
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel) {
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: 0,
|
||||
text: envelopeText,
|
||||
messageId: msgId,
|
||||
);
|
||||
} else if (_selectedRecipient != null) {
|
||||
final sent = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: _selectedRecipient!.publicKey,
|
||||
text: envelopeText,
|
||||
messageId: msgId,
|
||||
contact: _selectedRecipient!,
|
||||
);
|
||||
if (!sent) {
|
||||
messagesProvider.markMessageFailed(msgId);
|
||||
ToastLogger.error(context, 'Failed to announce image');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'📷 [Image] Sent IE1 for session $sessionId: '
|
||||
'${fragments.length} fragments, ${compressed.length}B',
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
|
||||
ToastLogger.error(context, 'Image send failed');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSendingImage = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Voice recording ────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _startVoiceRecording() async {
|
||||
@@ -476,6 +616,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final stream = _voiceRecorder.startCapture(
|
||||
chunkDuration: packetDuration,
|
||||
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
||||
enableCompressor: appProvider.isVoiceCompressorEnabled,
|
||||
enableLimiter: appProvider.isVoiceLimiterEnabled,
|
||||
);
|
||||
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
||||
_voiceStreamSub = stream.listen(
|
||||
@@ -808,6 +950,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
enabled: !_isSendingImage,
|
||||
leading: const Icon(Icons.photo_library),
|
||||
title: const Text('Send image from gallery'),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_pickAndSendImage(source: ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
enabled: !_isSendingImage,
|
||||
leading: const Icon(Icons.camera_alt),
|
||||
title: const Text('Take photo'),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_pickAndSendImage(source: ImageSource.camera);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../services/location_tracking_service.dart';
|
||||
import '../services/locale_preferences.dart';
|
||||
import '../services/update_checker_service.dart';
|
||||
import '../services/voice_bitrate_preferences.dart';
|
||||
import '../services/image_preferences.dart';
|
||||
import '../utils/sample_data_generator.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -47,6 +48,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showRxTxIndicators = true;
|
||||
bool _isCheckingForUpdates = false;
|
||||
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
|
||||
int _imageMaxSize = ImagePreferences.defaultMaxSize;
|
||||
int _imageCompression = ImagePreferences.defaultQuality;
|
||||
final LocationTrackingService _locationService = LocationTrackingService();
|
||||
|
||||
@override
|
||||
@@ -58,6 +61,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_initializeLocationService();
|
||||
_loadRxTxPreference();
|
||||
_loadVoiceBitratePreference();
|
||||
_loadImagePreferences();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -112,6 +116,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return '$bitrate bps';
|
||||
}
|
||||
|
||||
Future<void> _loadImagePreferences() async {
|
||||
final size = await ImagePreferences.getMaxSize();
|
||||
final compression = await ImagePreferences.getCompression();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_imageMaxSize = size;
|
||||
_imageCompression = compression;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveImageMaxSize(int size) async {
|
||||
await ImagePreferences.setMaxSize(size);
|
||||
if (!mounted) return;
|
||||
setState(() => _imageMaxSize = size);
|
||||
}
|
||||
|
||||
Future<void> _saveImageCompression(int compression) async {
|
||||
await ImagePreferences.setCompression(compression);
|
||||
if (!mounted) return;
|
||||
setState(() => _imageCompression = compression);
|
||||
}
|
||||
|
||||
Future<void> _initializeLocationService() async {
|
||||
// Initialize location service with BLE service
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
@@ -581,6 +607,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
builder: (context, appProvider, child) => _buildVoiceStatsCard(
|
||||
bitrate: _voiceBitrate,
|
||||
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||
),
|
||||
),
|
||||
@@ -604,6 +632,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<AppProvider>(
|
||||
builder: (context, appProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.compress),
|
||||
title: const Text('Voice compressor'),
|
||||
subtitle: const Text('Balances quiet and loud speech levels'),
|
||||
value: appProvider.isVoiceCompressorEnabled,
|
||||
onChanged: (value) async {
|
||||
await appProvider.toggleVoiceCompressorEnabled(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<AppProvider>(
|
||||
builder: (context, appProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.speed),
|
||||
title: const Text('Voice limiter'),
|
||||
subtitle: const Text('Prevents clipping peaks before encoding'),
|
||||
value: appProvider.isVoiceLimiterEnabled,
|
||||
onChanged: (value) async {
|
||||
await appProvider.toggleVoiceLimiterEnabled(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<AppProvider>(
|
||||
builder: (context, appProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.content_cut),
|
||||
@@ -619,6 +669,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Image Settings Section
|
||||
_buildSectionHeader('Image'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_size_select_large),
|
||||
title: const Text('Max image size'),
|
||||
subtitle: Text('$_imageMaxSize×$_imageMaxSize px'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _showImageMaxSizeDialog,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: const Text('Image compression'),
|
||||
subtitle: Text('$_imageCompression / 90 (higher = smaller file)'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Slider(
|
||||
value: _imageCompression.toDouble(),
|
||||
min: 10,
|
||||
max: 90,
|
||||
divisions: 8,
|
||||
label: '$_imageCompression',
|
||||
onChanged: (v) => setState(() => _imageCompression = v.round()),
|
||||
onChangeEnd: (v) => _saveImageCompression(v.round()),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Templates Section
|
||||
_buildSectionHeader('Templates'),
|
||||
ListTile(
|
||||
@@ -841,6 +919,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Widget _buildVoiceStatsCard({
|
||||
required int bitrate,
|
||||
required bool bandPassEnabled,
|
||||
required bool compressorEnabled,
|
||||
required bool limiterEnabled,
|
||||
required bool silenceTrimEnabled,
|
||||
}) {
|
||||
final supported = VoiceBitratePreferences.supportedBitrates;
|
||||
@@ -849,7 +929,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final normalized = maxBitrate > minBitrate
|
||||
? ((bitrate - minBitrate) / (maxBitrate - minBitrate)).clamp(0.0, 1.0)
|
||||
: 1.0;
|
||||
final enabledCount = (bandPassEnabled ? 1 : 0) + (silenceTrimEnabled ? 1 : 0);
|
||||
final enabledCount =
|
||||
(bandPassEnabled ? 1 : 0) +
|
||||
(compressorEnabled ? 1 : 0) +
|
||||
(limiterEnabled ? 1 : 0) +
|
||||
(silenceTrimEnabled ? 1 : 0);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -870,10 +954,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: normalized,
|
||||
minHeight: 8,
|
||||
),
|
||||
child: LinearProgressIndicator(value: normalized, minHeight: 8),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
@@ -885,6 +966,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _voiceStatChip(
|
||||
label: 'Compressor',
|
||||
enabled: compressorEnabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _voiceStatChip(
|
||||
label: 'Limiter',
|
||||
enabled: limiterEnabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _voiceStatChip(
|
||||
label: 'Silence trim',
|
||||
@@ -895,7 +994,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Processing enabled: $enabledCount/2',
|
||||
'Processing enabled: $enabledCount/4',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -1086,6 +1185,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showImageMaxSizeDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Max image size'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: ImagePreferences.supportedSizes
|
||||
.map(
|
||||
(size) => RadioListTile<int>(
|
||||
value: size,
|
||||
groupValue: _imageMaxSize,
|
||||
title: Text('${size}×$size px'),
|
||||
subtitle: size == ImagePreferences.defaultMaxSize
|
||||
? const Text('Default')
|
||||
: null,
|
||||
onChanged: (value) {
|
||||
if (value != null) _saveImageMaxSize(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showVoiceBitrateDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -1107,7 +1242,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
(bitrate) => RadioListTile<int>(
|
||||
value: bitrate,
|
||||
title: Text('$bitrate bps'),
|
||||
subtitle: bitrate == VoiceBitratePreferences.defaultBitrate
|
||||
subtitle:
|
||||
bitrate == VoiceBitratePreferences.defaultBitrate
|
||||
? const Text('Default')
|
||||
: null,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user