Improve image zoom quality

This commit is contained in:
Janez T
2026-03-01 19:17:09 +01:00
parent be7fe6c4b0
commit 0e7ba5572d
20 changed files with 2157 additions and 15 deletions

View File

@@ -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);
},
),
],
),
);

View File

@@ -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,
),