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", "type": "String",
"placeholders": {} "placeholders": {}
}, },
"doNotSendIfCantClean": "Strictly no EXIF",
"doNotSendIfCantCleanDescription": "Do not send the image if there was an error cleaning EXIF metadata",
"repeatPassword": "Repeat password", "repeatPassword": "Repeat password",
"@repeatPassword": {}, "@repeatPassword": {},
"notAnImage": "Not an image file.", "notAnImage": "Not an image file.",

View File

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

View File

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

View File

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

View File

@ -140,46 +140,32 @@ class ChatInputRow extends StatelessWidget {
contentPadding: const EdgeInsets.all(0), 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>( PopupMenuItem<String>(
value: 'poll', value: 'image',
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: backgroundColor:
theme.colorScheme.onPrimaryContainer, theme.colorScheme.onPrimaryContainer,
foregroundColor: foregroundColor:
theme.colorScheme.primaryContainer, 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), contentPadding: const EdgeInsets.all(0),
), ),
), ),
@ -197,6 +183,20 @@ class ChatInputRow extends StatelessWidget {
contentPadding: const EdgeInsets.all(0), 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': { 'question': {
'org.matrix.msc1767.text': question, 'org.matrix.msc1767.text': question,
'm.text': question, 'm.text': question,
'body': question,
}, },
'answers': answers 'answers': answers
.map( .map(
@ -117,21 +118,33 @@ class SendPollDialogState extends State<SendPollDialog> {
@override @override
Widget build(BuildContext context) { 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( 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( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// --- Question Input ---
TextField( TextField(
controller: _questionController, controller: _questionController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: L10n.of(context).question, labelText: l10n.question,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
alignLabelWithHint: true,
), ),
maxLines: 2, maxLines: 2,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// --- Answer Inputs ---
..._answerControllers.asMap().entries.map((entry) { ..._answerControllers.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final controller = entry.value; final controller = entry.value;
@ -143,58 +156,102 @@ class SendPollDialogState extends State<SendPollDialog> {
child: TextField( child: TextField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '${L10n.of(context).answer} ${index + 1}', labelText: '${l10n.answer} ${index + 1}',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
), ),
const SizedBox(width: 8),
IconButton( 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), onPressed: () => _removeAnswer(index),
), ),
], ],
), ),
); );
}), }),
const SizedBox(height: 8), const SizedBox(height: 8),
OutlinedButton(
onPressed: _addAnswer, // Add Answer Button
child: Text(L10n.of(context).addAnswer), Center(
child: OutlinedButton.icon(
onPressed: _addAnswer,
icon: const Icon(Icons.add),
label: Text(l10n.addAnswer),
),
), ),
const SizedBox(height: 16),
DropdownButtonFormField<int>( const Divider(height: 32),
initialValue: _maxSelections,
decoration: InputDecoration( // --- Max Selections (Slider) ---
labelText: L10n.of(context).maxSelections, Padding(
border: const OutlineInputBorder(), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.maxSelections,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'$_maxSelections',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
), ),
items: List.generate(
_answerControllers.length,
(i) => DropdownMenuItem(
value: i + 1,
child: Text('${i + 1}'),
),
),
onChanged: (value) => setState(() => _maxSelections = 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), const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _kind, // --- Poll Type (Segmented Button) ---
decoration: InputDecoration( Padding(
labelText: L10n.of(context).pollType, padding: const EdgeInsets.only(bottom: 8.0, left: 8.0),
border: const OutlineInputBorder(), 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;
});
},
), ),
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!),
), ),
], ],
), ),
@ -202,11 +259,11 @@ class SendPollDialogState extends State<SendPollDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: Navigator.of(context).pop, onPressed: Navigator.of(context).pop,
child: Text(L10n.of(context).cancel), child: Text(l10n.cancel),
), ),
FilledButton( FilledButton(
onPressed: _sendPoll, 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, storeKey: SettingKeys.cleanExif,
defaultValue: AppConfig.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( SettingsSwitchListTile.adaptive(
title: L10n.of(context).sendTypingNotifications, title: L10n.of(context).sendTypingNotifications,
subtitle: subtitle:

View File

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

View File

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

View File

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