feat: "do not send image if cant clean exif" as an option

refactor: new send poll ui
This commit is contained in:
OfficialDakari 2025-11-24 16:11:33 +05:00
parent 7355a0bba8
commit a9d818fd3d
34 changed files with 153 additions and 77 deletions

View File

@ -1 +1 @@
{"app_name":"extera_next","version":"2.0.1","package_name":"extera_next"}
{"app_name":"extera_next","version":"2.0.2","package_name":"extera_next"}

Binary file not shown.

View File

@ -52,6 +52,8 @@
"type": "String",
"placeholders": {}
},
"doNotSendIfCantClean": "Strictly no EXIF",
"doNotSendIfCantCleanDescription": "Do not send the image if there was an error cleaning EXIF metadata",
"repeatPassword": "Repeat password",
"@repeatPassword": {},
"notAnImage": "Not an image file.",

View File

@ -52,6 +52,8 @@
"type": "String",
"placeholders": {}
},
"doNotSendIfCantClean": "Не отправлять неочищенные картинки",
"doNotSendIfCantCleanDescription": "Не отправлять картинку, если не удаётся удалить метаданные EXIF",
"repeatPassword": "Повторите пароль",
"@repeatPassword": {},
"notAnImage": "Это не картинка.",

View File

@ -17,6 +17,7 @@ abstract class AppConfig {
static bool displayNavigationRail = true;
static bool enableGradient = true;
static bool cleanExif = true;
static bool doNotSendIfCantClean = true;
static String? httpProxy;

View File

@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
abstract class SettingKeys {
static const String httpProxy = 'xyz.extera.next.httpProxy';
static const String cleanExif = 'xyz.extera.next.cleanExif';
static const String doNotSendIfCantClean = 'xyz.extera.next.doNotSendIfCantClean';
static const String displayNavigationRail = 'chat.fluffy.displayNavigationRail';
static const String hideAvatarsInInvites = 'xyz.extera.next.hideAvatarsInInvites';
static const String pureBlack = 'xyz.extera.next.pureBlack';

View File

@ -140,46 +140,32 @@ class ChatInputRow extends StatelessWidget {
contentPadding: const EdgeInsets.all(0),
),
),
// PopupMenuItem<String>(
// value: 'image',
// child: ListTile(
// leading: CircleAvatar(
// backgroundColor:
// theme.colorScheme.onPrimaryContainer,
// foregroundColor:
// theme.colorScheme.primaryContainer,
// child: const Icon(Icons.photo_outlined),
// ),
// title: Text(L10n.of(context).sendImage),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
// PopupMenuItem<String>(
// value: 'video',
// child: ListTile(
// leading: CircleAvatar(
// backgroundColor:
// theme.colorScheme.onPrimaryContainer,
// foregroundColor:
// theme.colorScheme.primaryContainer,
// child:
// const Icon(Icons.video_camera_back_outlined),
// ),
// title: Text(L10n.of(context).sendVideo),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
PopupMenuItem<String>(
value: 'poll',
value: 'image',
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child: const Icon(Icons.poll_outlined),
child: const Icon(Icons.photo_outlined),
),
title: Text(L10n.of(context).createPoll),
title: Text(L10n.of(context).sendImage),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'video',
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child:
const Icon(Icons.video_camera_back_outlined),
),
title: Text(L10n.of(context).sendVideo),
contentPadding: const EdgeInsets.all(0),
),
),
@ -197,6 +183,20 @@ class ChatInputRow extends StatelessWidget {
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'poll',
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child: const Icon(Icons.poll_outlined),
),
title: Text(L10n.of(context).createPoll),
contentPadding: const EdgeInsets.all(0),
),
),
],
),
),

View File

