8081d98c67
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).
342 lines
11 KiB
Dart
342 lines
11 KiB
Dart
/// 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|