Šepot — end-to-end encrypted messenger

Initial public release. Anonymous on-device identity (no phone number,
e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double
Ratchet, sealed sender) via libsignal, a metadata-minimizing relay,
group chats, disappearing messages, encrypted local storage, optional
Tor transport, and a UI localized into Czech, English, German, Russian,
and Italian.

Monorepo: Flutter client (app/), Rust relay + wire types (rust/),
protobuf definitions (proto/), self-host deploy (deploy/).

Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
/// Nahlášení zneužití (Play UGC + CSAE, Apple Guideline 1.2).
///
/// Do verze 0.2.0 „nahlášení" jen zablokovalo kontakt a nikam nedošlo —
/// obchody ale vyžadují cestu, kterou se o problému dozví provozovatel.
/// Tahle obrazovka je ta cesta.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/errors.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/safety/report_service.dart';
import 'package:sepot/core/theme/app_theme.dart';
class ReportScreen extends StatefulWidget {
const ReportScreen({
super.key,
this.reportedFingerprint,
this.reportedName,
});
/// Otisk nahlášeného kontaktu (SEP-…), když se sem přišlo z konverzace.
final String? reportedFingerprint;
final String? reportedName;
@override
State<ReportScreen> createState() => _ReportScreenState();
}
class _ReportScreenState extends State<ReportScreen> {
final _popis = TextEditingController();
final _kontakt = TextEditingController();
ReportCategory _kategorie = ReportCategory.harassment;
bool _prilozitOtisk = true;
bool _odesila = false;
bool _hotovo = false;
@override
void dispose() {
_popis.dispose();
_kontakt.dispose();
super.dispose();
}
bool get _maKontakt =>
widget.reportedFingerprint != null &&
widget.reportedFingerprint!.isNotEmpty;
Future<void> _odeslat() async {
setState(() => _odesila = true);
try {
await const ReportService().submit(
category: _kategorie,
description: _popis.text,
reportedFingerprint:
_maKontakt && _prilozitOtisk ? widget.reportedFingerprint : null,
replyTo: _kontakt.text,
);
if (mounted) setState(() => _hotovo = true);
} on SepotUserError catch (e) {
if (!mounted) return;
setState(() => _odesila = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(describeError(context.l10n, e)),
backgroundColor: SepotColors.error,
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(context.l10n.reportTitle)),
body: _hotovo ? _uspech() : _formular(),
);
}
Widget _uspech() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline,
size: 56, color: SepotColors.sage),
const SizedBox(height: 20),
Text(
context.l10n.reportSubmitted,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 22,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 12),
Text(
_kategorie.isChildSafety
? context.l10n.reportSubmittedChildSafety
: context.l10n.reportSubmittedThanks,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 14,
height: 1.5,
color: SepotColors.textSecondary,
),
),
const SizedBox(height: 28),
FilledButton(
onPressed: () => context.pop(),
child: Text(context.l10n.commonDone),
),
],
),
),
);
}
Widget _formular() {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 40),
children: [
_panel(
icon: Icons.lock_outline,
barva: SepotColors.mist,
text: context.l10n.reportPanelE2eNotice,
),
const SizedBox(height: 10),
if (_kategorie.isChildSafety) ...[
_panel(
icon: Icons.gpp_maybe_outlined,
barva: SepotColors.error,
text: context.l10n.reportPanelCsamWarning,
),
const SizedBox(height: 10),
],
_panel(
icon: Icons.public,
barva: SepotColors.warning,
text: context.l10n.reportPanelIpNotice,
),
const SizedBox(height: 24),
_nadpis(context.l10n.reportCategoryHeading),
...ReportCategory.values.map(_volba),
const SizedBox(height: 20),
_nadpis(context.l10n.reportDescriptionHeading),
const SizedBox(height: 8),
TextField(
controller: _popis,
maxLines: 6,
maxLength: 4000,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
filled: false,
hintText: _hintKategorie(context.l10n, _kategorie),
border: const OutlineInputBorder(),
),
),
if (_maKontakt) ...[
const SizedBox(height: 4),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _prilozitOtisk,
activeTrackColor: SepotColors.ember,
onChanged: (v) => setState(() => _prilozitOtisk = v),
title: Text(
widget.reportedName == null
? context.l10n.reportAttachContactId
: context.l10n
.reportAttachContactIdNamed(widget.reportedName!),
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 14,
color: SepotColors.textPrimary),
),
subtitle: Text(
widget.reportedFingerprint!,
style: const TextStyle(
fontFamily: kFontMono,
fontSize: 12,
color: SepotColors.textMuted),
),
),
],
const SizedBox(height: 12),
_nadpis(context.l10n.reportReplyToHeading),
const SizedBox(height: 8),
TextField(
controller: _kontakt,
keyboardType: TextInputType.emailAddress,
inputFormatters: [LengthLimitingTextInputFormatter(200)],
decoration: InputDecoration(
filled: false,
hintText: context.l10n.reportReplyToHint,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 28),
FilledButton(
onPressed: _odesila ? null : _odeslat,
child: _odesila
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(context.l10n.reportSubmitButton),
),
const SizedBox(height: 20),
Center(
child: TextButton(
onPressed: () => launchUrl(
Uri.parse(AppConstants.childSafetyUrl),
mode: LaunchMode.externalApplication,
),
child: Text(context.l10n.settingsChildSafetyTitle),
),
),
],
);
}
String _labelKategorie(AppLocalizations l10n, ReportCategory k) =>
switch (k) {
ReportCategory.childSafety => l10n.reportCategoryChildSafety,
ReportCategory.harassment => l10n.reportCategoryHarassment,
ReportCategory.spam => l10n.reportCategorySpam,
ReportCategory.fraud => l10n.reportCategoryFraud,
ReportCategory.other => l10n.reportCategoryOther,
};
String _hintKategorie(AppLocalizations l10n, ReportCategory k) =>
switch (k) {
ReportCategory.childSafety => l10n.reportCategoryChildSafetyHint,
ReportCategory.harassment => l10n.reportCategoryHarassmentHint,
ReportCategory.spam => l10n.reportCategorySpamHint,
ReportCategory.fraud => l10n.reportCategoryFraudHint,
ReportCategory.other => l10n.reportCategoryOtherHint,
};
Widget _nadpis(String t) => Text(
t,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: SepotColors.textMuted,
),
);
Widget _volba(ReportCategory k) {
final vybrano = k == _kategorie;
return InkWell(
onTap: () => setState(() => _kategorie = k),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
vybrano ? Icons.radio_button_checked : Icons.radio_button_off,
size: 20,
color: vybrano ? SepotColors.ember : SepotColors.textMuted,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_labelKategorie(context.l10n, k),
style: TextStyle(
fontFamily: kFontSans,
fontSize: 15,
fontWeight: vybrano ? FontWeight.w600 : FontWeight.w400,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
_hintKategorie(context.l10n, k),
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 12,
height: 1.4,
color: SepotColors.textMuted,
),
),
],
),
),
],
),
),
);
}
Widget _panel({
required IconData icon,
required Color barva,
required String text,
}) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: SepotColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: SepotColors.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: barva),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 13,
height: 1.5,
color: SepotColors.textSecondary,
),
),
),
],
),
);
}
}