@ -73,6 +73,7 @@ class SendPollDialogState extends State<SendPollDialog> {
'question': {
'org.matrix.msc1767.text': question,
'm.text': question,
'body': question,
},
'answers': answers
.map(
@ -117,21 +118,33 @@ class SendPollDialogState extends State<SendPollDialog> {
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
// Ensure max slider value is at least 1 to prevent division by zero errors
final double maxAnswers = _answerControllers.isNotEmpty
? _answerControllers.length.toDouble()
: 1.0;
return AlertDialog(
title: Text(L10n.of(context).createPoll),
title: Text(l10n.createPoll),
// In M3, the surface tint color is often used, but we keep default styling here
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- Question Input ---
TextField(
controller: _questionController,
decoration: InputDecoration(
labelText: L10n.of(context).question,
labelText: l10n.question,
border: const OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: 2,
),
const SizedBox(height: 16),
// --- Answer Inputs ---
..._answerControllers.asMap().entries.map((entry) {
final index = entry.key;
final controller = entry.value;
@ -143,58 +156,102 @@ class SendPollDialogState extends State<SendPollDialog> {
child: TextField(
controller: controller,
decoration: InputDecoration(
labelText: '${L10n.of(context).answer} ${index + 1}',
labelText: '${l10n.answer} ${index + 1}',
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.remove_circle),
// M3 uses standard variant colors for destructive actions
icon: const Icon(Icons.remove_circle_outline),
onPressed: () => _removeAnswer(index),
),
],
),
);
}),
const SizedBox(height: 8),
OutlinedButton(
// Add Answer Button
Center(
child: OutlinedButton.icon(
onPressed: _addAnswer,
child: Text(L10n.of(context).addAnswer),
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _maxSelections,
decoration: InputDecoration(
labelText: L10n.of(context).maxSelections,
border: const OutlineInputBorder(),
),
items: List.generate(
_answerControllers.length,
(i) => DropdownMenuItem(
value: i + 1,
child: Text('${i + 1}'),
icon: const Icon(Icons.add),
label: Text(l10n.addAnswer),
),
),
onChanged: (value) => setState(() => _maxSelections = value!),
const Divider(height: 32),
// --- Max Selections (Slider) ---
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.maxSelections,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _kind,
decoration: InputDecoration(
labelText: L10n.of(context).pollType,
border: const OutlineInputBorder(),
Text(
'$_maxSelections',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
items: [
DropdownMenuItem(
value: 'org.matrix.msc3381.disclosed',
child: Text(L10n.of(context).publicPoll),
),
DropdownMenuItem(
value: 'org.matrix.msc3381.undisclosed',
child: Text(L10n.of(context).anonymousPoll),
),
],
onChanged: (value) => setState(() => _kind = value!),
),
),
Slider(
value: _maxSelections.toDouble().clamp(1.0, maxAnswers),
min: 1,
max: maxAnswers,
divisions: (maxAnswers > 1) ? (maxAnswers - 1).toInt() : 1,
label: '$_maxSelections',
onChanged: (value) {
setState(() {
_maxSelections = value.toInt();
});
},
),
const SizedBox(height: 16),
// --- Poll Type (Segmented Button) ---
Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 8.0),
child: Text(
l10n.pollType,
style: Theme.of(context).textTheme.bodyLarge,
),
),
SizedBox(
width: double.infinity,
child: SegmentedButton<String>(
showSelectedIcon: false, // Cleaner look for text-only segments
segments: [
ButtonSegment<String>(
value: 'org.matrix.msc3381.disclosed',
label: Text(l10n.publicPoll),
icon: const Icon(Icons.visibility_outlined),
),
ButtonSegment<String>(
value: 'org.matrix.msc3381.undisclosed',
label: Text(l10n.anonymousPoll),
icon: const Icon(Icons.visibility_off_outlined),
),
],
selected: {_kind},
onSelectionChanged: (Set<String> newSelection) {
setState(() {
// SegmentedButton returns a Set, we just need the first (only) value
_kind = newSelection.first;
});
},
),
),
],
),
@ -202,11 +259,11 @@ class SendPollDialogState extends State<SendPollDialog> {
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text(L10n.of(context).cancel),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: _sendPoll,
child: Text(L10n.of(context).send),
child: Text(l10n.send),
),
],
);

View File

@ -84,6 +84,14 @@ class SettingsSecurityView extends StatelessWidget {
storeKey: SettingKeys.cleanExif,
defaultValue: AppConfig.cleanExif,
),
SettingsSwitchListTile.adaptive(
title: L10n.of(context).doNotSendIfCantClean,
subtitle:
L10n.of(context).doNotSendIfCantCleanDescription,
onChanged: (b) => AppConfig.doNotSendIfCantClean = b,
storeKey: SettingKeys.doNotSendIfCantClean,
defaultValue: AppConfig.doNotSendIfCantClean,
),
SettingsSwitchListTile.adaptive(
title: L10n.of(context).sendTypingNotifications,
subtitle:

View File

@ -1,13 +1,18 @@
import 'dart:typed_data';
import 'package:extera_next/config/app_config.dart';
import 'package:image/image.dart';
class ExifCleaner {
static List<int> removeExifData(List<int> imageBytes) {
// Decode the image (this strips EXIF data)
final Image? image = decodeImage(Uint8List.fromList(imageBytes));
final image = decodeImage(Uint8List.fromList(imageBytes));
if (image == null) {
if (AppConfig.doNotSendIfCantClean) {
throw Exception('Failed to decode image');
} else {
return imageBytes;
}
}
// Encode back to bytes without EXIF based on detected format

View File

@ -957,10 +957,10 @@ packages:
dependency: "direct main"
description:
name: image_picker
sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041"
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.2.1"
image_picker_android:
dependency: transitive
description:

View File

@ -58,7 +58,7 @@ dependencies:
html: ^0.15.4
http: ^1.2.0
image: ^4.5.4
image_picker: ^1.1.0
image_picker: ^1.2.1
intl: ^0.20.2
just_audio: ^0.9.39
latlong2: ^0.9.1