feat: "do not send image if cant clean exif" as an option
refactor: new send poll ui
This commit is contained in:
parent
7355a0bba8
commit
a9d818fd3d
Binary file not shown.
|
|
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@
|
|||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"doNotSendIfCantClean": "Не отправлять неочищенные картинки",
|
||||
"doNotSendIfCantCleanDescription": "Не отправлять картинку, если не удаётся удалить метаданные EXIF",
|
||||
"repeatPassword": "Повторите пароль",
|
||||
"@repeatPassword": {},
|
||||
"notAnImage": "Это не картинка.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue