Š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
@@ -0,0 +1,191 @@
/// Změna PINu ve třech krocích (starý → nový → potvrzení).
///
/// PIN se neověřuje proti hashi, ale unwrapem DEK (Argon2id). Každý pokus
/// proto stojí ~2 s, což je zároveň jediná potřebná ochrana proti hádání.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/crypto/dek_wrap.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/core/theme/app_theme.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/pin_keypad.dart';
enum _Krok { stary, novy, potvrzeni }
class ChangePinScreen extends ConsumerStatefulWidget {
const ChangePinScreen({super.key});
@override
ConsumerState<ChangePinScreen> createState() => _ChangePinScreenState();
}
class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
_Krok _krok = _Krok.stary;
String _pin = '';
String? _staryPin;
String? _novyPin;
bool _pracuje = false;
String? _chyba;
(String, String) _titulky(AppLocalizations l10n) => switch (_krok) {
_Krok.stary =>
(l10n.pinChangeCurrentTitle, l10n.pinChangeCurrentSubtitle),
_Krok.novy => (l10n.pinChangeNewTitle, l10n.pinChangeNewSubtitle),
_Krok.potvrzeni =>
(l10n.pinChangeConfirmTitle, l10n.pinChangeConfirmSubtitle),
};
void _reset(String chyba) {
setState(() {
_chyba = chyba;
_pin = '';
_staryPin = null;
_novyPin = null;
_krok = _Krok.stary;
_pracuje = false;
});
}
void _klavesa(String k) {
if (_pracuje) return;
setState(() {
_chyba = null;
if (k == 'DEL') {
if (_pin.isNotEmpty) _pin = _pin.substring(0, _pin.length - 1);
return;
}
if (_pin.length < 6) _pin += k;
});
if (_pin.length == 6) _hotovoSest();
}
Future<void> _hotovoSest() async {
final storage = ref.read(storageProvider);
switch (_krok) {
case _Krok.stary:
setState(() => _pracuje = true);
final ok = await storage.verifyPin(_pin);
if (!mounted) return;
if (!ok) {
_reset(context.l10n.pinWrong);
return;
}
setState(() {
_staryPin = _pin;
_pin = '';
_krok = _Krok.novy;
_pracuje = false;
});
case _Krok.novy:
if (_pin == _staryPin) {
setState(() {
_chyba = context.l10n.pinChangeMustDiffer;
_pin = '';
});
return;
}
setState(() {
_novyPin = _pin;
_pin = '';
_krok = _Krok.potvrzeni;
});
case _Krok.potvrzeni:
if (_pin != _novyPin) {
setState(() {
_chyba = context.l10n.pinMismatch;
_pin = '';
_novyPin = null;
_krok = _Krok.novy;
});
return;
}
setState(() => _pracuje = true);
try {
await storage.changePin(oldPin: _staryPin!, newPin: _novyPin!);
} on DecoyPinConflict catch (e) {
if (!mounted) return;
_reset(describeError(context.l10n, e));
return;
} on WrongPinException {
if (!mounted) return;
_reset(context.l10n.pinWrong);
return;
} catch (_) {
if (!mounted) return;
_reset(context.l10n.pinChangeFailed);
return;
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.pinChanged)),
);
context.pop();
}
}
@override
Widget build(BuildContext context) {
final (titulek, popis) = _titulky(context.l10n);
return Scaffold(
appBar: AppBar(title: Text(context.l10n.settingsChangePinTitle)),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
titulek,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 22,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
popis,
style: const TextStyle(
fontSize: 14, color: SepotColors.textSecondary),
),
const SizedBox(height: 40),
PinDots(filled: _pin.length),
if (_chyba != null) ...[
const SizedBox(height: 16),
Text(
_chyba!,
textAlign: TextAlign.center,
style:
const TextStyle(color: SepotColors.error, fontSize: 14),
),
],
if (_pracuje) ...[
const SizedBox(height: 16),
// Argon2id (64 MiB, t=3) — pár sekund je záměr, ne prodleva.
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: SepotColors.ember),
),
],
const SizedBox(height: 40),
PinKeypad(onKey: _klavesa, enabled: !_pracuje),
],
),
),
),
);
}
}
+240
View File
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/sepot_logo.dart';
/// Odemykací obrazovka s PINem (+ volitelná biometrie)
class PinLockScreen extends ConsumerStatefulWidget {
const PinLockScreen({super.key});
@override
ConsumerState<PinLockScreen> createState() => _PinLockScreenState();
}
class _PinLockScreenState extends ConsumerState<PinLockScreen> {
String _pin = '';
String? _error;
bool _isVerifying = false;
bool _biometricsAvailable = false;
@override
void initState() {
super.initState();
// Biometrie zapnutá → rovnou nabídnout systémovou výzvu
WidgetsBinding.instance.addPostFrameCallback((_) => _tryBiometrics());
}
Future<void> _tryBiometrics() async {
final unlockReason = context.l10n.pinUnlockTitle;
final storage = ref.read(storageProvider);
if (!await storage.isBiometricsEnabled()) return;
if (mounted) setState(() => _biometricsAvailable = true);
try {
final ok = await LocalAuthentication().authenticate(
localizedReason: unlockReason,
options: const AuthenticationOptions(stickyAuth: true),
);
if (!ok) return; // uživatel zrušil → zůstává PIN
await storage.unlockWithBiometrics();
if (mounted) context.go('/chats');
} catch (_) {
// biometrie nedostupná/selhala — PIN je vždy fallback
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SepotLogo(size: 60, showText: false),
const SizedBox(height: 24),
Text(
context.l10n.pinUnlockTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 24,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
context.l10n.pinEnterPin,
style: const TextStyle(color: SepotColors.textSecondary),
),
const SizedBox(height: 48),
// PIN tečky
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(6, (i) {
return Container(
width: 16,
height: 16,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i < _pin.length
? SepotColors.ember
: SepotColors.border,
),
);
}),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
style: const TextStyle(color: SepotColors.error, fontSize: 14),
),
],
if (_isVerifying) ...[
const SizedBox(height: 16),
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SepotColors.ember,
),
),
],
const SizedBox(height: 48),
_buildNumpad(),
if (_biometricsAvailable) ...[
const SizedBox(height: 16),
TextButton.icon(
onPressed: _isVerifying ? null : _tryBiometrics,
icon: const Icon(Icons.fingerprint,
color: SepotColors.ember),
label: Text(
context.l10n.pinUnlockWithBiometrics,
style: const TextStyle(color: SepotColors.textSecondary),
),
),
],
],
),
),
),
);
}
Widget _buildNumpad() {
return Column(
children: [
for (final row in [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['', '0', 'DEL'],
])
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: row.map((key) {
if (key.isEmpty) return const SizedBox(width: 80, height: 64);
return _buildKey(key);
}).toList(),
),
),
],
);
}
Widget _buildKey(String key) {
final isDelete = key == 'DEL';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: InkWell(
onTap: () => _onKeyPress(key),
borderRadius: BorderRadius.circular(32),
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDelete ? Colors.transparent : SepotColors.surfaceVariant,
border: isDelete ? null : Border.all(color: SepotColors.border),
),
child: Center(
child: isDelete
? const Icon(
Icons.backspace_outlined,
color: SepotColors.textSecondary,
size: 24,
)
: Text(
key,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 24,
fontWeight: FontWeight.w500,
color: SepotColors.textPrimary,
),
),
),
),
),
);
}
void _onKeyPress(String key) {
if (_isVerifying) return;
HapticFeedback.lightImpact();
setState(() {
_error = null;
if (key == 'DEL') {
if (_pin.isNotEmpty) {
_pin = _pin.substring(0, _pin.length - 1);
}
} else if (_pin.length < 6) {
_pin += key;
if (_pin.length == 6) {
_verifyPin();
}
}
});
}
Future<void> _verifyPin() async {
setState(() => _isVerifying = true);
try {
// „Ověření" PINu = Argon2id unwrap DEK + otevření SQLCipher DB.
// Špatný PIN = GCM tag nesedí = DB zůstává nečitelná.
await ref.read(storageProvider).unlockWithPin(_pin);
if (mounted) context.go('/chats');
} on WrongPinException {
setState(() {
_error = context.l10n.pinWrong2;
_pin = '';
_isVerifying = false;
});
HapticFeedback.heavyImpact();
} on StateError {
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
if (mounted) context.go('/onboarding');
} catch (e) {
setState(() {
_error = context.l10n.pinUnlockError;
_pin = '';
_isVerifying = false;
});
}
}
}
+253
View File
@@ -0,0 +1,253 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/storage_provider.dart';
/// Nastavení PINu (6 číslic)
class PinSetupScreen extends ConsumerStatefulWidget {
const PinSetupScreen({super.key});
@override
ConsumerState<PinSetupScreen> createState() => _PinSetupScreenState();
}
class _PinSetupScreenState extends ConsumerState<PinSetupScreen> {
String _pin = '';
String? _firstPin;
bool _isConfirming = false;
bool _isSaving = false;
String? _error;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SepotColors.ember.withValues(alpha: 0.1),
),
child: const Icon(
Icons.pin_rounded,
size: 32,
color: SepotColors.ember,
),
),
const SizedBox(height: 24),
Text(
_isConfirming
? context.l10n.pinConfirmTitle
: context.l10n.pinSetupTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 24,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
_isConfirming
? context.l10n.pinConfirmSubtitle
: context.l10n.pinSetupSubtitle,
style: const TextStyle(
fontSize: 14,
color: SepotColors.textSecondary,
),
),
const SizedBox(height: 48),
// PIN tečky
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(6, (i) {
return Container(
width: 16,
height: 16,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i < _pin.length
? SepotColors.ember
: SepotColors.border,
border: Border.all(
color: i < _pin.length
? SepotColors.ember
: SepotColors.border,
width: 2,
),
),
);
}),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
style: const TextStyle(
color: SepotColors.error,
fontSize: 14,
),
),
],
if (_isSaving) ...[
const SizedBox(height: 16),
// Argon2id wrap DEK běží (~sekundy, memory-hard záměrně)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SepotColors.ember,
),
),
],
const SizedBox(height: 48),
// Numerická klávesnice
_buildNumpad(),
],
),
),
),
);
}
Widget _buildNumpad() {
return Column(
children: [
for (final row in [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['', '0', 'DEL'],
])
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: row.map((key) {
if (key.isEmpty) {
return const SizedBox(width: 80, height: 64);
}
return _buildKey(key);
}).toList(),
),
),
],
);
}
Widget _buildKey(String key) {
final isDelete = key == 'DEL';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: InkWell(
onTap: () => _onKeyPress(key),
borderRadius: BorderRadius.circular(32),
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDelete
? Colors.transparent
: SepotColors.surfaceVariant,
border: isDelete
? null
: Border.all(color: SepotColors.border),
),
child: Center(
child: isDelete
? const Icon(
Icons.backspace_outlined,
color: SepotColors.textSecondary,
size: 24,
)
: Text(
key,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 24,
fontWeight: FontWeight.w500,
color: SepotColors.textPrimary,
),
),
),
),
),
);
}
void _onKeyPress(String key) {
if (_isSaving) return;
HapticFeedback.lightImpact();
setState(() {
_error = null;
if (key == 'DEL') {
if (_pin.isNotEmpty) {
_pin = _pin.substring(0, _pin.length - 1);
}
} else if (_pin.length < 6) {
_pin += key;
if (_pin.length == 6) {
_onPinComplete();
}
}
});
}
Future<void> _onPinComplete() async {
if (_firstPin == null) {
// První zadání PINu
setState(() {
_firstPin = _pin;
_pin = '';
_isConfirming = true;
});
} else {
// Potvrzení PINu
if (_pin == _firstPin) {
// PINy se shodují — zabal DEK za Argon2id(PIN); od téhle chvíle
// je PIN klíčem k databázi, ne vizuálním zámkem
setState(() => _isSaving = true);
try {
await ref.read(storageProvider).enablePin(_pin);
} catch (e) {
setState(() {
_error = context.l10n.pinSetupFailed;
_pin = '';
_firstPin = null;
_isConfirming = false;
_isSaving = false;
});
return;
}
if (mounted) {
context.go('/chats');
}
} else {
// PINy se neshodují
setState(() {
_error = context.l10n.pinMismatchRetry;
_pin = '';
_firstPin = null;
_isConfirming = false;
});
}
}
}
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/features/chat_list/chat_list_tile.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/conversations_provider.dart';
import 'package:sepot/providers/groups_provider.dart';
import 'package:sepot/providers/identity_provider.dart';
/// Archivované konverzace — stejné dlaždice, long-press nabídne odarchivovat.
class ArchivedChatsScreen extends ConsumerWidget {
const ArchivedChatsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final conversationsAsync = ref.watch(conversationsProvider);
final contactsAsync = ref.watch(contactsProvider);
final groupsAsync = ref.watch(groupsProvider);
final myId = ref.watch(identityProvider).valueOrNull?.id ?? '';
return Scaffold(
appBar: AppBar(title: Text(context.l10n.archiveTitle)),
body: conversationsAsync.when(
data: (conversations) {
final archived = conversations.where((c) => c.archived).toList();
if (archived.isEmpty) {
return Center(
child: Text(context.l10n.archiveEmpty,
style: const TextStyle(color: SepotColors.textSecondary)),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: archived.length,
itemBuilder: (context, index) {
final conv = archived[index];
final group = groupsAsync.valueOrNull
?.where((g) => g.id == conv.contactId)
.firstOrNull;
final contact = contactsAsync.valueOrNull
?.where((c) => c.publicIdentity.id == conv.contactId)
.firstOrNull;
final name = group?.name ??
contact?.displayName ??
context.l10n.commonUnknownContact;
return ChatListTile(
conversation: conv,
contactName: name,
myId: myId,
isGroup: group != null,
onTap: () => context.push('/conversation/${conv.contactId}'),
onLongPress: () => _actions(context, ref, conv),
);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
),
error: (e, _) =>
Center(child: Text(describeError(context.l10n, e))),
),
);
}
void _actions(BuildContext context, WidgetRef ref, Conversation conv) {
final notifier = ref.read(conversationsProvider.notifier);
showModalBottomSheet<void>(
context: context,
backgroundColor: SepotColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => SafeArea(
child: ListTile(
leading: const Icon(Icons.unarchive_outlined),
title: Text(context.l10n.archiveUnarchive),
onTap: () {
Navigator.pop(ctx);
notifier.setArchived(conv.contactId, false);
},
),
),
);
}
}
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/features/chat_list/chat_list_tile.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/conversations_provider.dart';
import 'package:sepot/providers/groups_provider.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/push_provider.dart';
import 'package:sepot/widgets/e2e_badge.dart';
/// Hlavní obrazovka — seznam konverzací
class ChatListScreen extends ConsumerWidget {
const ChatListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Nastartuje krypto + spojení s relayem (idempotentní)
ref.watch(messagingStartProvider);
// Zaregistruje push callbacky (po startu messagingu) — obnoví registraci,
// pokud už distributor byl dřív vybraný. Push zapíná uživatel v nastavení.
ref.watch(pushInitProvider);
final conversationsAsync = ref.watch(conversationsProvider);
final contactsAsync = ref.watch(contactsProvider);
final groupsAsync = ref.watch(groupsProvider);
final identityAsync = ref.watch(identityProvider);
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.commonAppName,
style: const TextStyle(
fontFamily: kFontSans,
fontWeight: FontWeight.w700,
fontSize: 22,
),
),
actions: [
IconButton(
icon: const Icon(Icons.search_rounded),
onPressed: () {
// TODO: Implementovat vyhledávání
},
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: conversationsAsync.when(
data: (conversations) {
final active = conversations.where((c) => !c.archived).toList();
final archived = conversations.where((c) => c.archived).toList();
if (active.isEmpty && archived.isEmpty) {
return _buildEmptyState(context);
}
final hasArchive = archived.isNotEmpty;
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: active.length + (hasArchive ? 1 : 0),
itemBuilder: (context, index) {
if (hasArchive && index == 0) {
return ListTile(
leading: const Icon(Icons.archive_outlined,
color: SepotColors.textMuted),
title: Text(context.l10n.chatListArchivedCount(archived.length),
style: const TextStyle(color: SepotColors.textSecondary)),
onTap: () => context.push('/archived'),
);
}
final conv = active[index - (hasArchive ? 1 : 0)];
final group = groupsAsync.valueOrNull
?.where((g) => g.id == conv.contactId)
.firstOrNull;
final contact = contactsAsync.valueOrNull
?.where((c) => c.publicIdentity.id == conv.contactId)
.firstOrNull;
final myId = identityAsync.valueOrNull?.id ?? '';
final name = group?.name ??
contact?.displayName ??
context.l10n.commonUnknownContact;
return ChatListTile(
conversation: conv,
contactName: name,
myId: myId,
isGroup: group != null,
onTap: () => context.push('/conversation/${conv.contactId}'),
onLongPress: () => _showChatActions(context, ref, conv, name),
);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
),
error: (e, _) =>
Center(child: Text(describeError(context.l10n, e))),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/contacts'),
child: const Icon(Icons.chat_bubble_outline_rounded),
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(48),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SepotColors.ember.withValues(alpha: 0.1),
),
child: const Icon(
Icons.chat_bubble_outline_rounded,
size: 40,
color: SepotColors.ember,
),
),
const SizedBox(height: 24),
Text(
context.l10n.chatListEmptyTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 18,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
context.l10n.chatListEmptySubtitle,
style: const TextStyle(color: SepotColors.textSecondary),
),
const SizedBox(height: 32),
const E2EBadge(),
],
),
),
);
}
/// Akce nad konverzací (long-press): ztlumit / archivovat.
void _showChatActions(
BuildContext context, WidgetRef ref, Conversation conv, String name) {
final notifier = ref.read(conversationsProvider.notifier);
showModalBottomSheet<void>(
context: context,
backgroundColor: SepotColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(conv.isMuted
? Icons.notifications_active_outlined
: Icons.notifications_off_outlined),
title: Text(conv.isMuted
? context.l10n.chatListUnmute
: context.l10n.chatListMute),
onTap: () {
Navigator.pop(ctx);
notifier.setMuted(conv.contactId, !conv.isMuted);
},
),
ListTile(
leading: const Icon(Icons.archive_outlined),
title: Text(context.l10n.chatListArchive),
onTap: () {
Navigator.pop(ctx);
notifier.setArchived(conv.contactId, true);
},
),
],
),
),
);
}
}
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/message.dart';
import 'package:sepot/widgets/avatar.dart';
/// Dlaždice konverzace v seznamu chatů. Sdílená hlavním i archivovaným
/// seznamem, aby se logika (fajfky, náhled, ztlumení) nerozešla.
class ChatListTile extends StatelessWidget {
final Conversation conversation;
final String contactName;
final String myId;
final bool isGroup;
final VoidCallback onTap;
final VoidCallback? onLongPress;
const ChatListTile({
super.key,
required this.conversation,
required this.contactName,
required this.myId,
this.isGroup = false,
required this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
final lastMsg = conversation.lastMessage;
final unread = conversation.unreadCount(myId);
final muted = conversation.isMuted;
return InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
isGroup
? Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SepotColors.surfaceVariant,
border: Border.all(color: SepotColors.border),
),
child: const Icon(Icons.group_rounded,
color: SepotColors.ember, size: 24),
)
: SepotAvatar(name: contactName),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
contactName,
style: TextStyle(
fontSize: 16,
fontWeight:
unread > 0 ? FontWeight.w700 : FontWeight.w500,
color: SepotColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
if (muted)
const Padding(
padding: EdgeInsets.only(right: 6),
child: Icon(Icons.notifications_off,
size: 15, color: SepotColors.textMuted),
),
if (lastMsg != null)
Text(
_formatTime(context, lastMsg.timestamp),
style: TextStyle(
fontSize: 12,
color: unread > 0 && !muted
? SepotColors.ember
: SepotColors.textMuted,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
if (lastMsg != null && lastMsg.isMine(myId))
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(
switch (lastMsg.status) {
MessageStatus.sending => Icons.schedule,
MessageStatus.sent => Icons.done,
MessageStatus.delivered => Icons.done_all,
MessageStatus.read => Icons.done_all,
MessageStatus.failed => Icons.error_outline,
},
size: 16,
color: lastMsg.status == MessageStatus.failed
? SepotColors.error
: lastMsg.status == MessageStatus.read
? SepotColors.mist
: SepotColors.textMuted,
),
),
Expanded(
child: Text(
_getLastMessageText(context.l10n, lastMsg),
style: TextStyle(
fontSize: 14,
color: unread > 0
? SepotColors.textPrimary
: SepotColors.textSecondary,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
if (unread > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
// Ztlumené = šedý badge (počet ano, důraz ne)
color: muted
? SepotColors.textMuted
: SepotColors.ember,
borderRadius: BorderRadius.circular(12),
),
child: Text(
unread.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
],
),
),
);
}
static String _getLastMessageText(AppLocalizations l10n, Message? msg) {
if (msg == null) return l10n.chatListNewConversation;
if (msg.isDeleted) return l10n.chatMessageDeleted;
if (msg.attachment?.isAudio == true) return l10n.chatVoiceMessagePreview;
switch (msg.type) {
case MessageType.text:
return msg.content ?? '';
case MessageType.image:
return msg.content?.isNotEmpty == true
? l10n.chatListPhotoWithCaptionPreview(msg.content!)
: l10n.chatPhotoPreview;
case MessageType.file:
return l10n.chatListFilePreview(msg.fileName ?? l10n.commonFile);
case MessageType.readReceipt:
return l10n.chatListReadReceiptPreview;
default:
return '';
}
}
static String _formatTime(BuildContext context, DateTime time) {
final locale = Localizations.localeOf(context).toString();
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inMinutes < 1) return context.l10n.chatListTimeNow;
if (diff.inHours < 1) return context.l10n.chatListTimeMinutes(diff.inMinutes);
if (diff.inDays < 1) return DateFormat.Hm(locale).format(time);
if (diff.inDays < 7) return DateFormat.E(locale).format(time);
return DateFormat.Md(locale).format(time);
}
}
@@ -0,0 +1,297 @@
/// Ověření kontaktu — safety number.
///
/// Obě strany vidí STEJNÝCH 60 číslic (libsignal Fingerprint, menší
/// polovina první). Ověření: naskenovat QR protistrany (libsignal
/// porovná klíče křížem) nebo přečíst číslice jiným kanálem a potvrdit
/// ručně. Shoda → kontakt „ověřen"; neshoda → varování před MITM.
library;
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:sepot/core/crypto/safety_number.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
/// Prefix QR obsahu — odlišuje ověřovací QR od kontaktního `sepot://add/`.
const _verifyQrPrefix = 'sepot-verify:';
class ContactVerifyScreen extends ConsumerStatefulWidget {
const ContactVerifyScreen({super.key, required this.contactId});
final String contactId;
@override
ConsumerState<ContactVerifyScreen> createState() =>
_ContactVerifyScreenState();
}
class _ContactVerifyScreenState extends ConsumerState<ContactVerifyScreen> {
SafetyNumber? _number;
String? _numberForKey; // cache per identity klíč kontaktu
SafetyNumber? _safetyNumber(Contact contact, Uint8List localKey) {
final keyId = contact.publicIdentity.fingerprint.display;
if (_number == null || _numberForKey != keyId) {
_number = SafetyNumber.compute(
localIdentityKey: localKey,
remoteIdentityKey: contact.publicIdentity.identityKey,
);
_numberForKey = keyId;
}
return _number;
}
Future<void> _setVerified(Contact contact, bool verified) async {
contact.verified = verified;
await ref.read(storageProvider).saveContact(contact);
ref.invalidate(contactsProvider);
}
Future<void> _scanAndCompare(Contact contact, SafetyNumber mine) async {
final scanned = await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => const _VerifyScanScreen()),
);
if (scanned == null || !mounted) return;
var match = false;
if (scanned.startsWith(_verifyQrPrefix)) {
try {
final theirs = Uint8List.fromList(
base64Decode(scanned.substring(_verifyQrPrefix.length)));
match = SafetyNumber.compare(mine.scannable, theirs);
} on FormatException {
match = false;
}
}
if (match) {
await _setVerified(contact, true);
}
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(
match ? Icons.verified : Icons.gpp_bad_outlined,
color: match ? SepotColors.sage : SepotColors.error,
size: 40,
),
title: Text(
match ? ctx.l10n.verifyVerified : ctx.l10n.verifyMismatchTitle),
content: Text(
match
? ctx.l10n.verifyMatchBody(contact.displayName)
: ctx.l10n.verifyMismatchBody,
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonUnderstood),
),
],
),
);
}
Future<void> _confirmManual(Contact contact) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.verifyManualConfirmTitle),
content: Text(ctx.l10n.verifyManualConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(ctx.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(ctx.l10n.verifyManualConfirmAction),
),
],
),
);
if (confirmed == true) await _setVerified(contact, true);
}
@override
Widget build(BuildContext context) {
final contact = ref
.watch(contactsProvider)
.valueOrNull
?.where((c) => c.id == widget.contactId)
.firstOrNull;
final identity = ref.watch(identityProvider).valueOrNull;
if (contact == null || identity == null) {
return Scaffold(
appBar: AppBar(title: Text(context.l10n.verifyTitle)),
body: const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
),
);
}
final number = _safetyNumber(contact, identity.identityPublicKey)!;
final qrData = '$_verifyQrPrefix${base64Encode(number.scannable)}';
return Scaffold(
appBar: AppBar(title: Text(context.l10n.verifyTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (contact.verified)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: SepotColors.sage.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.verified,
color: SepotColors.sage, size: 18),
const SizedBox(width: 8),
Text(context.l10n.verifyContactVerifiedBanner,
style: const TextStyle(color: SepotColors.sage)),
],
),
),
Text(
context.l10n.verifyIntro(contact.displayName),
style: const TextStyle(
color: SepotColors.textSecondary, height: 1.4),
),
const SizedBox(height: 24),
Center(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: QrImageView(
data: qrData,
size: 180,
backgroundColor: Colors.white,
),
),
),
const SizedBox(height: 24),
// 60 číslic ve 12 blocích po 5 (4 řádky × 3 sloupce)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: SepotColors.surfaceVariant,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
for (var row = 0; row < 4; row++)
Padding(
padding: EdgeInsets.only(bottom: row < 3 ? 10 : 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (var col = 0; col < 3; col++)
Text(
number.groups[row * 3 + col],
style: SepotTheme.cryptoText.copyWith(
fontSize: 17,
letterSpacing: 1.5,
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => _scanAndCompare(contact, number),
icon: const Icon(Icons.qr_code_scanner_rounded),
label: Text(context.l10n.verifyScanButton),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _confirmManual(contact),
icon: const Icon(Icons.checklist_rounded),
label: Text(context.l10n.verifyManualButton),
),
if (contact.verified) ...[
const SizedBox(height: 12),
TextButton(
onPressed: () => _setVerified(contact, false),
child: Text(
context.l10n.verifyUnverifyButton,
style: const TextStyle(color: SepotColors.textMuted),
),
),
],
],
),
),
);
}
}
/// Minimalistický skener jen pro ověřovací QR — vrací syrový obsah kódu.
class _VerifyScanScreen extends StatefulWidget {
const _VerifyScanScreen();
@override
State<_VerifyScanScreen> createState() => _VerifyScanScreenState();
}
class _VerifyScanScreenState extends State<_VerifyScanScreen> {
final _controller = MobileScannerController(
detectionSpeed: DetectionSpeed.normal,
facing: CameraFacing.back,
);
bool _done = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_done) return;
final raw = capture.barcodes.firstOrNull?.rawValue;
if (raw == null) return;
_done = true;
Navigator.of(context).pop(raw);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.verifyScanScreenTitle),
actions: [
IconButton(
icon: const Icon(Icons.flash_on),
onPressed: () => _controller.toggleTorch(),
),
],
),
body: MobileScanner(controller: _controller, onDetect: _onDetect),
);
}
}
@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/identity.dart';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/widgets/avatar.dart';
/// Obrazovka kontaktů
class ContactsScreen extends ConsumerStatefulWidget {
const ContactsScreen({super.key});
@override
ConsumerState<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends ConsumerState<ContactsScreen> {
final _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final contactsAsync = ref.watch(contactsProvider);
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.contactsTitle),
actions: [
IconButton(
icon: const Icon(Icons.group_add_outlined),
onPressed: () => context.push('/create-group'),
tooltip: context.l10n.groupsNewGroupTitle,
),
IconButton(
icon: const Icon(Icons.qr_code_scanner_rounded),
onPressed: () => context.push('/qr-scan'),
tooltip: context.l10n.qrScanTitle,
),
IconButton(
icon: const Icon(Icons.person_add_outlined),
onPressed: () => _showAddContactDialog(context),
tooltip: context.l10n.contactsAddContact,
),
],
),
body: Column(
children: [
// Vyhledávání
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: context.l10n.contactsSearchHint,
prefixIcon: const Icon(Icons.search, color: SepotColors.textMuted),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: SepotColors.border),
),
),
onChanged: (v) => setState(() => _searchQuery = v.toLowerCase()),
),
),
// Sdílet vlastní QR
_buildShareCard(context),
const Divider(height: 1),
// Seznam kontaktů
Expanded(
child: contactsAsync.when(
data: (contacts) {
final filtered = contacts.where((c) {
if (_searchQuery.isEmpty) return true;
return c.displayName.toLowerCase().contains(_searchQuery) ||
c.sepotId.toLowerCase().contains(_searchQuery);
}).toList();
if (filtered.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.people_outline,
size: 48,
color: SepotColors.textMuted,
),
const SizedBox(height: 16),
Text(
_searchQuery.isEmpty
? context.l10n.contactsEmpty
: context.l10n.contactsNoResults,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 16,
color: SepotColors.textSecondary,
),
),
],
),
);
}
return ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final contact = filtered[index];
final displayName = contact.displayName.isEmpty
? context.l10n.commonUnknownContact
: contact.displayName;
return ListTile(
leading: SepotAvatar(name: displayName),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Row(
children: [
Text(
contact.sepotId,
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
),
if (contact.verified) ...[
const SizedBox(width: 8),
const Icon(
Icons.verified,
size: 14,
color: SepotColors.ember,
),
],
],
),
trailing: const Icon(
Icons.chat_bubble_outline,
color: SepotColors.textMuted,
),
onTap: () {
context.push('/conversation/${contact.publicIdentity.id}');
},
onLongPress: () =>
_showContactOptions(context, contact.id),
);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
),
error: (e, _) =>
Center(child: Text(describeError(context.l10n, e))),
),
),
],
),
);
}
Widget _buildShareCard(BuildContext context) {
return InkWell(
onTap: () => context.push('/qr-share'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
const CircleAvatar(
radius: 24,
backgroundColor: SepotColors.surfaceVariant,
child: Icon(
Icons.qr_code_rounded,
color: SepotColors.ember,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.contactsShareCardTitle,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
Text(
context.l10n.contactsShareCardSubtitle,
style: const TextStyle(
fontSize: 13,
color: SepotColors.textSecondary,
),
),
],
),
),
const Icon(Icons.chevron_right, color: SepotColors.textMuted),
],
),
),
);
}
void _showAddContactDialog(BuildContext context) {
final idController = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.contactsAddContact),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.contactsAddDialogPrompt,
style: const TextStyle(
fontSize: 14, color: SepotColors.textSecondary),
),
const SizedBox(height: 16),
TextField(
controller: idController,
decoration: InputDecoration(
hintText: context.l10n.contactsShareStringHint,
prefixIcon: const Icon(Icons.link),
),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(context.l10n.commonCancel),
),
ElevatedButton(
onPressed: () async {
final shareString = idController.text.trim();
if (shareString.isEmpty) return;
final identity = PublicIdentity.fromShareString(shareString);
if (identity == null) {
if (ctx.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(context.l10n.contactsInvalidShareString)),
);
}
return;
}
try {
await ref
.read(contactsProvider.notifier)
.addContact(identity);
if (ctx.mounted) Navigator.of(ctx).pop();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contactsAdded(
identity.displayName.isEmpty
? context.l10n.commonUnknownContact
: identity.displayName)),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(describeError(context.l10n, e))),
);
}
}
},
child: Text(context.l10n.commonAdd),
),
],
),
);
}
void _showContactOptions(BuildContext context, String contactId) {
showModalBottomSheet(
context: context,
backgroundColor: SepotColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.verified_outlined, color: SepotColors.ember),
title: Text(context.l10n.chatMenuVerifyContact),
subtitle: Text(context.l10n.verifyContactSubtitle),
onTap: () {
Navigator.of(ctx).pop();
// Skutečné ověření = safety number, ne slepé přepnutí flagu
context.push('/verify/$contactId');
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: SepotColors.error),
title: Text(context.l10n.contactsDeleteContact,
style: const TextStyle(color: SepotColors.error)),
onTap: () {
ref.read(contactsProvider.notifier).removeContact(contactId);
Navigator.of(ctx).pop();
},
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
/// Fullscreen prohlížeč obrázkové přílohy.
///
/// Plné rozlišení se dešifruje až tady, do PAMĚTI (na disk nikdy).
/// FLAG_SECURE platí globálně, takže screenshot/škrcení obrazovky
/// nehrozí ani ve vieweru. Sdílení jde přes systémový share sheet
/// (XFile.fromData — plaintext jen v cache sdílení).
library;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/message.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:share_plus/share_plus.dart';
class ImageViewerScreen extends ConsumerStatefulWidget {
const ImageViewerScreen({super.key, required this.message});
final Message message;
@override
ConsumerState<ImageViewerScreen> createState() => _ImageViewerScreenState();
}
class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
late final Future<Uint8List?> _decrypted;
@override
void initState() {
super.initState();
_decrypted = ref
.read(messagingServiceProvider)
.loadDecryptedAttachment(widget.message);
}
Future<void> _share() async {
final meta = widget.message.attachment!;
final ext = meta.mime == 'image/png'
? 'png'
: meta.mime == 'image/gif'
? 'gif'
: 'jpg';
final name = context.l10n.attachmentSharedImageFilename(ext);
final bytes = await _decrypted;
if (bytes == null) return;
await Share.shareXFiles([
XFile.fromData(bytes, name: name, mimeType: meta.mime),
]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.white),
actions: [
IconButton(
icon: const Icon(Icons.share_outlined),
onPressed: _share,
),
],
),
body: FutureBuilder<Uint8List?>(
future: _decrypted,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
);
}
final bytes = snapshot.data;
if (snapshot.hasError || bytes == null) {
return Center(
child: Text(
context.l10n.attachmentDecryptFailed,
style: const TextStyle(color: Colors.white70),
),
);
}
return InteractiveViewer(
maxScale: 6,
child: Center(
child: Image.memory(bytes, fit: BoxFit.contain),
),
);
},
),
);
}
}
@@ -0,0 +1,520 @@
/// Obsah bublin s přílohou — obrázek (thumbnail + stav) a dokument
/// (karta s názvem/velikostí). Plné rozlišení se dešifruje až ve vieweru;
/// v chatu stačí inline náhled, který dorazil UVNITŘ E2E zprávy.
library;
import 'dart:async';
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/features/conversation/image_viewer_screen.dart';
import 'package:sepot/models/attachment.dart';
import 'package:sepot/models/message.dart';
import 'package:sepot/providers/conversations_provider.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:share_plus/share_plus.dart';
String formatBytes(AppLocalizations l10n, int bytes) {
if (bytes < 1024) return l10n.attachmentSizeBytes(bytes);
if (bytes < 1024 * 1024) {
return l10n.attachmentSizeKb((bytes / 1024).toStringAsFixed(0));
}
return l10n.attachmentSizeMb((bytes / (1024 * 1024)).toStringAsFixed(1));
}
/// Cache dešifrovaných plných obrázků (blob_id → bajty), ať se nedešifruje
/// při každém překreslení seznamu. Prostý strop, nejstarší se zahazují.
final _fullImageCache = <String, Uint8List>{};
const _fullImageCacheMax = 30;
void _cachePut(String blobId, Uint8List bytes) {
_fullImageCache.remove(blobId);
_fullImageCache[blobId] = bytes;
while (_fullImageCache.length > _fullImageCacheMax) {
_fullImageCache.remove(_fullImageCache.keys.first);
}
}
/// Obrázková příloha: náhled + overlay podle stavu přenosu.
class ImageAttachmentContent extends ConsumerWidget {
const ImageAttachmentContent({
super.key,
required this.contactId,
required this.message,
});
final String contactId;
final Message message;
@override
Widget build(BuildContext context, WidgetRef ref) {
final meta = message.attachment!;
final thumb = meta.thumbnail;
final aspect = (meta.width != null &&
meta.height != null &&
meta.width! > 0 &&
meta.height! > 0)
? meta.width! / meta.height!
: 4 / 3;
return GestureDetector(
onTap: switch (meta.state) {
AttachmentTransferState.ready => () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ImageViewerScreen(message: message),
),
);
},
AttachmentTransferState.failed => () => ref
.read(conversationsProvider.notifier)
.retryAttachmentDownload(contactId, message),
_ => null,
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: aspect.clamp(0.5, 2.5),
child: Stack(
fit: StackFit.expand,
children: [
// Náhled jako podklad/placeholder (rozmazaný, ne kostičkovaný).
// Klíč podle blob_id — jinak ListView recykluje widget a
// s gaplessPlayback ukáže náhled PŘEDCHOZÍ fotky.
if (thumb != null)
Image.memory(thumb,
key: ValueKey('thumb-${meta.blobId}'),
fit: BoxFit.cover,
gaplessPlayback: true)
else
const ColoredBox(
color: SepotColors.surfaceVariant,
child: Icon(Icons.image_outlined,
size: 48, color: SepotColors.textMuted),
),
// Jakmile je blob stažený, překryj OSTROU plnou fotkou.
// Klíč podle blob_id, ať se stav nedědí z jiné fotky.
if (meta.state == AttachmentTransferState.ready)
_FullImage(
key: ValueKey('full-${meta.blobId}'),
contactId: contactId,
message: message),
if (meta.state != AttachmentTransferState.ready)
ColoredBox(
color: Colors.black45,
child: Center(child: _stateIndicator(context, meta.state)),
),
],
),
),
),
);
}
Widget _stateIndicator(BuildContext context, AttachmentTransferState state) {
return switch (state) {
AttachmentTransferState.downloading ||
AttachmentTransferState.pending =>
const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
),
AttachmentTransferState.failed => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.refresh_rounded, color: Colors.white, size: 32),
const SizedBox(height: 4),
Text(
context.l10n.attachmentDownloadFailedTapToRetry,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 11),
),
],
),
AttachmentTransferState.ready => const SizedBox.shrink(),
};
}
}
/// Ostrá plná fotka do bubliny (stav ready). Dešifruje blob do paměti
/// s cache; než je hotová, prosvítá náhled pod ní (žádné kostičky).
class _FullImage extends ConsumerStatefulWidget {
const _FullImage(
{super.key, required this.contactId, required this.message});
final String contactId;
final Message message;
@override
ConsumerState<_FullImage> createState() => _FullImageState();
}
class _FullImageState extends ConsumerState<_FullImage> {
Uint8List? _bytes;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(_FullImage old) {
super.didUpdateWidget(old);
// Kdyby se widget přece jen recykloval na jinou fotku (jiný blob_id),
// zahoď staré bajty a načti čerstvě — žádné zděděné náhledy.
if (old.message.attachment!.blobId != widget.message.attachment!.blobId) {
_bytes = null;
_load();
}
}
Future<void> _load() async {
final blobId = widget.message.attachment!.blobId;
final cached = _fullImageCache[blobId];
if (cached != null) {
setState(() => _bytes = cached);
return;
}
final bytes = await ref
.read(messagingServiceProvider)
.loadDecryptedAttachment(widget.message);
if (bytes != null) _cachePut(blobId, bytes);
if (mounted) setState(() => _bytes = bytes);
}
@override
Widget build(BuildContext context) {
final bytes = _bytes;
if (bytes == null) return const SizedBox.shrink();
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true);
}
}
/// Hlasová zpráva: play/pause, průběh a délka. Blob se dešifruje do paměti
/// až při prvním přehrání (plaintext audio nikdy nejde na disk).
class AudioAttachmentContent extends ConsumerStatefulWidget {
const AudioAttachmentContent({
super.key,
required this.contactId,
required this.message,
required this.isMine,
});
final String contactId;
final Message message;
final bool isMine;
@override
ConsumerState<AudioAttachmentContent> createState() => _AudioState();
}
class _AudioState extends ConsumerState<AudioAttachmentContent> {
final _player = AudioPlayer();
final _subs = <StreamSubscription<dynamic>>[];
bool _loading = false;
bool _playing = false;
Duration _pos = Duration.zero;
Duration? _total;
@override
void initState() {
super.initState();
final dur = widget.message.attachment!.durationMs;
_total = dur != null ? Duration(milliseconds: dur) : null;
_subs.add(_player.onPositionChanged.listen((p) {
if (mounted) setState(() => _pos = p);
}));
_subs.add(_player.onDurationChanged.listen((d) {
if (mounted && d > Duration.zero) setState(() => _total = d);
}));
_subs.add(_player.onPlayerStateChanged.listen((s) {
if (mounted) setState(() => _playing = s == PlayerState.playing);
}));
_subs.add(_player.onPlayerComplete.listen((_) {
if (mounted) setState(() => _pos = Duration.zero);
}));
}
@override
void dispose() {
for (final s in _subs) {
s.cancel();
}
_player.dispose();
super.dispose();
}
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60).toString();
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
Future<void> _toggle() async {
final meta = widget.message.attachment!;
if (_playing) {
await _player.pause();
return;
}
if (_pos > Duration.zero && _pos < (_total ?? Duration.zero)) {
await _player.resume();
return;
}
setState(() => _loading = true);
final bytes = await ref
.read(messagingServiceProvider)
.loadDecryptedAttachment(widget.message);
if (!mounted) return;
setState(() => _loading = false);
if (bytes == null) return;
await _player.play(BytesSource(bytes, mimeType: meta.mime));
}
@override
Widget build(BuildContext context) {
final meta = widget.message.attachment!;
final ink = widget.isMine ? SepotColors.emberInk : SepotColors.textPrimary;
final accent = widget.isMine ? SepotColors.emberInk : SepotColors.ember;
if (meta.state != AttachmentTransferState.ready) {
return SizedBox(
width: 200,
child: Row(
children: [
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 10),
Text(
meta.state == AttachmentTransferState.failed
? context.l10n.attachmentDownloadFailed
: context.l10n.attachmentDownloading,
style: TextStyle(color: ink, fontSize: 13),
),
],
),
);
}
final total = _total ?? Duration.zero;
final progress = total.inMilliseconds == 0
? 0.0
: (_pos.inMilliseconds / total.inMilliseconds).clamp(0.0, 1.0);
return SizedBox(
width: 210,
child: Row(
children: [
GestureDetector(
onTap: _loading ? null : _toggle,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: accent.withValues(alpha: 0.18),
),
child: _loading
? Padding(
padding: const EdgeInsets.all(10),
child: CircularProgressIndicator(
strokeWidth: 2, color: accent),
)
: Icon(_playing ? Icons.pause : Icons.play_arrow,
color: accent, size: 24),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
minHeight: 4,
backgroundColor: ink.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation(accent),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.mic, size: 13, color: ink.withValues(alpha: .6)),
Text(
_playing || _pos > Duration.zero
? _fmt(_pos)
: _fmt(total),
style: TextStyle(
fontSize: 11, color: ink.withValues(alpha: .8)),
),
],
),
],
),
),
],
),
);
}
}
/// Dokument: karta s ikonou, názvem a velikostí. Klepnutí (stav ready)
/// dešifruje do paměti a otevře systémový share sheet — plaintext se na
/// disk dostane jen do cache sdílení (spravuje OS), nikdy do galerie.
class DocumentAttachmentContent extends ConsumerStatefulWidget {
const DocumentAttachmentContent({
super.key,
required this.contactId,
required this.message,
});
final String contactId;
final Message message;
@override
ConsumerState<DocumentAttachmentContent> createState() =>
_DocumentAttachmentContentState();
}
class _DocumentAttachmentContentState
extends ConsumerState<DocumentAttachmentContent> {
bool _opening = false;
Future<void> _open() async {
final meta = widget.message.attachment!;
final l10n = context.l10n;
setState(() => _opening = true);
try {
final bytes = await ref
.read(messagingServiceProvider)
.loadDecryptedAttachment(widget.message);
if (bytes == null) return;
await Share.shareXFiles([
XFile.fromData(
bytes,
name: meta.name ?? l10n.attachmentSharedFileFallbackName,
mimeType: meta.mime,
),
]);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(l10n.attachmentOpenFailed(describeError(l10n, e)))),
);
}
} finally {
if (mounted) setState(() => _opening = false);
}
}
@override
Widget build(BuildContext context) {
final meta = widget.message.attachment!;
return InkWell(
onTap: switch (meta.state) {
AttachmentTransferState.ready => _opening ? null : _open,
AttachmentTransferState.failed => () => ref
.read(conversationsProvider.notifier)
.retryAttachmentDownload(widget.contactId, widget.message),
_ => null,
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_leadingIcon(meta.state),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
meta.name ?? context.l10n.commonFile,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
meta.state == AttachmentTransferState.failed
? context.l10n.attachmentDownloadFailedTapToRetry
: formatBytes(context.l10n, meta.size),
style: const TextStyle(
fontSize: 12,
color: SepotColors.textMuted,
),
),
],
),
),
],
),
),
);
}
Widget _leadingIcon(AttachmentTransferState state) {
const size = 38.0;
if (_opening ||
state == AttachmentTransferState.downloading ||
state == AttachmentTransferState.pending) {
return const SizedBox(
width: size,
height: size,
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
color: SepotColors.ember,
),
),
);
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: SepotColors.ember.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
state == AttachmentTransferState.failed
? Icons.refresh_rounded
: Icons.description_outlined,
color: SepotColors.ember,
size: 20,
),
);
}
}
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/groups_provider.dart';
import 'package:sepot/widgets/avatar.dart';
/// Založení skupiny: jméno + výběr členů.
///
/// Vybrat jde jen kontakty s doručovací adresou (routing) — bez ní jim
/// fanout nemá kam doručit. Kontakty bez routingu jsou v seznamu vidět,
/// ale zašedlé s vysvětlením.
class CreateGroupScreen extends ConsumerStatefulWidget {
const CreateGroupScreen({super.key});
@override
ConsumerState<CreateGroupScreen> createState() => _CreateGroupScreenState();
}
class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
final _nameController = TextEditingController();
final _selected = <String>{};
var _creating = false;
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
bool _hasRouting(Contact c) =>
c.publicIdentity.accountId != null &&
c.publicIdentity.deliveryAddress != null;
Future<void> _create(List<Contact> contacts) async {
final name = _nameController.text.trim();
if (name.isEmpty || _selected.isEmpty || _creating) return;
setState(() => _creating = true);
try {
final members =
contacts.where((c) => _selected.contains(c.id)).toList();
final group =
await ref.read(groupsProvider.notifier).createGroup(name, members);
if (mounted) context.pushReplacement('/conversation/${group.id}');
} catch (e) {
setState(() => _creating = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n
.groupsCreateFailed(describeError(context.l10n, e))),
),
);
}
}
}
@override
Widget build(BuildContext context) {
final contactsAsync = ref.watch(contactsProvider);
final contacts = (contactsAsync.valueOrNull ?? [])
.where((c) => !c.blocked)
.toList();
final canCreate = _nameController.text.trim().isNotEmpty &&
_selected.isNotEmpty &&
!_creating;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.groupsNewGroupTitle),
actions: [
TextButton(
onPressed: canCreate ? () => _create(contacts) : null,
child: _creating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: SepotColors.ember),
)
: Text(context.l10n.groupsCreateAction),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _nameController,
maxLength: 64,
decoration: InputDecoration(
labelText: context.l10n.groupsNameHint,
counterText: '',
prefixIcon: const Icon(Icons.group_rounded,
color: SepotColors.textMuted),
),
onChanged: (_) => setState(() {}),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(
context.l10n.groupsMembersCounter(
_selected.length, Group.maxMembers - 1),
style: const TextStyle(
color: SepotColors.textSecondary, fontSize: 13),
),
],
),
),
const SizedBox(height: 4),
Expanded(
child: contacts.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
context.l10n.groupsEmptyContactsHint,
textAlign: TextAlign.center,
style: const TextStyle(
color: SepotColors.textSecondary),
),
),
)
: ListView.builder(
itemCount: contacts.length,
itemBuilder: (context, index) {
final c = contacts[index];
final routable = _hasRouting(c);
final checked = _selected.contains(c.id);
return CheckboxListTile(
value: checked,
onChanged: routable
? (v) => setState(() {
if (v == true) {
if (_selected.length <
Group.maxMembers - 1) {
_selected.add(c.id);
}
} else {
_selected.remove(c.id);
}
})
: null,
secondary: SepotAvatar(name: c.displayName, size: 40),
title: Text(
c.displayName,
style: TextStyle(
color: routable
? SepotColors.textPrimary
: SepotColors.textMuted,
),
),
subtitle: routable
? null
: Text(
context.l10n.groupsMemberMissingAddress,
style: const TextStyle(
fontSize: 12,
color: SepotColors.textMuted),
),
activeColor: SepotColors.ember,
controlAffinity: ListTileControlAffinity.trailing,
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,401 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/sepot_logo.dart';
/// Onboarding — 3 stránky + generování identity
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
final _pageController = PageController();
int _currentPage = 0;
final _nameController = TextEditingController();
List<String>? _seedPhrase;
bool _isCreating = false;
bool _seedSaved = false;
@override
void dispose() {
_pageController.dispose();
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
// Indikátor stránek
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: List.generate(3, (i) {
return Expanded(
child: Container(
height: 3,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
color: i <= _currentPage
? SepotColors.ember
: SepotColors.border,
),
),
);
}),
),
),
// Stránky
Expanded(
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (page) => setState(() => _currentPage = page),
children: [
_buildWelcomePage(),
_buildEncryptionPage(),
_buildIdentityPage(),
],
),
),
],
),
),
);
}
/// Stránka 1: Uvítání
Widget _buildWelcomePage() {
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SepotLogo(size: 100),
const SizedBox(height: 48),
Text(
context.l10n.onboardingWelcomeTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 28,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 16),
Text(
context.l10n.onboardingWelcomeSubtitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 16,
height: 1.6,
color: SepotColors.textSecondary,
),
),
const Spacer(),
ElevatedButton(
onPressed: () => _pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
child: Text(context.l10n.onboardingStart),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => context.push('/restore'),
child: Text(context.l10n.onboardingRestoreExisting),
),
],
),
);
}
/// Stránka 2: Vysvětlení šifrování
Widget _buildEncryptionPage() {
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SepotColors.mist.withValues(alpha: 0.1),
border: Border.all(color: SepotColors.mist.withValues(alpha: 0.3)),
),
child: const Icon(
Icons.shield_rounded,
size: 40,
color: SepotColors.mist,
),
),
const SizedBox(height: 32),
Text(
context.l10n.onboardingEncryptionTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 24,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 24),
_buildFeatureRow(
Icons.lock_rounded,
context.l10n.onboardingFeatureSignalTitle,
context.l10n.onboardingFeatureSignalSubtitle,
),
const SizedBox(height: 16),
_buildFeatureRow(
Icons.fingerprint,
context.l10n.onboardingFeaturePqTitle,
context.l10n.onboardingFeaturePqSubtitle,
),
const SizedBox(height: 16),
_buildFeatureRow(
Icons.phone_android_rounded,
context.l10n.onboardingFeatureLocalKeysTitle,
context.l10n.onboardingFeatureLocalKeysSubtitle,
),
const SizedBox(height: 16),
_buildFeatureRow(
Icons.timer_outlined,
context.l10n.chatDisappearingMessages,
context.l10n.onboardingFeatureDisappearingSubtitle,
),
const Spacer(),
ElevatedButton(
onPressed: () => _pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
child: Text(context.l10n.commonContinue),
),
],
),
);
}
Widget _buildFeatureRow(IconData icon, String title, String subtitle) {
return Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: SepotColors.surfaceVariant,
),
child: Icon(icon, size: 22, color: SepotColors.ember),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: SepotColors.textSecondary,
),
),
],
),
),
],
);
}
/// Stránka 3: Generování identity
Widget _buildIdentityPage() {
return Padding(
padding: const EdgeInsets.all(32),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Text(
context.l10n.onboardingIdentityTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 24,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
context.l10n.onboardingIdentitySubtitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 14,
color: SepotColors.textSecondary,
),
),
const SizedBox(height: 32),
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: context.l10n.onboardingDisplayNameLabel,
hintText: context.l10n.onboardingDisplayNameHint,
prefixIcon: const Icon(Icons.person_outline),
),
textCapitalization: TextCapitalization.words,
// Bez rebuildů by tlačítko „Vygenerovat klíče" zůstalo
// navždy disabled (jeho stav čte controller v build())
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 32),
if (_seedPhrase != null) ...[
Text(
context.l10n.settingsSeedPhraseTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 16,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
context.l10n.onboardingSeedPhraseWarning(_seedPhrase!.length),
style: const TextStyle(
fontSize: 13,
color: SepotColors.warning,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: SepotColors.surfaceVariant,
border: Border.all(color: SepotColors.border),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(_seedPhrase!.length, (i) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: SepotColors.surface,
border: Border.all(color: SepotColors.border),
),
child: Text(
'${i + 1}. ${_seedPhrase![i]}',
style: SepotTheme.cryptoText.copyWith(fontSize: 13),
),
);
}),
),
),
const SizedBox(height: 16),
CheckboxListTile(
value: _seedSaved,
onChanged: (v) => setState(() => _seedSaved = v ?? false),
title: Text(
context.l10n.onboardingSeedSavedConfirm,
style: const TextStyle(fontSize: 14),
),
activeColor: SepotColors.ember,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
],
const SizedBox(height: 24),
if (_seedPhrase == null)
ElevatedButton(
onPressed: _nameController.text.trim().isNotEmpty
? _generateIdentity
: null,
child: _isCreating
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(context.l10n.onboardingGenerateKeys),
)
else
ElevatedButton(
onPressed: _seedSaved ? _completeOnboarding : null,
child: Text(context.l10n.commonContinue),
),
],
),
),
);
}
Future<void> _generateIdentity() async {
if (_nameController.text.trim().isEmpty) return;
setState(() => _isCreating = true);
try {
// Vygeneruj identitu
final identity = await ref
.read(identityProvider.notifier)
.createIdentity(_nameController.text.trim());
// Seed fráze = skutečná záloha identity klíče (BIP39 enkódování),
// NE nezávislá slova — jinak by obnova nikdy nefungovala
final seed = identity.mnemonic;
await ref.read(storageProvider).saveSeedPhrase(seed);
setState(() {
_seedPhrase = seed;
_isCreating = false;
});
} catch (e) {
setState(() => _isCreating = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(describeError(context.l10n, e))),
);
}
}
}
Future<void> _completeOnboarding() async {
final storage = ref.read(storageProvider);
await storage.setOnboardingComplete(true);
if (mounted) {
context.go('/pin-setup');
}
}
}
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/crypto/mnemonic.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
/// Obnova účtu ze zálohovací fráze (24 slov). Vrátí IDENTITU (klíč →
/// stejný SEP kód a safety number), NE historii zpráv.
class RestoreScreen extends ConsumerStatefulWidget {
const RestoreScreen({super.key});
@override
ConsumerState<RestoreScreen> createState() => _RestoreScreenState();
}
class _RestoreScreenState extends ConsumerState<RestoreScreen> {
final _nameController = TextEditingController();
final _wordsController = TextEditingController();
bool _restoring = false;
String? _error;
@override
void dispose() {
_nameController.dispose();
_wordsController.dispose();
super.dispose();
}
/// Rozsekej vstup na slova (mezery, nové řádky, čárky, čísla „1." pryč).
List<String> _parseWords(String raw) {
return raw
.toLowerCase()
.replaceAll(RegExp(r'\d+[.)]?'), ' ')
.split(RegExp(r'[\s,]+'))
.where((w) => w.isNotEmpty)
.toList();
}
int get _wordCount => _parseWords(_wordsController.text).length;
Future<void> _restore() async {
final name = _nameController.text.trim();
final words = _parseWords(_wordsController.text);
if (name.isEmpty) {
setState(() => _error = context.l10n.restoreErrorNameRequired);
return;
}
if (words.length != AppConstants.seedPhraseWords) {
setState(() => _error = context.l10n.restoreErrorWordCount(
_wordCount, AppConstants.seedPhraseWords));
return;
}
if (!SepotMnemonic.isValid(words)) {
setState(() => _error = context.l10n.restoreErrorInvalidPhrase);
return;
}
setState(() {
_restoring = true;
_error = null;
});
try {
await ref
.read(identityProvider.notifier)
.recoverIdentity(words, name);
await ref.read(storageProvider).setOnboardingComplete(true);
if (mounted) context.go('/pin-setup');
} catch (e) {
if (!mounted) return;
setState(() {
_restoring = false;
_error =
context.l10n.restoreErrorFailed(describeError(context.l10n, e));
});
}
}
@override
Widget build(BuildContext context) {
final ok = _wordCount == AppConstants.seedPhraseWords &&
_nameController.text.trim().isNotEmpty;
return Scaffold(
appBar: AppBar(title: Text(context.l10n.restoreTitle)),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.restoreHeading,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 22,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
context.l10n.restoreIntro(AppConstants.seedPhraseWords),
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 14,
height: 1.5,
color: SepotColors.textSecondary,
),
),
const SizedBox(height: 20),
// Upozornění na historii
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: SepotColors.warning.withValues(alpha: 0.1),
border: Border.all(
color: SepotColors.warning.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.info_outline,
size: 20, color: SepotColors.warning),
const SizedBox(width: 10),
Expanded(
child: Text(
context.l10n.restoreHistoryWarning,
style: TextStyle(
fontSize: 13,
color: SepotColors.textPrimary.withValues(alpha: .9)),
),
),
],
),
),
const SizedBox(height: 24),
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: context.l10n.onboardingDisplayNameLabel,
hintText: context.l10n.onboardingDisplayNameHint,
prefixIcon: const Icon(Icons.person_outline),
),
textCapitalization: TextCapitalization.words,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 20),
TextField(
controller: _wordsController,
minLines: 4,
maxLines: 8,
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'[^\w\s.,)]')),
],
decoration: InputDecoration(
labelText: context.l10n.settingsSeedPhraseTitle,
hintText: context.l10n.restorePhraseHint,
alignLabelWithHint: true,
helperText: context.l10n.restoreWordCountHelper(
_wordCount, AppConstants.seedPhraseWords),
helperStyle: TextStyle(
color: _wordCount == AppConstants.seedPhraseWords
? SepotColors.sage
: SepotColors.textMuted,
),
),
onChanged: (_) => setState(() => _error = null),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!,
style: const TextStyle(
color: SepotColors.error, fontSize: 13)),
],
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: (ok && !_restoring) ? _restore : null,
child: _restoring
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(context.l10n.restoreTitle),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/identity.dart';
import 'package:sepot/providers/contacts_provider.dart';
/// QR skener pro přidání kontaktů
class QrScanScreen extends ConsumerStatefulWidget {
const QrScanScreen({super.key});
@override
ConsumerState<QrScanScreen> createState() => _QrScanScreenState();
}
class _QrScanScreenState extends ConsumerState<QrScanScreen> {
final _controller = MobileScannerController(
detectionSpeed: DetectionSpeed.normal,
facing: CameraFacing.back,
);
bool _isProcessing = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.qrScanTitle),
actions: [
IconButton(
icon: const Icon(Icons.flash_on),
onPressed: () => _controller.toggleTorch(),
),
IconButton(
icon: const Icon(Icons.cameraswitch),
onPressed: () => _controller.switchCamera(),
),
],
),
body: Stack(
children: [
MobileScanner(
controller: _controller,
onDetect: _onDetect,
),
// Overlay
_buildOverlay(),
// Spodní panel
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
SepotColors.background.withValues(alpha: 0.9),
SepotColors.background,
],
),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.qr_code_scanner_rounded,
color: SepotColors.ember,
size: 32,
),
const SizedBox(height: 8),
Text(
context.l10n.qrScanInstruction,
style: const TextStyle(
fontSize: 16,
color: SepotColors.textPrimary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
context.l10n.qrScanHint,
style: const TextStyle(
fontSize: 13,
color: SepotColors.textSecondary,
),
),
],
),
),
),
),
],
),
);
}
Widget _buildOverlay() {
return LayoutBuilder(
builder: (context, constraints) {
final scanSize = constraints.maxWidth * 0.7;
final left = (constraints.maxWidth - scanSize) / 2;
final top = (constraints.maxHeight - scanSize) / 2 - 40;
return Stack(
children: [
// Tmavý overlay kolem
ColorFiltered(
colorFilter: ColorFilter.mode(
SepotColors.background.withValues(alpha: 0.5),
BlendMode.srcOut,
),
child: Stack(
children: [
Container(
decoration: const BoxDecoration(
color: Colors.red, // Jakákoliv neprůhledná barva
backgroundBlendMode: BlendMode.dstOut,
),
),
Positioned(
left: left,
top: top,
child: Container(
width: scanSize,
height: scanSize,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(24),
),
),
),
],
),
),
// Rámec skenu
Positioned(
left: left,
top: top,
child: Container(
width: scanSize,
height: scanSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: SepotColors.ember,
width: 3,
),
),
),
),
],
);
},
);
}
void _onDetect(BarcodeCapture capture) {
if (_isProcessing) return;
final barcode = capture.barcodes.firstOrNull;
if (barcode?.rawValue == null) return;
final shareString = barcode!.rawValue!;
final identity = PublicIdentity.fromShareString(shareString);
if (identity == null) return;
setState(() => _isProcessing = true);
_controller.stop();
_showContactConfirmation(identity);
}
void _showContactConfirmation(PublicIdentity identity) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.qrNewContactTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
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()
: '?',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: SepotColors.ember,
),
),
),
const SizedBox(height: 16),
Text(
identity.displayName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
identity.sepotId,
style: SepotTheme.cryptoText,
),
const SizedBox(height: 8),
Text(
context.l10n.settingsFingerprint(identity.fingerprint.display),
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
_controller.start();
setState(() => _isProcessing = false);
},
child: Text(context.l10n.commonCancel),
),
ElevatedButton(
onPressed: () async {
try {
await ref
.read(contactsProvider.notifier)
.addContact(identity, verified: true);
if (ctx.mounted) Navigator.of(ctx).pop();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(context.l10n.qrContactAdded(identity.displayName)),
),
);
context.pop();
}
} catch (e) {
if (ctx.mounted) Navigator.of(ctx).pop();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(describeError(context.l10n, e))),
);
}
_controller.start();
setState(() => _isProcessing = false);
}
},
child: Text(context.l10n.commonAdd),
),
],
),
);
}
}
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/messaging_provider.dart';
/// Sdílení vlastního QR kódu a Šepot ID
class QrShareScreen extends ConsumerWidget {
const QrShareScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final identityAsync = ref.watch(identityProvider);
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.qrShareTitle),
),
body: identityAsync.when(
data: (identity) {
if (identity == null) {
return Center(child: Text(context.l10n.qrIdentityNotFound));
}
// Sdílet s routingem (accountId + delivery address z relaye),
// jinak protistrana nemá kam poslat první zprávu
final service = ref.watch(messagingServiceProvider);
final shareString = (service.shareableIdentity ??
identity.publicIdentity)
.toShareString();
return SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
children: [
// QR kód
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
),
child: QrImageView(
data: shareString,
version: QrVersions.auto,
size: 240,
errorCorrectionLevel: QrErrorCorrectLevel.M,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Color(0xFF030712),
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.circle,
color: Color(0xFF030712),
),
),
),
const SizedBox(height: 32),
// Šepot ID
Text(
identity.sepotId,
style: const TextStyle(
fontFamily: kFontMono,
fontSize: 24,
fontWeight: FontWeight.w600,
color: SepotColors.ember,
),
),
const SizedBox(height: 8),
Text(
identity.displayName,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 18,
fontWeight: FontWeight.w500,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
// Fingerprint
Text(
context.l10n.settingsFingerprint(identity.fingerprint.display),
style: SepotTheme.cryptoText,
),
const SizedBox(height: 32),
// Akce
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: shareString));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(context.l10n.commonCopiedToClipboard),
),
);
},
icon: const Icon(Icons.copy),
label: Text(context.l10n.commonCopy),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Share.share(
context.l10n.qrShareInviteMessage(shareString),
);
},
icon: const Icon(Icons.share),
label: Text(context.l10n.commonShare),
),
),
],
),
const SizedBox(height: 24),
// Informace
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: SepotColors.surfaceVariant,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SepotColors.border),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: SepotColors.textMuted, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.qrShareInfo,
style: const TextStyle(
fontSize: 13,
color: SepotColors.textSecondary,
),
),
),
],
),
),
],
),
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
),
error: (e, _) => Center(child: Text(describeError(context.l10n, e))),
),
);
}
}
+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,
),
),
),
],
),
);
}
}
@@ -0,0 +1,106 @@
/// Přepínač biometrického odemčení.
///
/// Zapnutí = kopie DEK do OS keystore (TEE) — vědomé změkčení čistého
/// PIN-only modelu (DEK jinak existuje jen za Argon2id) výměnou za
/// pohodlí. Vyžaduje nastavený PIN a odemčené úložiště; enrolment se
/// potvrzuje systémovou biometrickou výzvou.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/storage_provider.dart';
class BiometricsTile extends ConsumerStatefulWidget {
const BiometricsTile({super.key});
@override
ConsumerState<BiometricsTile> createState() => _BiometricsTileState();
}
class _BiometricsTileState extends ConsumerState<BiometricsTile> {
bool _enabled = false;
bool _busy = false;
@override
void initState() {
super.initState();
ref.read(storageProvider).isBiometricsEnabled().then((v) {
if (mounted) setState(() => _enabled = v);
});
}
Future<void> _toggle(bool value) async {
if (_busy) return;
setState(() => _busy = true);
final l10n = context.l10n;
final storage = ref.read(storageProvider);
try {
if (value) {
if (!await storage.hasPin()) {
_snack(l10n.biometricsPinRequiredFirst);
return;
}
if (!storage.isUnlocked) {
_snack(l10n.biometricsStorageLocked);
return;
}
final auth = LocalAuthentication();
if (!await auth.isDeviceSupported()) {
_snack(l10n.biometricsNotAvailable);
return;
}
final ok = await auth.authenticate(
localizedReason: l10n.biometricsEnrollPromptReason,
options: const AuthenticationOptions(stickyAuth: true),
);
if (!ok) return;
await storage.enableBiometrics();
if (mounted) setState(() => _enabled = true);
} else {
await storage.disableBiometrics();
if (mounted) setState(() => _enabled = false);
}
} catch (e) {
_snack(l10n.biometricsSetupFailed(describeError(l10n, e)));
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _snack(String text) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
@override
Widget build(BuildContext context) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: SepotColors.surfaceVariant,
),
child: const Icon(Icons.fingerprint,
size: 20, color: SepotColors.textSecondary),
),
title: Text(context.l10n.biometricsTileTitle,
style: const TextStyle(fontSize: 15)),
subtitle: Text(
context.l10n.biometricsTileSubtitle,
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
),
trailing: Switch(
value: _enabled,
onChanged: _busy ? null : _toggle,
activeTrackColor: SepotColors.ember,
),
onTap: () => _toggle(!_enabled),
);
}
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/locale_provider.dart';
/// Volba jazyka aplikace: podle systému, nebo ruční přepnutí.
///
/// Názvy jazyků se záměrně nepřekládají — každý je ve své nativní podobě,
/// aby uživatel našel svůj jazyk i v cizím rozhraní.
const _jazyky = <String, String>{
'cs': 'Čeština',
'en': 'English',
'de': 'Deutsch',
'ru': 'Русский',
'it': 'Italiano',
};
class LanguageSettingsTile extends ConsumerWidget {
const LanguageSettingsTile({super.key});
Future<void> _pick(BuildContext context, WidgetRef ref) async {
final current = ref.read(localeProvider).valueOrNull?.languageCode;
final l10n = context.l10n;
final picked = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(l10n.settingsLanguage),
children: [
RadioGroup<String>(
groupValue: current ?? '',
onChanged: (value) => Navigator.pop(ctx, value),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String>(
value: '',
title: Text(l10n.languageSystem),
),
for (final entry in _jazyky.entries)
RadioListTile<String>(
value: entry.key,
title: Text(entry.value),
),
],
),
),
],
),
);
if (picked == null) return;
await ref
.read(localeProvider.notifier)
.set(picked.isEmpty ? null : Locale(picked));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final code = ref.watch(localeProvider).valueOrNull?.languageCode;
final subtitle = _jazyky[code] ?? context.l10n.languageSystem;
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: SepotColors.surfaceVariant,
),
child: const Icon(
Icons.translate,
size: 20,
color: SepotColors.textSecondary,
),
),
title: Text(context.l10n.settingsLanguage,
style: const TextStyle(fontSize: 15)),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
),
onTap: () => _pick(context, ref),
);
}
}
@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/push_provider.dart';
/// Nastavení push notifikací — degooglovaný UnifiedPush.
/// Zapnutí = výběr distributora (ntfy) + registrace endpointu u relaye.
/// Budíček nenese obsah ani odesílatele; při probuzení se appka připojí
/// a vyzvedne frontu.
class PushSettingsTile extends ConsumerStatefulWidget {
const PushSettingsTile({super.key});
@override
ConsumerState<PushSettingsTile> createState() => _PushSettingsTileState();
}
class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
String _mode = 'off';
String? _distributor;
bool _busy = false;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
final push = ref.read(pushServiceProvider);
final mode = await push.currentMode();
final d = await push.currentDistributor();
if (mounted) {
setState(() {
_mode = mode;
_distributor = d;
});
}
}
Future<void> _chooseMode() async {
final picked = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(ctx.l10n.pushTitle),
children: [
_modeOption(ctx, 'off', ctx.l10n.commonOff, ctx.l10n.pushModeOffDesc),
_modeOption(ctx, 'up', ctx.l10n.pushModeUp, ctx.l10n.pushModeUpDesc),
_modeOption(
ctx, 'fcm', ctx.l10n.pushModeFcm, ctx.l10n.pushModeFcmDesc),
],
),
);
if (picked == null || picked == _mode) return;
final push = ref.read(pushServiceProvider);
setState(() => _busy = true);
try {
switch (picked) {
case 'off':
await push.setModeOff();
case 'up':
final ok = await push.setModeUnifiedPush();
if (!ok && mounted) await _pickDistributor();
case 'fcm':
await push.setModeFcm();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n
.pushSetupFailed(describeError(context.l10n, e)))),
);
}
} finally {
await _refresh();
if (mounted) setState(() => _busy = false);
}
}
Widget _modeOption(
BuildContext ctx, String value, String title, String subtitle) {
return SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, value),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
value == _mode
? Icons.radio_button_checked
: Icons.radio_button_off,
size: 18,
color: value == _mode
? SepotColors.ember
: SepotColors.textMuted,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
Text(
subtitle,
style: const TextStyle(
fontSize: 12, color: SepotColors.textMuted),
),
],
),
),
],
),
);
}
Future<void> _pickDistributor() async {
final push = ref.read(pushServiceProvider);
final list = await push.distributors();
if (!mounted) return;
if (list.isEmpty) {
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.pushNoDistributorTitle),
content: Text(ctx.l10n.pushNoDistributorBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(ctx.l10n.commonOk),
),
],
),
);
return;
}
final chosen = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(ctx.l10n.pushPickDistributorTitle),
children: [
for (final d in list)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, d),
child: Text(d),
),
],
),
);
if (chosen != null) {
await push.useDistributor(chosen);
}
}
@override
Widget build(BuildContext context) {
final subtitle = switch (_mode) {
'up' => context.l10n.pushSubtitleUnifiedPush(
_distributor ?? context.l10n.pushNoDistributorFallback),
'fcm' => context.l10n.pushSubtitleFcm,
_ => context.l10n.commonOff,
};
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: SepotColors.surfaceVariant,
),
child: const Icon(
Icons.notifications_outlined,
size: 20,
color: SepotColors.textSecondary,
),
),
title: Text(context.l10n.pushTitle,
style: const TextStyle(fontSize: 15)),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
),
trailing: _busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SepotColors.ember,
),
)
: const Icon(Icons.chevron_right, color: SepotColors.textMuted),
onTap: _busy ? null : _chooseMode,
);
}
}
@@ -0,0 +1,686 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
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/core/theme/app_theme.dart';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/conversations_provider.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/providers/settings_provider.dart';
import 'package:sepot/features/settings/biometrics_tile.dart';
import 'package:sepot/features/settings/language_settings_tile.dart';
import 'package:sepot/features/settings/push_settings_tile.dart';
import 'package:sepot/features/settings/tor_settings_tile.dart';
import 'package:sepot/widgets/sepot_logo.dart';
/// Nastavení aplikace
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final identityAsync = ref.watch(identityProvider);
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Text(l10n.settingsTitle),
),
body: SingleChildScrollView(
child: Column(
children: [
// Profil
identityAsync.when(
data: (identity) {
if (identity == null) return const SizedBox.shrink();
return _buildProfileCard(context, identity);
},
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
),
const SizedBox(height: 8),
// Sekce Obecné
_buildSectionHeader(l10n.settingsSectionGeneral),
const LanguageSettingsTile(),
const SizedBox(height: 8),
// Sekce Zabezpečení
_buildSectionHeader(l10n.settingsSectionSecurity),
_buildTile(
icon: Icons.pin_outlined,
title: l10n.settingsChangePinTitle,
subtitle: l10n.settingsChangePinSubtitle,
onTap: () => context.push('/change-pin'),
),
const BiometricsTile(),
_buildTile(
icon: Icons.timer_outlined,
title: l10n.settingsAutoLockTitle,
subtitle: _autoLockLabel(
l10n,
ref.watch(autoLockProvider).valueOrNull ??
AutoLockNotifier.vychozi,
),
onTap: () => _nastavAutoLock(context, ref),
),
_buildTile(
icon: Icons.password_outlined,
title: l10n.settingsDecoyPinTitle,
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
? l10n.settingsDecoyPinSetSubtitle
: l10n.settingsDecoyPinUnsetSubtitle,
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
? TextButton(
onPressed: () => _disableDecoyPin(context, ref),
child: Text(l10n.commonCancel),
)
: null,
onTap: () {
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
_setDecoyPin(context, ref);
}
},
),
const SizedBox(height: 8),
// Sekce Notifikace
_buildSectionHeader(l10n.settingsSectionNotifications),
const PushSettingsTile(),
const SizedBox(height: 8),
// Sekce Připojení — Tor je přes Orbot (Android). Na iOS zatím
// není cesta k lokální SOCKS proxy, tak dlaždici skryjeme.
if (Platform.isAndroid) ...[
_buildSectionHeader(l10n.settingsSectionConnection),
const TorSettingsTile(),
const SizedBox(height: 8),
],
// Sekce Soukromí
// Mizící zprávy se nastavují per konverzaci (menu ⋮), globální
// dlaždice tu byla mrtvá — odebrána místo předstírání funkce.
_buildSectionHeader(l10n.settingsSectionPrivacy),
_buildTile(
icon: Icons.done_all,
title: l10n.settingsReadReceiptsTitle,
subtitle: l10n.settingsReadReceiptsSubtitle,
trailing: Switch(
value: ref.watch(readReceiptsProvider).valueOrNull ?? false,
onChanged: (v) =>
ref.read(readReceiptsProvider.notifier).set(v),
activeTrackColor: SepotColors.ember,
),
onTap: () {},
),
_buildTile(
icon: Icons.screenshot_monitor_outlined,
title: l10n.settingsScreenSecurityTitle,
subtitle: l10n.settingsScreenSecuritySubtitle,
trailing: Switch(
value: ref.watch(screenSecurityProvider).valueOrNull ?? false,
onChanged: (v) =>
ref.read(screenSecurityProvider.notifier).set(v),
activeTrackColor: SepotColors.ember,
),
onTap: () {},
),
const SizedBox(height: 8),
// Sekce Záloha
_buildSectionHeader(l10n.settingsSectionBackup),
_buildTile(
icon: Icons.key_outlined,
title: l10n.settingsSeedPhraseTitle,
subtitle: l10n.settingsSeedPhraseSubtitle,
onTap: () => _showSeedPhrase(context, ref),
),
_buildTile(
icon: Icons.backup_outlined,
title: l10n.settingsBackupExportTitle,
subtitle: l10n.settingsBackupExportSubtitle,
onTap: () => _exportBackup(context, ref),
),
_buildTile(
icon: Icons.restore_outlined,
title: l10n.settingsBackupImportTitle,
subtitle: l10n.settingsBackupImportSubtitle,
onTap: () => _importBackup(context, ref),
),
// „Web klient" byl slib funkce, která neexistuje (multi-device je
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
const SizedBox(height: 8),
// Sekce Bezpečnost — Play UGC/CSAE policy, Apple Guideline 1.2.
// Nahlášení musí být dostupné i mimo konkrétní konverzaci.
_buildSectionHeader(l10n.settingsSectionSafety),
_buildTile(
icon: Icons.flag_outlined,
title: l10n.settingsReportAbuseTitle,
subtitle: l10n.settingsReportAbuseSubtitle,
onTap: () => context.push('/report'),
),
_buildTile(
icon: Icons.shield_outlined,
title: l10n.settingsChildSafetyTitle,
subtitle: l10n.settingsChildSafetySubtitle,
onTap: () => launchUrl(
Uri.parse(AppConstants.childSafetyUrl),
mode: LaunchMode.externalApplication,
),
),
const SizedBox(height: 8),
// Sekce O aplikaci
_buildSectionHeader(l10n.settingsSectionAbout),
_buildTile(
icon: Icons.info_outline,
title: l10n.commonAppName,
subtitle: l10n.settingsVersion(AppConstants.appVersion),
onTap: () => _showAbout(context),
),
// Zdrojový kód je veřejný (AGPL-3.0). Detaily o licenci a odkaz
// na repozitář dává dlaždice níže.
_buildTile(
icon: Icons.gavel_outlined,
title: l10n.settingsLicenseTitle,
subtitle: l10n.settingsLicenseSubtitle,
onTap: () => _zobrazLicenci(context),
),
_buildTile(
icon: Icons.privacy_tip_outlined,
title: l10n.settingsPrivacyPolicyTitle,
subtitle: l10n.settingsPrivacyPolicySubtitle,
onTap: () => _otevri(AppConstants.privacyUrl),
),
_buildTile(
icon: Icons.description_outlined,
title: l10n.settingsTermsTitle,
subtitle: l10n.settingsTermsSubtitle,
onTap: () => _otevri(AppConstants.termsUrl),
),
_buildTile(
icon: Icons.delete_outline,
title: l10n.settingsDataDeletionTitle,
subtitle: l10n.settingsDataDeletionSubtitle,
onTap: () => _otevri(AppConstants.deletionUrl),
),
const SizedBox(height: 24),
// Smazat účet
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _confirmDeleteAccount(context, ref),
style: OutlinedButton.styleFrom(
foregroundColor: SepotColors.error,
side: const BorderSide(color: SepotColors.error),
),
child: Text(l10n.settingsDeleteAllDataButton),
),
),
const SizedBox(height: 48),
],
),
),
);
}
Future<void> _otevri(String url) => launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
/// Popisek intervalu auto-zámku pro daný počet sekund.
String _autoLockLabel(AppLocalizations l10n, int sekundy) {
switch (sekundy) {
case 0:
return l10n.settingsAutoLockNever;
case 60:
return l10n.settingsAutoLock1min;
case 900:
return l10n.settingsAutoLock15min;
default:
return l10n.settingsAutoLock5min;
}
}
/// AGPL-3.0 § 6 chce spolu s binárkou i nabídku zdrojového kódu.
/// Zdroj je veřejný (repozitář, F-Droid, APK); tenhle text na něj odkazuje.
void _zobrazLicenci(BuildContext context) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.settingsLicenseTitle),
content: Text(
ctx.l10n.settingsLicenseBody(AppConstants.supportEmail),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(ctx.l10n.commonClose),
),
],
),
);
}
Future<void> _nastavAutoLock(BuildContext context, WidgetRef ref) async {
final ted = ref.read(autoLockProvider).valueOrNull ?? AutoLockNotifier.vychozi;
final vybrano = await showDialog<int>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(ctx.l10n.settingsAutoLockTitle),
children: [
for (final sekundy in AutoLockNotifier.moznostiSekundy)
ListTile(
onTap: () => Navigator.pop(ctx, sekundy),
leading: Icon(
sekundy == ted
? Icons.radio_button_checked
: Icons.radio_button_off,
color: sekundy == ted
? SepotColors.ember
: SepotColors.textMuted,
),
title: Text(_autoLockLabel(ctx.l10n, sekundy)),
),
],
),
);
if (vybrano != null && vybrano != ted) {
await ref.read(autoLockProvider.notifier).set(vybrano);
}
}
Widget _buildProfileCard(BuildContext context, dynamic identity) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: SepotColors.surface,
border: Border.all(color: SepotColors.border),
),
child: Column(
children: [
CircleAvatar(
radius: 36,
backgroundColor: SepotColors.ember.withValues(alpha: 0.1),
child: Text(
identity.displayName.substring(0, 1).toUpperCase(),
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 28,
fontWeight: FontWeight.w600,
color: SepotColors.ember,
),
),
),
const SizedBox(height: 12),
Text(
identity.displayName,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 20,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 4),
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: identity.sepotId));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.settingsIdCopied)),
);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
identity.sepotId,
style: SepotTheme.idText,
),
const SizedBox(width: 4),
const Icon(Icons.copy, size: 14, color: SepotColors.ember),
],
),
),
const SizedBox(height: 8),
Text(
context.l10n.settingsFingerprint(identity.fingerprint),
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
),
],
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
title,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 13,
fontWeight: FontWeight.w600,
color: SepotColors.textMuted,
letterSpacing: 0.5,
),
),
),
);
}
Widget _buildTile({
required IconData icon,
required String title,
required String subtitle,
Widget? trailing,
required VoidCallback onTap,
}) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: SepotColors.surfaceVariant,
),
child: Icon(icon, size: 20, color: SepotColors.textSecondary),
),
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
),
trailing: trailing ?? const Icon(Icons.chevron_right, color: SepotColors.textMuted),
onTap: onTap,
);
}
Future<void> _exportBackup(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n;
final identity = ref.read(identityProvider).valueOrNull;
if (identity == null) return;
try {
final bytes = await ref.read(backupServiceProvider).export(identity);
final dir = await getTemporaryDirectory();
final path = '${dir.path}/sepot-zaloha.sepotbackup';
await File(path).writeAsBytes(bytes);
await Share.shareXFiles(
[XFile(path, mimeType: 'application/octet-stream')],
subject: l10n.backupShareSubject,
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.backupExportFailed(describeError(l10n, e)))),
);
}
}
}
Future<void> _importBackup(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n;
final identity = ref.read(identityProvider).valueOrNull;
if (identity == null) return;
try {
final res = await FilePicker.pickFiles(withData: true);
final data = res?.files.firstOrNull?.bytes;
if (data == null) return;
final n = await ref.read(backupServiceProvider).import(identity, data);
ref.invalidate(conversationsProvider);
ref.invalidate(contactsProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.backupRestoredCount(n))),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.backupImportFailed(describeError(l10n, e)))),
);
}
}
}
Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n;
final pinCtrl = TextEditingController();
final confirmCtrl = TextEditingController();
const numberField = TextInputType.number;
final pin = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.settingsDecoyPinTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
ctx.l10n.settingsDecoyPinExplain,
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
TextField(
controller: pinCtrl,
obscureText: true,
keyboardType: numberField,
maxLength: 6,
decoration: InputDecoration(
labelText: ctx.l10n.settingsDecoyPinTitle, counterText: ''),
),
TextField(
controller: confirmCtrl,
obscureText: true,
keyboardType: numberField,
maxLength: 6,
decoration: InputDecoration(
labelText: ctx.l10n.commonConfirm, counterText: ''),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(ctx.l10n.commonCancel)),
TextButton(
onPressed: () {
if (pinCtrl.text.length == 6 &&
pinCtrl.text == confirmCtrl.text) {
Navigator.pop(ctx, pinCtrl.text);
}
},
child: Text(ctx.l10n.commonSet),
),
],
),
);
if (pin == null) return;
try {
await ref.read(storageProvider).enableDecoyPin(pin);
ref.invalidate(hasDecoyPinProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settingsDecoyPinEnabled)),
);
}
} on DecoyPinConflict {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settingsDecoyPinConflict)),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(describeError(l10n, e))),
);
}
}
}
Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n;
await ref.read(storageProvider).disableDecoyPin();
ref.invalidate(hasDecoyPinProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settingsDecoyPinDisabled)),
);
}
}
void _showSeedPhrase(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n;
final storage = ref.read(storageProvider);
final seed = await storage.loadSeedPhrase();
if (seed == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settingsSeedPhraseNotFound)),
);
}
return;
}
if (!context.mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.settingsSeedPhraseTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
ctx.l10n.settingsSeedPhraseWarning,
style: const TextStyle(
fontSize: 13,
color: SepotColors.warning,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(seed.length, (i) {
return Chip(
label: Text(
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
style: SepotTheme.cryptoText,
),
backgroundColor: SepotColors.surfaceVariant,
side: const BorderSide(color: SepotColors.border),
);
}),
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonClose),
),
],
),
);
}
void _showAbout(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SepotLogo(size: 64),
const SizedBox(height: 16),
Text(
ctx.l10n.settingsAboutTagline,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
ctx.l10n.settingsVersion(AppConstants.appVersion),
style: const TextStyle(color: SepotColors.textSecondary),
),
const SizedBox(height: 16),
Text(
ctx.l10n.settingsAboutBody,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
color: SepotColors.textSecondary,
height: 1.5,
),
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonClose),
),
],
),
);
}
void _confirmDeleteAccount(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.settingsDeleteAccountTitle),
content: Text(
ctx.l10n.settingsDeleteAccountBody,
style: const TextStyle(color: SepotColors.textSecondary),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonCancel),
),
ElevatedButton(
onPressed: () async {
final messaging = ref.read(messagingServiceProvider);
// Serverová deregistrace PŘED lokálním wipe — podpis
// požadavku potřebuje identity klíč, který wipe zahodí.
final serverOk = await messaging.deleteRelayAccount();
// Messaging drží referenci na DB — zastavit PŘED smazáním
await messaging.stop();
await ref.read(storageProvider).deleteAll();
if (ctx.mounted) Navigator.of(ctx).pop();
if (context.mounted) {
if (!serverOk) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.settingsDeleteAccountServerOffline,
),
duration: const Duration(seconds: 8),
),
);
}
context.go('/onboarding');
}
},
style: ElevatedButton.styleFrom(
backgroundColor: SepotColors.error,
),
child: Text(ctx.l10n.settingsDeleteAllConfirm),
),
],
),
);
}
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/network/relay_client.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/settings_provider.dart';
/// Přepínač routování přes Tor (SOCKS5/Orbot na `.onion`).
///
/// Zapnutí skryje IP klienta před relayem — provoz jde přes lokální
/// Orbot. Bez běžícího Orbotu se spojení nenaváže (indikátor zůstane
/// „připojuji"), na což uživatele upozorníme předem. Přepnutí přestaví
/// spojení za běhu, restart appky netřeba.
class TorSettingsTile extends ConsumerStatefulWidget {
const TorSettingsTile({super.key});
@override
ConsumerState<TorSettingsTile> createState() => _TorSettingsTileState();
}
class _TorSettingsTileState extends ConsumerState<TorSettingsTile> {
bool _busy = false;
Future<void> _toggle(bool value) async {
if (value && !await _confirmOrbot()) return;
setState(() => _busy = true);
try {
await ref.read(torProvider.notifier).set(value);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.torToggleFailed(describeError(context.l10n, e)),
),
),
);
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
/// Tor přes Orbot potřebuje běžící Orbot s aktivním SOCKS proxy.
Future<bool> _confirmOrbot() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.torConnectionTitle),
content: Text(ctx.l10n.torDialogBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.commonCancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.commonTurnOn),
),
],
),
);
return ok ?? false;
}
@override
Widget build(BuildContext context) {
final enabled = ref.watch(torProvider).valueOrNull ?? false;
// Stav připojení má smysl ukazovat jen když je Tor zapnutý — pomůže
// odhalit chybějící/vypnutý Orbot (uvázne na „připojuji").
String subtitle;
if (!enabled) {
subtitle = context.l10n.torSubtitleOff;
} else {
final conn = ref.watch(relayConnectionProvider).valueOrNull;
subtitle = switch (conn) {
RelayConnectionState.connected => context.l10n.torStatusConnected,
RelayConnectionState.connecting => context.l10n.torStatusConnecting,
_ => context.l10n.torStatusWaiting,
};
}
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: SepotColors.surfaceVariant,
),
child: Icon(
enabled ? Icons.vpn_lock : Icons.public,
size: 20,
color: enabled ? SepotColors.ember : SepotColors.textSecondary,
),
),
title: Text(
context.l10n.torConnectionTitle,
style: const TextStyle(fontSize: 15),
),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
),
trailing: _busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SepotColors.ember,
),
)
: Switch(
value: enabled,
onChanged: _toggle,
activeTrackColor: SepotColors.ember,
),
);
}
}