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",
|
"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.",
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@
|
||||||
"type": "String",
|
"type": "String",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
|
"doNotSendIfCantClean": "Не отправлять неочищенные картинки",
|
||||||
|
"doNotSendIfCantCleanDescription": "Не отправлять картинку, если не удаётся удалить метаданные EXIF",
|
||||||
"repeatPassword": "Повторите пароль",
|
"repeatPassword": "Повторите пароль",
|
||||||
"@repeatPassword": {},
|
"@repeatPassword": {},
|
||||||
"notAnImage": "Это не картинка.",
|
"notAnImage": "Это не картинка.",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue