Š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:
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user