fix: Settings crash, photo preview before sending, incoming-content hardening
Settings crashed with a TypeError: the profile card passed a SepotFingerprint object where the localized string formatter expects a String (hidden behind a `dynamic` parameter). Also fixes RangeError in avatar initials (empty names, double spaces) and switches initials to grapheme clusters so emoji don't get split. New photo preview screen: a captured or picked photo no longer sends immediately — it opens in a preview with freehand drawing (7 colours, undo), draggable text overlay, 90° rotation and a caption field. Nothing leaves the device until Send is tapped. Incoming-content hardening: decryption and content processing are now separated in the delivery path. Once an envelope is decrypted the ratchet has advanced, so redelivery can never help — permanently malformed content (FormatException/TypeError) is acked and dropped instead of poisoning the queue on every reconnect. Attachment metadata now validates base64 of the crypto fields up front, and a corrupt inline thumbnail no longer breaks bubble rendering. PNG without real transparency (screenshots, edited photos) is now re-encoded as JPEG — several times smaller on the wire. Tests: 116 (new widget tests for Settings and the photo preview, AttachmentMeta wire validation).
This commit is contained in:
@@ -4,6 +4,37 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
||||
verzování dle [ajtak standardu](#verzování).
|
||||
|
||||
## [Nevydáno]
|
||||
|
||||
### Přidáno
|
||||
- **Náhled fotky před odesláním.** Vyfocená nebo vybraná fotka už
|
||||
neodchází okamžitě — otevře se náhled, kde jde fotku otočit, kreslit
|
||||
po ní prstem (7 barev, krok zpět), přidat na ni text (tažením se
|
||||
přesouvá, klepnutím upraví) a připojit popisek. Odejde až po klepnutí
|
||||
na Odeslat; zavření náhledu nic nepošle.
|
||||
|
||||
### Opraveno
|
||||
- **Nastavení se nevykreslilo** — obrazovka místo obsahu ukazovala
|
||||
chybovou hlášku „Tahle obrazovka se nevykreslila". Profilová karta
|
||||
předávala do překladu otisk klíče jako objekt místo textu.
|
||||
- Avatar s iniciálami spadl na prázdném jménu nebo jménu s dvojitou
|
||||
mezerou; iniciály se nově skládají po grafémech, takže nerozpůlí emoji.
|
||||
- **Písma se dodávají v aplikaci** (Inter, JetBrains Mono — OFL).
|
||||
Aplikace dřív stahovala fonty za běhu z Google CDN — síťový požadavek
|
||||
na cizí server navíc a binárka odlišná od zveřejněného zdroje.
|
||||
- **Trvale vadná příchozí zpráva už neotravuje frontu.** Zpráva, jejíž
|
||||
obsah nejde po dešifrování zpracovat (poškozená pole od upraveného
|
||||
klienta), se potvrdí a zahodí — dřív se doručovala donekonečna při
|
||||
každém připojení. Nedešifrovatelné obálky fronta dál drží.
|
||||
- Příloha s poškozeným náhledem od protistrany shodila vykreslení
|
||||
bubliny; vadný náhled se nově zahodí při příjmu a base64 krypto polí
|
||||
příloh se validuje.
|
||||
- Zachycení výměny důvěryhodního kořene relaye (signál MITM) na
|
||||
pozadí — dřív tichá neošetřená výjimka, teď příznak čitelný pro UI.
|
||||
- PNG bez průhlednosti (screenshoty, upravené fotky) se posílají jako
|
||||
JPEG — několikanásobně menší přenos; PNG zůstává jen pro obrázky se
|
||||
skutečnou průhledností.
|
||||
|
||||
## [1.0.0] – 2026-07-11
|
||||
|
||||
První ostrá verze. Šepot je od 11. 7. 2026 open source — kompletní
|
||||
|
||||
@@ -72,9 +72,11 @@ class ImageProcessor {
|
||||
: img.copyResize(image, height: maxDimension);
|
||||
}
|
||||
|
||||
// PNG s průhledností zůstává PNG (JPEG alfa kanál neumí, a re-encode
|
||||
// PNG→PNG metadata stejně zahodí); vše ostatní JPEG q80.
|
||||
final keepPng = mimeHint == 'image/png' && decoded.numChannels == 4;
|
||||
// PNG s REÁLNOU průhledností zůstává PNG (JPEG alfa kanál neumí,
|
||||
// a re-encode PNG→PNG metadata stejně zahodí); vše ostatní JPEG q80.
|
||||
// Samotný alfa kanál nestačí — plně krytý RGBA PNG (screenshot,
|
||||
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
|
||||
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
|
||||
final bytes = keepPng
|
||||
? Uint8List.fromList(img.encodePng(image))
|
||||
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
||||
@@ -89,6 +91,16 @@ class ImageProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
/// Má obraz aspoň jeden ne-plně-krytý pixel? Běží v izolátu spolu se
|
||||
/// zbytkem zpracování, O(n) sken je proti dekódu/enkódu zanedbatelný.
|
||||
static bool _hasTransparency(img.Image image) {
|
||||
if (image.numChannels < 4) return false;
|
||||
for (final pixel in image) {
|
||||
if (pixel.a < pixel.maxChannelValue) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Uint8List _thumbnail(img.Image source) {
|
||||
var thumb = max(source.width, source.height) > thumbDimension
|
||||
? (source.width >= source.height
|
||||
|
||||
@@ -1045,13 +1045,24 @@ class MessagingService {
|
||||
final trustRoot = _trustRoot;
|
||||
if (crypto == null || trustRoot == null) return;
|
||||
|
||||
final SepotSealedMessage opened;
|
||||
try {
|
||||
final opened = await crypto.sealedDecrypt(
|
||||
opened = await crypto.sealedDecrypt(
|
||||
ciphertext: Uint8List.fromList(envelope.payload),
|
||||
trustRoot: trustRoot,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
} catch (_) {
|
||||
// Nedešifrovatelná/cizí obálka: NEACKovat, ať ji fronta podrží
|
||||
// (může jít o dočasný stav — např. cert refresh).
|
||||
return;
|
||||
}
|
||||
|
||||
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
|
||||
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
|
||||
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
|
||||
// trvale otravovala frontu při každém reconnectu.
|
||||
try {
|
||||
final content =
|
||||
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
|
||||
final type = content['t'] as String?;
|
||||
@@ -1059,7 +1070,12 @@ class MessagingService {
|
||||
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', // 1:1 + skupiny
|
||||
'ginv', 'gleave', 'gmeta', // správa skupin
|
||||
};
|
||||
if (content['v'] != 1 || !known.contains(type)) return;
|
||||
if (content['v'] != 1 || !known.contains(type)) {
|
||||
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
|
||||
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
final contact = await _findOrCreateContact(opened, content);
|
||||
final senderFp = contact.id;
|
||||
@@ -1242,9 +1258,18 @@ class MessagingService {
|
||||
if (message.attachment != null) {
|
||||
unawaited(downloadAttachment(convId, message));
|
||||
}
|
||||
} on FormatException {
|
||||
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
|
||||
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
} on TypeError {
|
||||
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
|
||||
// trvale vadné, ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
} catch (_) {
|
||||
// Nedešifrovatelná/cizí obálka: NEACKovat, ať ji fronta podrží
|
||||
// (může jít o dočasný stav — např. cert refresh)
|
||||
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
|
||||
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
|
||||
// pravděpodobně selže. Trvalé chyby obsahu řeší větve výše.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import 'package:sepot/features/conversation/widgets/attachment_content.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/features/conversation/photo_preview_screen.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
@@ -83,7 +85,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
.firstOrNull;
|
||||
final conversationAsync = ref.watch(conversationProvider(widget.contactId));
|
||||
final identity = ref.watch(identityProvider).valueOrNull;
|
||||
// Skupinová konverzace: contactId nese id skupiny (gid)
|
||||
// Skupinová konverzace: contactId == gid
|
||||
final group = ref.watch(groupProvider(widget.contactId)).valueOrNull;
|
||||
|
||||
if (group != null) {
|
||||
@@ -266,14 +268,14 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Skupinová konverzace: jména autorů nad cizími
|
||||
/// Skupinová konverzace : jména autorů nad cizími
|
||||
/// bublinami, menu Členové / Přejmenovat / Opustit, žádný verify badge
|
||||
/// (ověřují se jednotliví členové v Kontaktech).
|
||||
Widget _buildGroupScaffold(
|
||||
BuildContext context,
|
||||
Group group,
|
||||
AsyncValue<Conversation?> conversationAsync,
|
||||
dynamic identity, {
|
||||
LocalIdentity? identity, {
|
||||
required List<Contact> contacts,
|
||||
}) {
|
||||
// Fingerprint → jméno pro popisky autorů. Členy, kteří ještě nejsou
|
||||
@@ -953,6 +955,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
try {
|
||||
// Moc krátké klepnutí — zahoď (uživatel to nechtěl poslat)
|
||||
if (dur.inMilliseconds < 800) {
|
||||
await file.delete();
|
||||
return;
|
||||
}
|
||||
final bytes = await file.readAsBytes();
|
||||
@@ -962,6 +965,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
durationMs: dur.inMilliseconds,
|
||||
);
|
||||
ref.invalidate(conversationProvider(widget.contactId));
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -970,12 +974,6 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
.chatVoiceSendFailed(describeError(context.l10n, e)))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Dočasný audio soubor leží v cache nezašifrovaný — smaž ho ve všech
|
||||
// větvích (odesláno, zahozeno i chyba), ať v ní po pádu nezůstane.
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1252,19 +1250,40 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
Uint8List? bytes;
|
||||
var fileName = '';
|
||||
String? mime;
|
||||
String? caption;
|
||||
try {
|
||||
switch (action) {
|
||||
case _AttachAction.gallery:
|
||||
case _AttachAction.camera:
|
||||
// maxWidth/Height 2048 = strop ImageProcessoru; menší vstup šetří
|
||||
// RAM při náhledu i finálním renderu úprav.
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: action == _AttachAction.gallery
|
||||
? ImageSource.gallery
|
||||
: ImageSource.camera,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 92,
|
||||
);
|
||||
if (picked == null) return;
|
||||
bytes = await picked.readAsBytes();
|
||||
fileName = picked.name;
|
||||
mime = picked.mimeType;
|
||||
final pickedBytes = await picked.readAsBytes();
|
||||
if (!mounted) return;
|
||||
// Nic neodchází rovnou z kamery — náhled s úpravami a potvrzením.
|
||||
final preview = await Navigator.of(context).push<PhotoPreviewResult>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => PhotoPreviewScreen(
|
||||
bytes: pickedBytes,
|
||||
fileName: picked.name,
|
||||
mime: picked.mimeType,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (preview == null || !mounted) return;
|
||||
bytes = preview.bytes;
|
||||
fileName = preview.fileName;
|
||||
mime = preview.mime;
|
||||
caption = preview.caption;
|
||||
case _AttachAction.document:
|
||||
final result = await FilePicker.pickFiles(withData: true);
|
||||
final file = result?.files.firstOrNull;
|
||||
@@ -1295,6 +1314,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
mime: mime,
|
||||
caption: caption,
|
||||
);
|
||||
ref.invalidate(conversationProvider(widget.contactId));
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/services/image_processor.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
|
||||
/// Výsledek náhledu: co poslat. `null` z Navigatoru = uživatel zrušil.
|
||||
class PhotoPreviewResult {
|
||||
const PhotoPreviewResult({
|
||||
required this.bytes,
|
||||
required this.fileName,
|
||||
required this.mime,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
final Uint8List bytes;
|
||||
final String fileName;
|
||||
final String? mime;
|
||||
final String? caption;
|
||||
}
|
||||
|
||||
/// Náhled fotky před odesláním — nic neodchází bez potvrzení.
|
||||
///
|
||||
/// Úpravy: kreslení prstem, text na fotce (tažení prstem, tap = upravit),
|
||||
/// otočení o 90°. Tahy a texty žijí v relativních souřadnicích [0..1]
|
||||
/// zobrazeného (already otočeného) obrazu, takže finální render v plném
|
||||
/// rozlišení je jen přeškálování. Bez úprav odchází původní bajty —
|
||||
/// EXIF/GPS z nich stáhne ImageProcessor při odeslání.
|
||||
class PhotoPreviewScreen extends StatefulWidget {
|
||||
const PhotoPreviewScreen({
|
||||
super.key,
|
||||
required this.bytes,
|
||||
required this.fileName,
|
||||
this.mime,
|
||||
});
|
||||
|
||||
final Uint8List bytes;
|
||||
final String fileName;
|
||||
final String? mime;
|
||||
|
||||
@override
|
||||
State<PhotoPreviewScreen> createState() => _PhotoPreviewScreenState();
|
||||
}
|
||||
|
||||
enum _EditMode { none, draw }
|
||||
|
||||
class _Stroke {
|
||||
_Stroke({required this.color, required this.width, required this.points});
|
||||
|
||||
final Color color;
|
||||
|
||||
/// Šířka tahu jako podíl šířky obrazu (přežije škálování).
|
||||
final double width;
|
||||
final List<Offset> points; // relativní [0..1]
|
||||
}
|
||||
|
||||
class _OverlayText {
|
||||
_OverlayText({
|
||||
required this.text,
|
||||
required this.color,
|
||||
required this.position,
|
||||
});
|
||||
|
||||
String text;
|
||||
Color color;
|
||||
|
||||
/// Střed textu, relativně [0..1] k zobrazenému obrazu.
|
||||
Offset position;
|
||||
|
||||
/// Výška fontu jako podíl šířky obrazu.
|
||||
static const double fontScale = 0.05;
|
||||
}
|
||||
|
||||
class _PhotoPreviewScreenState extends State<PhotoPreviewScreen> {
|
||||
static const _palette = <Color>[
|
||||
Colors.white,
|
||||
Colors.black,
|
||||
SepotColors.ember,
|
||||
SepotColors.sage,
|
||||
SepotColors.mist,
|
||||
SepotColors.warning,
|
||||
SepotColors.error,
|
||||
];
|
||||
|
||||
final _captionController = TextEditingController();
|
||||
ui.Image? _image;
|
||||
_EditMode _mode = _EditMode.none;
|
||||
Color _color = Colors.white;
|
||||
int _quarterTurns = 0;
|
||||
final List<_Stroke> _strokes = [];
|
||||
final List<_OverlayText> _texts = [];
|
||||
bool _rendering = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_decode();
|
||||
}
|
||||
|
||||
Future<void> _decode() async {
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(widget.bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
if (mounted) {
|
||||
setState(() => _image = frame.image);
|
||||
} else {
|
||||
frame.image.dispose();
|
||||
}
|
||||
} catch (_) {
|
||||
// Nedekódovatelné bajty (picker by je dát neměl) — zpátky bez
|
||||
// odeslání, ať uživatel nezůstane viset na věčném spinneru.
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.photoPreviewDecodeFailed)),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_captionController.dispose();
|
||||
_image?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _edited =>
|
||||
_strokes.isNotEmpty || _texts.isNotEmpty || _quarterTurns % 4 != 0;
|
||||
|
||||
/// Otočení o 90° po směru: relativní bod (x, y) → (1 − y, x).
|
||||
void _rotate() {
|
||||
setState(() {
|
||||
_quarterTurns = (_quarterTurns + 1) % 4;
|
||||
for (final s in _strokes) {
|
||||
for (var i = 0; i < s.points.length; i++) {
|
||||
s.points[i] = Offset(1 - s.points[i].dy, s.points[i].dx);
|
||||
}
|
||||
}
|
||||
for (final t in _texts) {
|
||||
t.position = Offset(1 - t.position.dy, t.position.dx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _undo() {
|
||||
setState(() {
|
||||
if (_strokes.isNotEmpty) _strokes.removeLast();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addText() async {
|
||||
final text = await _editTextDialog(initial: '');
|
||||
if (text == null || text.trim().isEmpty) return;
|
||||
setState(() {
|
||||
_texts.add(_OverlayText(
|
||||
text: text.trim(),
|
||||
color: _color,
|
||||
position: const Offset(0.5, 0.5),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _editText(_OverlayText item) async {
|
||||
final text = await _editTextDialog(initial: item.text, removable: true);
|
||||
if (text == null) return; // zrušeno
|
||||
setState(() {
|
||||
if (text.trim().isEmpty) {
|
||||
_texts.remove(item); // „Odebrat" vrací prázdný řetězec
|
||||
} else {
|
||||
item.text = text.trim();
|
||||
item.color = _color;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _editTextDialog({
|
||||
required String initial,
|
||||
bool removable = false,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => _TextEditDialog(initial: initial, removable: removable),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
if (_rendering) return;
|
||||
setState(() => _rendering = true);
|
||||
try {
|
||||
final caption = _captionController.text.trim();
|
||||
final result = _edited
|
||||
? PhotoPreviewResult(
|
||||
// Render je vždy PNG — mime říká pravdu o bajtech. Finální
|
||||
// formát rozhodne ImageProcessor (bez průhlednosti → JPEG).
|
||||
bytes: await _renderEdited(),
|
||||
fileName: 'photo.png',
|
||||
mime: 'image/png',
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
)
|
||||
: PhotoPreviewResult(
|
||||
bytes: widget.bytes,
|
||||
fileName: widget.fileName,
|
||||
mime: widget.mime,
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, result);
|
||||
} finally {
|
||||
if (mounted) setState(() => _rendering = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vykreslí otočený obraz + tahy + texty v plném rozlišení (strop 2048 px
|
||||
/// po delší straně — dál by to ImageProcessor stejně zmenšil).
|
||||
Future<Uint8List> _renderEdited() async {
|
||||
final src = _image!;
|
||||
final rotated = _quarterTurns.isOdd;
|
||||
final srcW = src.width.toDouble();
|
||||
final srcH = src.height.toDouble();
|
||||
final scale = math.min(
|
||||
1.0,
|
||||
ImageProcessor.maxDimension / math.max(srcW, srcH),
|
||||
);
|
||||
final outW = (rotated ? srcH : srcW) * scale;
|
||||
final outH = (rotated ? srcW : srcH) * scale;
|
||||
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
canvas.save();
|
||||
switch (_quarterTurns % 4) {
|
||||
case 1:
|
||||
canvas.translate(outW, 0);
|
||||
canvas.rotate(math.pi / 2);
|
||||
case 2:
|
||||
canvas.translate(outW, outH);
|
||||
canvas.rotate(math.pi);
|
||||
case 3:
|
||||
canvas.translate(0, outH);
|
||||
canvas.rotate(3 * math.pi / 2);
|
||||
}
|
||||
canvas.drawImageRect(
|
||||
src,
|
||||
Rect.fromLTWH(0, 0, srcW, srcH),
|
||||
Rect.fromLTWH(0, 0, srcW * scale, srcH * scale),
|
||||
Paint()..filterQuality = FilterQuality.high,
|
||||
);
|
||||
canvas.restore();
|
||||
|
||||
_paintOverlay(canvas, Size(outW, outH));
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(outW.round(), outH.round());
|
||||
picture.dispose();
|
||||
try {
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
return data!.buffer.asUint8List();
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tahy a texty — společná cesta pro živý náhled i finální render,
|
||||
/// aby odeslaná fotka vypadala přesně jako náhled.
|
||||
void _paintOverlay(Canvas canvas, Size size) {
|
||||
for (final stroke in _strokes) {
|
||||
final paint = Paint()
|
||||
..color = stroke.color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke.width * size.width
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
final points =
|
||||
stroke.points.map((p) => Offset(p.dx * size.width, p.dy * size.height));
|
||||
final path = Path()..addPolygon(points.toList(), false);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
for (final item in _texts) {
|
||||
final painter = _textPainter(item, size);
|
||||
final center = Offset(
|
||||
item.position.dx * size.width,
|
||||
item.position.dy * size.height,
|
||||
);
|
||||
painter.paint(
|
||||
canvas,
|
||||
center - Offset(painter.width / 2, painter.height / 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextPainter _textPainter(_OverlayText item, Size size) {
|
||||
return TextPainter(
|
||||
text: TextSpan(
|
||||
text: item.text,
|
||||
style: TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
color: item.color,
|
||||
fontSize: _OverlayText.fontScale * size.width,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 4, color: Colors.black54),
|
||||
],
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
)..layout(maxWidth: size.width);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final image = _image;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildToolbar(l10n),
|
||||
Expanded(
|
||||
child: image == null
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
)
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) =>
|
||||
_buildCanvas(image, constraints),
|
||||
),
|
||||
),
|
||||
if (_mode == _EditMode.draw || _texts.isNotEmpty)
|
||||
_buildPalette(),
|
||||
_buildBottomBar(l10n),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolbar(AppLocalizations l10n) {
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
tooltip: l10n.commonCancel,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_strokes.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo, color: Colors.white),
|
||||
tooltip: l10n.photoPreviewUndo,
|
||||
onPressed: _undo,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.rotate_90_degrees_cw_outlined,
|
||||
color: Colors.white),
|
||||
tooltip: l10n.photoPreviewRotate,
|
||||
onPressed: _image == null ? null : _rotate,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.gesture,
|
||||
color: _mode == _EditMode.draw ? SepotColors.ember : Colors.white,
|
||||
),
|
||||
tooltip: l10n.photoPreviewDraw,
|
||||
onPressed: _image == null
|
||||
? null
|
||||
: () => setState(() {
|
||||
_mode = _mode == _EditMode.draw
|
||||
? _EditMode.none
|
||||
: _EditMode.draw;
|
||||
}),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.title, color: Colors.white),
|
||||
tooltip: l10n.photoPreviewAddText,
|
||||
onPressed: _image == null ? null : _addText,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCanvas(ui.Image image, BoxConstraints constraints) {
|
||||
final rotated = _quarterTurns.isOdd;
|
||||
final iw = (rotated ? image.height : image.width).toDouble();
|
||||
final ih = (rotated ? image.width : image.height).toDouble();
|
||||
final scale = math.min(
|
||||
constraints.maxWidth / iw,
|
||||
constraints.maxHeight / ih,
|
||||
);
|
||||
final size = Size(iw * scale, ih * scale);
|
||||
|
||||
Offset toRelative(Offset local) =>
|
||||
Offset(local.dx / size.width, local.dy / size.height);
|
||||
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
// RawImage kreslí už dekódovaný _image — Image.memory by tytéž
|
||||
// bajty dekódoval podruhé (u 2048² ~16 MB RGBA navíc).
|
||||
child: RotatedBox(
|
||||
quarterTurns: _quarterTurns,
|
||||
child: RawImage(image: image, fit: BoxFit.fill),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanStart: _mode == _EditMode.draw
|
||||
? (d) => setState(() {
|
||||
_strokes.add(_Stroke(
|
||||
color: _color,
|
||||
width: 0.008,
|
||||
points: [toRelative(d.localPosition)],
|
||||
));
|
||||
})
|
||||
: null,
|
||||
onPanUpdate: _mode == _EditMode.draw
|
||||
? (d) => setState(() {
|
||||
_strokes.last.points.add(toRelative(d.localPosition));
|
||||
})
|
||||
: null,
|
||||
child: CustomPaint(
|
||||
painter: _OverlayPainter(
|
||||
strokes: List.of(_strokes),
|
||||
paintAll: _paintOverlay,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Texty mají vlastní gesta (drag + tap), proto jedou nad
|
||||
// kreslicí vrstvou jako neviditelné hit-oblasti.
|
||||
for (final item in _texts)
|
||||
Positioned(
|
||||
left: item.position.dx * size.width - size.width / 2,
|
||||
top: item.position.dy * size.height - 24,
|
||||
width: size.width,
|
||||
height: 48,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onTap: () => _editText(item),
|
||||
onPanUpdate: (d) => setState(() {
|
||||
final next = item.position + toRelative(d.delta);
|
||||
item.position = Offset(
|
||||
next.dx.clamp(0.02, 0.98),
|
||||
next.dy.clamp(0.02, 0.98),
|
||||
);
|
||||
}),
|
||||
child: Center(
|
||||
child: Container(
|
||||
// Neviditelný úchop kolem textu, ať se dá chytit prstem
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
color: Colors.transparent,
|
||||
child: Opacity(
|
||||
opacity: 0,
|
||||
child: Text(
|
||||
item.text,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: _OverlayText.fontScale * size.width,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPalette() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final color in _palette)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _color = color),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: _color == color
|
||||
? SepotColors.textPrimary
|
||||
: SepotColors.border,
|
||||
width: _color == color ? 2.5 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _captionController,
|
||||
maxLength: 500,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
textInputAction: TextInputAction.newline,
|
||||
style: const TextStyle(color: SepotColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
filled: false,
|
||||
hintText: l10n.photoPreviewCaptionHint,
|
||||
hintStyle: const TextStyle(color: SepotColors.textMuted),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: const BorderSide(color: SepotColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: const BorderSide(color: SepotColors.ember),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: IconButton.filled(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: SepotColors.ember,
|
||||
foregroundColor: SepotColors.emberInk,
|
||||
),
|
||||
tooltip: l10n.photoPreviewSend,
|
||||
onPressed: _image == null || _rendering ? null : _send,
|
||||
icon: _rendering
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SepotColors.emberInk,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog textu na fotce — vlastní widget kvůli životnímu cyklu
|
||||
/// TextEditingControlleru (dispose patří do State, ne za showDialog).
|
||||
class _TextEditDialog extends StatefulWidget {
|
||||
const _TextEditDialog({required this.initial, required this.removable});
|
||||
|
||||
final String initial;
|
||||
final bool removable;
|
||||
|
||||
@override
|
||||
State<_TextEditDialog> createState() => _TextEditDialogState();
|
||||
}
|
||||
|
||||
class _TextEditDialogState extends State<_TextEditDialog> {
|
||||
late final TextEditingController _controller =
|
||||
TextEditingController(text: widget.initial);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.photoPreviewTextDialogTitle),
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
maxLength: 100,
|
||||
decoration: const InputDecoration(counterText: ''),
|
||||
),
|
||||
actions: [
|
||||
if (widget.removable)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, ''),
|
||||
child: Text(
|
||||
context.l10n.photoPreviewRemoveText,
|
||||
style: const TextStyle(color: SepotColors.error),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _controller.text),
|
||||
child: Text(context.l10n.commonOk),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Živý náhled úprav — deleguje na tutéž kreslicí rutinu jako finální
|
||||
/// render, jen v rozlišení obrazovky.
|
||||
class _OverlayPainter extends CustomPainter {
|
||||
_OverlayPainter({required this.strokes, required this.paintAll});
|
||||
|
||||
final List<_Stroke> strokes;
|
||||
final void Function(Canvas canvas, Size size) paintAll;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) => paintAll(canvas, size);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _OverlayPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class _QrScanScreenState extends ConsumerState<QrScanScreen> {
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red, // Jakákoliv neprůhledná barva
|
||||
color: Colors.red, // Jakákoliv nepůhledná barva
|
||||
backgroundBlendMode: BlendMode.dstOut,
|
||||
),
|
||||
),
|
||||
@@ -198,11 +198,9 @@ class _QrScanScreenState extends ConsumerState<QrScanScreen> {
|
||||
radius: 32,
|
||||
backgroundColor: SepotColors.ember.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
// Naskenovaná identita může mít prázdný displayName
|
||||
// (i podvržený QR); guard proti pádu na substring(0,1).
|
||||
identity.displayName.isNotEmpty
|
||||
? identity.displayName.substring(0, 1).toUpperCase()
|
||||
: '?',
|
||||
identity.displayName.isEmpty
|
||||
? '?'
|
||||
: identity.displayName.characters.first.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
@@ -296,7 +297,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProfileCard(BuildContext context, dynamic identity) {
|
||||
Widget _buildProfileCard(BuildContext context, LocalIdentity identity) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
@@ -311,7 +312,9 @@ class SettingsScreen extends ConsumerWidget {
|
||||
radius: 36,
|
||||
backgroundColor: SepotColors.ember.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
identity.displayName.substring(0, 1).toUpperCase(),
|
||||
identity.displayName.isEmpty
|
||||
? '?'
|
||||
: identity.displayName.characters.first.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 28,
|
||||
@@ -352,7 +355,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.settingsFingerprint(identity.fingerprint),
|
||||
context.l10n.settingsFingerprint(identity.fingerprint.display),
|
||||
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1698,5 +1698,41 @@
|
||||
"commonE2eBadgeFull": "End-to-end šifrováno",
|
||||
"@commonE2eBadgeFull": {
|
||||
"description": "Plná varianta badge šifrování (ikona zámku + text), zobrazuje se např. v hlavičce chatu / onboardingu."
|
||||
},
|
||||
"photoPreviewCaptionHint": "Přidat popisek…",
|
||||
"@photoPreviewCaptionHint": {
|
||||
"description": "hintText pole popisku na obrazovce náhledu fotky před odesláním"
|
||||
},
|
||||
"photoPreviewDraw": "Kreslit",
|
||||
"@photoPreviewDraw": {
|
||||
"description": "Tooltip nástroje kreslení prstem na náhledu fotky"
|
||||
},
|
||||
"photoPreviewAddText": "Přidat text",
|
||||
"@photoPreviewAddText": {
|
||||
"description": "Tooltip nástroje přidání textu na fotku"
|
||||
},
|
||||
"photoPreviewRotate": "Otočit",
|
||||
"@photoPreviewRotate": {
|
||||
"description": "Tooltip otočení fotky o 90°"
|
||||
},
|
||||
"photoPreviewUndo": "Zpět",
|
||||
"@photoPreviewUndo": {
|
||||
"description": "Tooltip vrácení posledního tahu kreslení"
|
||||
},
|
||||
"photoPreviewSend": "Poslat",
|
||||
"@photoPreviewSend": {
|
||||
"description": "Tooltip tlačítka odeslání fotky z náhledu"
|
||||
},
|
||||
"photoPreviewTextDialogTitle": "Text na fotce",
|
||||
"@photoPreviewTextDialogTitle": {
|
||||
"description": "Titulek dialogu pro zadání/úpravu textu na fotce"
|
||||
},
|
||||
"photoPreviewRemoveText": "Odebrat",
|
||||
"@photoPreviewRemoveText": {
|
||||
"description": "Tlačítko odebrání textu z fotky v dialogu úpravy"
|
||||
},
|
||||
"photoPreviewDecodeFailed": "Fotku se nepodařilo načíst",
|
||||
"@photoPreviewDecodeFailed": {
|
||||
"description": "SnackBar, když se vybraná fotka nedá dekódovat — náhled se zavře bez odeslání"
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -348,5 +348,14 @@
|
||||
"contactsErrorAlreadyExists": "Kontakt existiert bereits",
|
||||
"commonAppTagline": "Privater Messenger",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "Ende-zu-Ende-verschlüsselt"
|
||||
"commonE2eBadgeFull": "Ende-zu-Ende-verschlüsselt",
|
||||
"photoPreviewCaptionHint": "Bildunterschrift hinzufügen…",
|
||||
"photoPreviewDraw": "Zeichnen",
|
||||
"photoPreviewAddText": "Text hinzufügen",
|
||||
"photoPreviewRotate": "Drehen",
|
||||
"photoPreviewUndo": "Rückgängig",
|
||||
"photoPreviewSend": "Senden",
|
||||
"photoPreviewTextDialogTitle": "Text auf dem Foto",
|
||||
"photoPreviewRemoveText": "Entfernen",
|
||||
"photoPreviewDecodeFailed": "Foto konnte nicht geladen werden"
|
||||
}
|
||||
|
||||
+10
-1
@@ -348,5 +348,14 @@
|
||||
"contactsErrorAlreadyExists": "Contact already exists",
|
||||
"commonAppTagline": "Private messenger",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "End-to-end encrypted"
|
||||
"commonE2eBadgeFull": "End-to-end encrypted",
|
||||
"photoPreviewCaptionHint": "Add a caption…",
|
||||
"photoPreviewDraw": "Draw",
|
||||
"photoPreviewAddText": "Add text",
|
||||
"photoPreviewRotate": "Rotate",
|
||||
"photoPreviewUndo": "Undo",
|
||||
"photoPreviewSend": "Send",
|
||||
"photoPreviewTextDialogTitle": "Text on photo",
|
||||
"photoPreviewRemoveText": "Remove",
|
||||
"photoPreviewDecodeFailed": "Couldn't load the photo"
|
||||
}
|
||||
|
||||
+10
-1
@@ -348,5 +348,14 @@
|
||||
"contactsErrorAlreadyExists": "Il contatto esiste già",
|
||||
"commonAppTagline": "Messenger privato",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "Cifrato end-to-end"
|
||||
"commonE2eBadgeFull": "Cifrato end-to-end",
|
||||
"photoPreviewCaptionHint": "Aggiungi una didascalia…",
|
||||
"photoPreviewDraw": "Disegna",
|
||||
"photoPreviewAddText": "Aggiungi testo",
|
||||
"photoPreviewRotate": "Ruota",
|
||||
"photoPreviewUndo": "Annulla",
|
||||
"photoPreviewSend": "Invia",
|
||||
"photoPreviewTextDialogTitle": "Testo sulla foto",
|
||||
"photoPreviewRemoveText": "Rimuovi",
|
||||
"photoPreviewDecodeFailed": "Impossibile caricare la foto"
|
||||
}
|
||||
|
||||
+10
-1
@@ -348,5 +348,14 @@
|
||||
"contactsErrorAlreadyExists": "Контакт уже существует",
|
||||
"commonAppTagline": "Приватный мессенджер",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "Сквозное шифрование"
|
||||
"commonE2eBadgeFull": "Сквозное шифрование",
|
||||
"photoPreviewCaptionHint": "Добавить подпись…",
|
||||
"photoPreviewDraw": "Рисовать",
|
||||
"photoPreviewAddText": "Добавить текст",
|
||||
"photoPreviewRotate": "Повернуть",
|
||||
"photoPreviewUndo": "Отменить",
|
||||
"photoPreviewSend": "Отправить",
|
||||
"photoPreviewTextDialogTitle": "Текст на фото",
|
||||
"photoPreviewRemoveText": "Удалить",
|
||||
"photoPreviewDecodeFailed": "Не удалось загрузить фото"
|
||||
}
|
||||
|
||||
@@ -2197,6 +2197,60 @@ abstract class AppLocalizations {
|
||||
/// In cs, this message translates to:
|
||||
/// **'End-to-end šifrováno'**
|
||||
String get commonE2eBadgeFull;
|
||||
|
||||
/// hintText pole popisku na obrazovce náhledu fotky před odesláním
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přidat popisek…'**
|
||||
String get photoPreviewCaptionHint;
|
||||
|
||||
/// Tooltip nástroje kreslení prstem na náhledu fotky
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Kreslit'**
|
||||
String get photoPreviewDraw;
|
||||
|
||||
/// Tooltip nástroje přidání textu na fotku
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přidat text'**
|
||||
String get photoPreviewAddText;
|
||||
|
||||
/// Tooltip otočení fotky o 90°
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Otočit'**
|
||||
String get photoPreviewRotate;
|
||||
|
||||
/// Tooltip vrácení posledního tahu kreslení
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zpět'**
|
||||
String get photoPreviewUndo;
|
||||
|
||||
/// Tooltip tlačítka odeslání fotky z náhledu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Poslat'**
|
||||
String get photoPreviewSend;
|
||||
|
||||
/// Titulek dialogu pro zadání/úpravu textu na fotce
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Text na fotce'**
|
||||
String get photoPreviewTextDialogTitle;
|
||||
|
||||
/// Tlačítko odebrání textu z fotky v dialogu úpravy
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odebrat'**
|
||||
String get photoPreviewRemoveText;
|
||||
|
||||
/// SnackBar, když se vybraná fotka nedá dekódovat — náhled se zavře bez odeslání
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Fotku se nepodařilo načíst'**
|
||||
String get photoPreviewDecodeFailed;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1248,4 +1248,31 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'End-to-end šifrováno';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Přidat popisek…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Kreslit';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Přidat text';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Otočit';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Zpět';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Poslat';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Text na fotce';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Odebrat';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Fotku se nepodařilo načíst';
|
||||
}
|
||||
|
||||
@@ -1270,4 +1270,31 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'Ende-zu-Ende-verschlüsselt';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Bildunterschrift hinzufügen…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Zeichnen';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Text hinzufügen';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Drehen';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Rückgängig';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Senden';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Text auf dem Foto';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Entfernen';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Foto konnte nicht geladen werden';
|
||||
}
|
||||
|
||||
@@ -1243,4 +1243,31 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'End-to-end encrypted';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Add a caption…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Draw';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Add text';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Rotate';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Undo';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Send';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Text on photo';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Remove';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Couldn\'t load the photo';
|
||||
}
|
||||
|
||||
@@ -1257,4 +1257,31 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'Cifrato end-to-end';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Aggiungi una didascalia…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Disegna';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Aggiungi testo';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Ruota';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Annulla';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Invia';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Testo sulla foto';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Rimuovi';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Impossibile caricare la foto';
|
||||
}
|
||||
|
||||
@@ -1282,4 +1282,31 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'Сквозное шифрование';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Добавить подпись…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Рисовать';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Добавить текст';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Повернуть';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Отменить';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Отправить';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Текст на фото';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Удалить';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Не удалось загрузить фото';
|
||||
}
|
||||
|
||||
@@ -97,6 +97,21 @@ class AttachmentMeta {
|
||||
if (size <= 0 || size > 512 * 1024 * 1024) {
|
||||
throw const FormatException('implausible attachment size');
|
||||
}
|
||||
// Krypto pole MUSÍ být validní base64 (jinak je meta k ničemu);
|
||||
// vyhodí FormatException → zpráva se zahodí jako vadná.
|
||||
base64Decode(wire['k'] as String);
|
||||
base64Decode(wire['n'] as String);
|
||||
base64Decode(wire['d'] as String);
|
||||
// Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka
|
||||
// se stáhne z blobu. base64Decode v build() by shodil celou bublinu.
|
||||
var thumb = wire['th'] as String?;
|
||||
if (thumb != null) {
|
||||
try {
|
||||
base64Decode(thumb);
|
||||
} on FormatException {
|
||||
thumb = null;
|
||||
}
|
||||
}
|
||||
return AttachmentMeta(
|
||||
blobId: wire['bid'] as String,
|
||||
keyB64: wire['k'] as String,
|
||||
@@ -107,7 +122,7 @@ class AttachmentMeta {
|
||||
name: wire['name'] as String?,
|
||||
width: wire['w'] as int?,
|
||||
height: wire['h'] as int?,
|
||||
thumbB64: wire['th'] as String?,
|
||||
thumbB64: thumb,
|
||||
durationMs: wire['dur'] as int?,
|
||||
state: AttachmentTransferState.pending,
|
||||
);
|
||||
|
||||
@@ -129,6 +129,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
required Uint8List bytes,
|
||||
required String fileName,
|
||||
String? mime,
|
||||
String? caption,
|
||||
}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
@@ -140,6 +141,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
mime: mime,
|
||||
caption: caption,
|
||||
);
|
||||
} else {
|
||||
final contact = await _contact(contactId);
|
||||
@@ -148,6 +150,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
mime: mime,
|
||||
caption: caption,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,17 @@ class SepotAvatar extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _getInitials(String name) {
|
||||
final parts = name.trim().split(' ');
|
||||
// Prázdné díly odfiltrovat — dvojitá mezera ve jméně nesmí shodit render.
|
||||
// Grafémy (characters), ne code units — jméno začínající emoji by se
|
||||
// jinak rozpůlilo na rozbitý surrogate.
|
||||
final parts =
|
||||
name.trim().split(' ').where((p) => p.isNotEmpty).toList();
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
|
||||
return '${parts[0].characters.first}${parts[1].characters.first}'
|
||||
.toUpperCase();
|
||||
}
|
||||
return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase();
|
||||
if (parts.isEmpty) return '?';
|
||||
return parts[0].characters.take(2).toString().toUpperCase();
|
||||
}
|
||||
|
||||
Color _colorFromName(String name) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/models/attachment.dart';
|
||||
|
||||
/// Validace metadat přílohy z drátu — remote input od protistrany.
|
||||
/// Vadný náhled nesmí shodit render bubliny, vadná krypto pole musí
|
||||
/// zprávu zabít už při příjmu (ACK + zahodit), ne až při použití.
|
||||
void main() {
|
||||
Map<String, dynamic> validWire() => {
|
||||
'bid': 'a1b2c3d4e5f60718293a4b5c6d7e8f90',
|
||||
'k': base64Encode(List.filled(32, 1)),
|
||||
'n': base64Encode(List.filled(12, 2)),
|
||||
'd': base64Encode(List.filled(32, 3)),
|
||||
's': 1024,
|
||||
'mime': 'image/jpeg',
|
||||
};
|
||||
|
||||
test('validní wire projde a thumbnail dekóduje', () {
|
||||
final meta = AttachmentMeta.fromWire(
|
||||
{...validWire(), 'th': base64Encode([1, 2, 3])});
|
||||
expect(meta.thumbnail, [1, 2, 3]);
|
||||
expect(meta.key.length, 32);
|
||||
});
|
||||
|
||||
test('vadný base64 náhled se zahodí, zpráva žije', () {
|
||||
final meta = AttachmentMeta.fromWire({...validWire(), 'th': '@@@!!'});
|
||||
expect(meta.thumbB64, isNull);
|
||||
expect(meta.thumbnail, isNull);
|
||||
expect(meta.blobId, validWire()['bid']);
|
||||
});
|
||||
|
||||
test('vadná base64 krypto pole zprávu zabijí FormatException', () {
|
||||
for (final field in ['k', 'n', 'd']) {
|
||||
expect(
|
||||
() => AttachmentMeta.fromWire({...validWire(), field: 'ne base64!'}),
|
||||
throwsFormatException,
|
||||
reason: 'pole $field s nevalidní base64 musí hodit',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('nesmyslná velikost hodí FormatException', () {
|
||||
expect(
|
||||
() => AttachmentMeta.fromWire({...validWire(), 's': 0}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => AttachmentMeta.fromWire({...validWire(), 's': 600 * 1024 * 1024}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('špatný typ pole hodí TypeError (poison cesta ACKuje)', () {
|
||||
expect(
|
||||
() => AttachmentMeta.fromWire({...validWire(), 'k': 42}),
|
||||
throwsA(isA<TypeError>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sepot/features/conversation/photo_preview_screen.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
|
||||
/// Náhled fotky před odesláním: nic neodchází bez potvrzení, úpravy
|
||||
/// (rotace) se propíšou do odeslaných bajtů, popisek do výsledku.
|
||||
///
|
||||
/// Dekódování obrázku a render úprav jsou reálné async operace engine,
|
||||
/// proto testy běží v `tester.runAsync`.
|
||||
Uint8List _testPng({int width = 8, int height = 4}) =>
|
||||
Uint8List.fromList(img.encodePng(img.Image(width: width, height: height)));
|
||||
|
||||
void main() {
|
||||
Future<Future<PhotoPreviewResult?>> openPreview(
|
||||
WidgetTester tester,
|
||||
Uint8List bytes,
|
||||
) async {
|
||||
late Future<PhotoPreviewResult?> pushed;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('cs'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () {
|
||||
pushed = Navigator.of(context).push<PhotoPreviewResult>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PhotoPreviewScreen(
|
||||
bytes: bytes,
|
||||
fileName: 'test.png',
|
||||
mime: 'image/png',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('otevřít'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('otevřít'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
// Reálný async decode obrázku — počkat, až zmizí spinner.
|
||||
for (var i = 0; i < 50; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await tester.pump();
|
||||
if (find.byType(CircularProgressIndicator).evaluate().isEmpty) break;
|
||||
}
|
||||
expect(
|
||||
find.byType(CircularProgressIndicator),
|
||||
findsNothing,
|
||||
reason: 'obrázek se nedekódoval',
|
||||
);
|
||||
return pushed;
|
||||
}
|
||||
|
||||
Future<PhotoPreviewResult?> settleResult(
|
||||
WidgetTester tester,
|
||||
Future<PhotoPreviewResult?> pushed,
|
||||
) async {
|
||||
// Render + pop jsou reálné async operace — pumpovat, dokud route žije.
|
||||
// pump s duration posouvá i pop animaci, jinak by route nikdy nezmizel.
|
||||
for (var i = 0; i < 100; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
if (find.byType(PhotoPreviewScreen).evaluate().isEmpty) break;
|
||||
}
|
||||
return pushed;
|
||||
}
|
||||
|
||||
testWidgets('zrušení nic nepošle', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
final pushed = await openPreview(tester, _testPng());
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
final result = await settleResult(tester, pushed);
|
||||
expect(find.byType(PhotoPreviewScreen), findsNothing);
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('odeslání bez úprav vrací původní bajty + popisek', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.runAsync(() async {
|
||||
final bytes = _testPng();
|
||||
final pushed = await openPreview(tester, bytes);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ahoj z testu');
|
||||
await tester.tap(find.byTooltip('Poslat'));
|
||||
final result = await settleResult(tester, pushed);
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.bytes, same(bytes)); // beze změn = žádný re-encode tady
|
||||
expect(result.caption, 'ahoj z testu');
|
||||
expect(result.fileName, 'test.png');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('rotace se propíše do odeslaných bajtů', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
final pushed = await openPreview(tester, _testPng(width: 8, height: 4));
|
||||
|
||||
await tester.tap(find.byTooltip('Otočit'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Poslat'));
|
||||
final result = await settleResult(tester, pushed);
|
||||
|
||||
expect(result, isNotNull);
|
||||
final decoded = img.decodeImage(result!.bytes)!;
|
||||
expect(decoded.width, 4); // 8×4 otočeno o 90° → 4×8
|
||||
expect(decoded.height, 8);
|
||||
expect(result.caption, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
|
||||
/// Obrazovka Nastavení se musí vykreslit bez výjimky v build —
|
||||
/// pád tady znamená pro uživatele „Tahle obrazovka se nevykreslila".
|
||||
class _FakeIdentityNotifier extends IdentityNotifier {
|
||||
_FakeIdentityNotifier(this._identity);
|
||||
final LocalIdentity? _identity;
|
||||
|
||||
@override
|
||||
Future<LocalIdentity?> build() async => _identity;
|
||||
}
|
||||
|
||||
LocalIdentity _identityWithName(String name) => LocalIdentity(
|
||||
displayName: name,
|
||||
identityPrivateKey: Uint8List(32),
|
||||
identityPublicKey: Uint8List(33),
|
||||
createdAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
Widget _app(LocalIdentity? identity) => ProviderScope(
|
||||
overrides: [
|
||||
identityProvider.overrideWith(() => _FakeIdentityNotifier(identity)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('cs'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const SettingsScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('vykreslí se s běžnou identitou', (tester) async {
|
||||
await tester.pumpWidget(_app(_identityWithName('Alice')));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('vykreslí se s prázdným jménem identity', (tester) async {
|
||||
await tester.pumpWidget(_app(_identityWithName('')));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('vykreslí se bez identity', (tester) async {
|
||||
await tester.pumpWidget(_app(null));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user