я как бы еще не доделал, это такой промежуточный пуш знаете
This commit is contained in:
@@ -5,13 +5,14 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import '../chats/chat_list_screen.dart';
|
||||
import 'password_2fa_screen.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class CodeConfirmationScreen extends StatefulWidget {
|
||||
final String phoneNumber;
|
||||
final String token;
|
||||
|
||||
const CodeConfirmationScreen({
|
||||
super.key,
|
||||
super.key,
|
||||
required this.phoneNumber,
|
||||
required this.token,
|
||||
});
|
||||
@@ -20,11 +21,17 @@ class CodeConfirmationScreen extends StatefulWidget {
|
||||
State<CodeConfirmationScreen> createState() => _CodeConfirmationScreenState();
|
||||
}
|
||||
|
||||
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _codeController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
int _timerSeconds = 30;
|
||||
Timer? _timer;
|
||||
Timer? _errorTimer;
|
||||
|
||||
String? _errorMessage;
|
||||
late AnimationController _shakeController;
|
||||
late Animation<double> _shakeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -33,11 +40,26 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
|
||||
_shakeController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
_shakeAnimation = TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0.0, end: -8.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 2),
|
||||
TweenSequenceItem(tween: Tween(begin: 8.0, end: -8.0), weight: 2),
|
||||
TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 2),
|
||||
TweenSequenceItem(tween: Tween(begin: 8.0, end: -4.0), weight: 2),
|
||||
TweenSequenceItem(tween: Tween(begin: -4.0, end: 0.0), weight: 1),
|
||||
]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_errorTimer?.cancel();
|
||||
_shakeController.dispose();
|
||||
_codeController.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
@@ -57,11 +79,18 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
_errorTimer?.cancel();
|
||||
_shakeController.forward(from: 0);
|
||||
setState(() => _errorMessage = message);
|
||||
_errorTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted) setState(() => _errorMessage = null);
|
||||
});
|
||||
}
|
||||
|
||||
void _resendCode() {
|
||||
if (_timerSeconds == 0) {
|
||||
_startTimer();
|
||||
// TODO: вызвать accountModule.resendCode
|
||||
print('Resending code to ${widget.phoneNumber}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,29 +107,24 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
|
||||
if (result.requiresPassword) {
|
||||
final trackId = result.challengeTrackId;
|
||||
|
||||
|
||||
if (trackId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка: отсутствуют данные для 2FA')),
|
||||
);
|
||||
showCustomNotification(context, 'Ошибка: отсутствуют данные для 2FA');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Password2FAScreen(
|
||||
trackId: trackId,
|
||||
hint: result.challengeHint,
|
||||
),
|
||||
builder: (context) =>
|
||||
Password2FAScreen(trackId: trackId, hint: result.challengeHint),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если 2FA не требуется, делаем login
|
||||
final loginResult = await accountModule.login();
|
||||
|
||||
await accountModule.login();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.pushAndRemoveUntil(
|
||||
@@ -110,19 +134,15 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка: $e')),
|
||||
);
|
||||
_showError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToChats() {
|
||||
_verifyCode();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final hasError = _errorMessage != null;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
@@ -159,92 +179,155 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Stack(
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0,
|
||||
child: SizedBox(
|
||||
height: 0,
|
||||
width: 0,
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.number,
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
if (value.length == 6) {
|
||||
_navigateToChats();
|
||||
}
|
||||
},
|
||||
AnimatedBuilder(
|
||||
animation: _shakeAnimation,
|
||||
builder: (context, child) => Transform.translate(
|
||||
offset: Offset(_shakeAnimation.value, 0),
|
||||
child: child,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0,
|
||||
child: SizedBox(
|
||||
height: 0,
|
||||
width: 0,
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.number,
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (hasError) setState(() => _errorMessage = null);
|
||||
setState(() {});
|
||||
if (value.length == 6) _verifyCode();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => _focusNode.requestFocus(),
|
||||
child: FittedBox(
|
||||
child: Row(
|
||||
children: List.generate(6, (index) {
|
||||
bool isFocused = _codeController.text.length == index && _focusNode.hasFocus;
|
||||
bool hasValue = _codeController.text.length > index;
|
||||
String char = hasValue ? _codeController.text[index] : '';
|
||||
GestureDetector(
|
||||
onTap: () => _focusNode.requestFocus(),
|
||||
child: FittedBox(
|
||||
child: Row(
|
||||
children: List.generate(6, (index) {
|
||||
final isFocused =
|
||||
_codeController.text.length == index &&
|
||||
_focusNode.hasFocus;
|
||||
final hasValue =
|
||||
_codeController.text.length > index;
|
||||
final char = hasValue
|
||||
? _codeController.text[index]
|
||||
: '';
|
||||
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 54,
|
||||
margin: EdgeInsets.only(right: index == 5 ? 0 : 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isFocused
|
||||
? cs.primary
|
||||
: (hasValue ? cs.outlineVariant : Colors.transparent),
|
||||
width: 1.5,
|
||||
Color borderColor;
|
||||
if (hasError && hasValue) {
|
||||
borderColor = cs.error;
|
||||
} else if (isFocused) {
|
||||
borderColor = cs.primary;
|
||||
} else if (hasValue) {
|
||||
borderColor = cs.outlineVariant;
|
||||
} else {
|
||||
borderColor = Colors.transparent;
|
||||
}
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 44,
|
||||
height: 54,
|
||||
margin: EdgeInsets.only(
|
||||
right: index == 5 ? 0 : 10,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
char,
|
||||
key: ValueKey<String>(char + index.toString()),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: BoxDecoration(
|
||||
color: hasError && hasValue
|
||||
? cs.error.withValues(alpha: 0.1)
|
||||
: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
transitionBuilder:
|
||||
(
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
char,
|
||||
key: ValueKey<String>(
|
||||
char +
|
||||
index.toString() +
|
||||
(hasError ? 'e' : ''),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: hasError && hasValue
|
||||
? cs.error
|
||||
: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topLeft,
|
||||
child: hasError
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: AnimatedOpacity(
|
||||
opacity: hasError ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: cs.error,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GestureDetector(
|
||||
onTap: _resendCode,
|
||||
child: Text(
|
||||
_timerSeconds > 0
|
||||
? 'Отправить повторно через $_timerSeconds сек.'
|
||||
: 'Отправить код по SMS',
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
style: TextStyle(
|
||||
color: cs.tertiary,
|
||||
color: _timerSeconds > 0 ? cs.outline : cs.tertiary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
child: Text(
|
||||
_timerSeconds > 0
|
||||
? 'Отправить повторно через $_timerSeconds сек.'
|
||||
: 'Отправить код по SMS',
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -253,9 +336,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
onPressed: () {
|
||||
if (_codeController.text.length == 6) {
|
||||
_navigateToChats();
|
||||
}
|
||||
if (_codeController.text.length == 6) _verifyCode();
|
||||
},
|
||||
backgroundColor: _codeController.text.length == 6
|
||||
? cs.primaryContainer
|
||||
@@ -266,7 +347,9 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
),
|
||||
child: Icon(
|
||||
Icons.arrow_forward,
|
||||
color: _codeController.text.length == 6 ? cs.onPrimaryContainer : cs.onSurfaceVariant,
|
||||
color: _codeController.text.length == 6
|
||||
? cs.onPrimaryContainer
|
||||
: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -23,6 +24,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
late CountryName _selectedCountry;
|
||||
bool _isPhoneValid = false;
|
||||
bool _isTOSRead = false;
|
||||
String? _phoneError;
|
||||
Timer? _phoneErrorTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -31,6 +34,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_checkTOS();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneErrorTimer?.cancel();
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkTOS() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
@@ -308,9 +318,19 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showPhoneError(String message) {
|
||||
_phoneErrorTimer?.cancel();
|
||||
setState(() => _phoneError = message);
|
||||
_phoneErrorTimer = Timer(const Duration(seconds: 4), () {
|
||||
if (mounted) setState(() => _phoneError = null);
|
||||
});
|
||||
}
|
||||
|
||||
void _showPhoneConfirmationDialog(String formattedPhone) {
|
||||
final screenContext = context;
|
||||
|
||||
showGeneralDialog(
|
||||
context: context,
|
||||
context: screenContext,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: '',
|
||||
barrierColor: Colors.black54,
|
||||
@@ -374,18 +394,22 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
|
||||
final fullPhone = '${_selectedCountry.phoneCode}${_phoneController.text}';
|
||||
|
||||
|
||||
final fullPhone =
|
||||
'${_selectedCountry.phoneCode}${_phoneController.text}';
|
||||
|
||||
try {
|
||||
final result = await accountModule.requestCode(fullPhone);
|
||||
|
||||
final result = await accountModule.requestCode(
|
||||
fullPhone,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
screenContext,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CodeConfirmationScreen(
|
||||
phoneNumber: '${_selectedCountry.phoneCode} $formattedPhone',
|
||||
phoneNumber:
|
||||
'${_selectedCountry.phoneCode} $formattedPhone',
|
||||
token: result.token,
|
||||
),
|
||||
),
|
||||
@@ -393,7 +417,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
_showPhoneError(e.toString());
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -719,7 +743,28 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 8),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topLeft,
|
||||
child: _phoneError != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
_phoneError!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () => _showOtherLoginMethods(context),
|
||||
child: Text(
|
||||
|
||||
@@ -2,16 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../chats/chat_list_screen.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class Password2FAScreen extends StatefulWidget {
|
||||
final String trackId;
|
||||
final String? hint;
|
||||
|
||||
const Password2FAScreen({
|
||||
super.key,
|
||||
required this.trackId,
|
||||
this.hint,
|
||||
});
|
||||
const Password2FAScreen({super.key, required this.trackId, this.hint});
|
||||
|
||||
@override
|
||||
State<Password2FAScreen> createState() => _Password2FAScreenState();
|
||||
@@ -43,8 +40,7 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// После успешной 2FA делаем login
|
||||
final loginResult = await accountModule.login();
|
||||
await accountModule.login();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -60,9 +56,7 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Неверный пароль: $e')),
|
||||
);
|
||||
showCustomNotification(context, 'Неверный пароль: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +125,9 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordVisible ? Icons.visibility_off : Icons.visibility,
|
||||
_isPasswordVisible
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'dart:math';
|
||||
@@ -9,6 +10,10 @@ import 'chat_screen.dart';
|
||||
import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
import '../profile/settings_tab.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../main.dart' show api;
|
||||
|
||||
class ChatListScreen extends StatefulWidget {
|
||||
const ChatListScreen({super.key});
|
||||
@@ -25,7 +30,12 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
late AnimationController _fabController;
|
||||
final Set<String> _selectedChats = {};
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
double _pullRatio = 0.0; // 0.0 = folded (hidden row), 1.0 = fully expanded
|
||||
double _pullRatio = 0.0;
|
||||
ProfileData? _profile;
|
||||
List<CachedChat> _chats = [];
|
||||
SessionState _sessionState = SessionState.disconnected;
|
||||
StreamSubscription? _stateSub;
|
||||
bool _shouldCollapseSearch = false;
|
||||
|
||||
bool get _isSelectionMode => _selectedChats.isNotEmpty;
|
||||
|
||||
@@ -36,12 +46,21 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
} else {
|
||||
_selectedChats.add(chatId);
|
||||
}
|
||||
|
||||
if (_isSelectionMode) {
|
||||
if (_scrollController.hasClients && _scrollController.offset < 132) {
|
||||
_shouldCollapseSearch = true;
|
||||
}
|
||||
} else {
|
||||
_shouldCollapseSearch = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(() {
|
||||
_selectedChats.clear();
|
||||
_shouldCollapseSearch = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,11 +72,45 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
duration: const Duration(milliseconds: 350),
|
||||
);
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
_sessionState = api.state;
|
||||
_stateSub = api.stateStream.listen((state) {
|
||||
if (mounted) setState(() => _sessionState = state);
|
||||
});
|
||||
|
||||
_loadProfile();
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
final p = await AppDatabase.loadActiveProfile();
|
||||
if (p != null) {
|
||||
final chats = await ChatsModule.getChats(p.id);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_profile = p;
|
||||
_chats = chats;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(int? timestamp) {
|
||||
if (timestamp == null || timestamp == 0) return '';
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final h = dt.hour.toString().padLeft(2, '0');
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.hasClients) {
|
||||
final double offset = _scrollController.offset;
|
||||
if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) {
|
||||
setState(() {
|
||||
_shouldCollapseSearch = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (offset < 0) {
|
||||
final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0);
|
||||
if (newRatio != _pullRatio) {
|
||||
@@ -75,6 +128,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stateSub?.cancel();
|
||||
_fabController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
@@ -133,9 +187,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
height: _isSelectionMode
|
||||
height: _shouldCollapseSearch
|
||||
? 0
|
||||
: (132 + (96 * _pullRatio)),
|
||||
: (100 + (96 * _pullRatio)),
|
||||
color: Colors.transparent,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: AnimatedContainer(
|
||||
@@ -143,7 +197,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
curve: Curves.easeOutCubic,
|
||||
transform: Matrix4.translationValues(
|
||||
0,
|
||||
_isSelectionMode ? -100 : 0,
|
||||
_shouldCollapseSearch ? -100 : 0,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
@@ -153,7 +207,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
20,
|
||||
12,
|
||||
20,
|
||||
4,
|
||||
2,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
@@ -189,7 +243,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Подключение...',
|
||||
_sessionState == SessionState.online
|
||||
? (_profile?.firstName ?? 'Чат')
|
||||
: 'Подключение...',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
@@ -266,9 +322,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
20,
|
||||
4,
|
||||
2,
|
||||
20,
|
||||
12,
|
||||
8,
|
||||
),
|
||||
child: Container(
|
||||
height: 44,
|
||||
@@ -316,9 +372,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
top: _isSelectionMode ? 64 : 0,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -337,7 +391,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: [
|
||||
@@ -363,53 +417,24 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
_buildChatItem(
|
||||
'stas',
|
||||
'Станислав',
|
||||
'Хорошо',
|
||||
'10:07',
|
||||
'https://i.pravatar.cc/150?u=stas',
|
||||
isOnline: true,
|
||||
isRead: true,
|
||||
),
|
||||
_buildChatItem(
|
||||
'ilya',
|
||||
'Илья',
|
||||
'печатает...',
|
||||
'10:07',
|
||||
'https://i.pravatar.cc/150?u=ilya',
|
||||
isOnline: true,
|
||||
isTyping: true,
|
||||
unreadCount: 1,
|
||||
),
|
||||
_buildChatItem(
|
||||
'veronika',
|
||||
'Вероника',
|
||||
'Спасибо',
|
||||
'09:56',
|
||||
'https://i.pravatar.cc/150?u=veronika',
|
||||
isRead: true,
|
||||
),
|
||||
_buildChatItem(
|
||||
'komet',
|
||||
'Komet Client',
|
||||
'Кстати. Смотрите, какую шту...',
|
||||
'09:56',
|
||||
'https://i.pravatar.cc/150?u=komet',
|
||||
unreadCount: 5,
|
||||
isMuted: true,
|
||||
),
|
||||
_buildChatItem(
|
||||
'podezd',
|
||||
'4-й подъезд',
|
||||
'Людмила: Сколько?',
|
||||
'09:34',
|
||||
'https://i.pravatar.cc/150?u=podezd',
|
||||
unreadCount: 78,
|
||||
isMuted: true,
|
||||
),
|
||||
]),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final chat = _chats[index];
|
||||
return _buildChatItem(
|
||||
chat.id.toString(),
|
||||
chat.title ?? 'Чат',
|
||||
chat.lastMsgText ?? '',
|
||||
_formatTime(chat.lastMsgTime),
|
||||
(chat.iconUrl != null && chat.iconUrl!.isNotEmpty)
|
||||
? chat.iconUrl!
|
||||
: '',
|
||||
isOnline: chat.isOnline,
|
||||
unreadCount: chat.unreadCount,
|
||||
isMuted: chat.dontDisturbUntil > 0,
|
||||
);
|
||||
}, childCount: _chats.length),
|
||||
),
|
||||
const SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: 120),
|
||||
),
|
||||
],
|
||||
), // CustomScrollView
|
||||
@@ -532,7 +557,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isSelectionMode) ...[
|
||||
if (!_isSelectionMode && _currentNavIndex == 0) ...[
|
||||
if (_fabController.value > 0)
|
||||
Positioned(
|
||||
right: 20,
|
||||
@@ -726,7 +751,22 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
leading: Stack(
|
||||
children: [
|
||||
CircleAvatar(radius: 24, backgroundImage: NetworkImage(imageUrl)),
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
backgroundImage: imageUrl.isNotEmpty
|
||||
? NetworkImage(imageUrl)
|
||||
: null,
|
||||
child: imageUrl.isEmpty
|
||||
? Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 20,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned(
|
||||
right: -2,
|
||||
|
||||
@@ -58,10 +58,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
titleSpacing: 0,
|
||||
title: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: NetworkImage(widget.imageUrl),
|
||||
),
|
||||
if (widget.imageUrl.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: NetworkImage(widget.imageUrl),
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: Colors.blueGrey,
|
||||
child: Text(
|
||||
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -111,11 +121,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.network(
|
||||
'https://images.unsplash.com/photo-1579546929518-9e396f3cc809',
|
||||
fit: BoxFit.cover,
|
||||
opacity: const AlwaysStoppedAnimation(0.4),
|
||||
),
|
||||
child: widget.imageUrl.isNotEmpty
|
||||
? Image.network(
|
||||
widget.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
opacity: const AlwaysStoppedAnimation(0.4),
|
||||
)
|
||||
: Image.network(
|
||||
'https://images.unsplash.com/photo-1579546929518-9e396f3cc809',
|
||||
fit: BoxFit.cover,
|
||||
opacity: const AlwaysStoppedAnimation(0.4),
|
||||
),
|
||||
),
|
||||
const Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
|
||||
class SettingsTab extends StatelessWidget {
|
||||
class SettingsTab extends StatefulWidget {
|
||||
const SettingsTab({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsTab> createState() => _SettingsTabState();
|
||||
}
|
||||
|
||||
class _SettingsTabState extends State<SettingsTab> {
|
||||
ProfileData? _profile;
|
||||
bool _isPhoneVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProfile();
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
final p = await AppDatabase.loadActiveProfile();
|
||||
if (mounted) setState(() => _profile = p);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
if (_profile == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final String fullName =
|
||||
'${_profile!.firstName}${_profile!.lastName != null ? ' ${_profile!.lastName}' : ''}';
|
||||
final String phone = '+${_profile!.phone}';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: SafeArea(
|
||||
@@ -14,7 +44,9 @@ class SettingsTab extends StatelessWidget {
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildHeader(context, cs)),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildHeader(context, cs, fullName, phone),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
@@ -25,7 +57,7 @@ class SettingsTab extends StatelessWidget {
|
||||
_SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'),
|
||||
_SettingsItem(
|
||||
icon: Symbols.language,
|
||||
label: 'Войти в сферум',
|
||||
label: 'Войти в Сферум',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -55,7 +87,12 @@ class SettingsTab extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, ColorScheme cs) {
|
||||
Widget _buildHeader(
|
||||
BuildContext context,
|
||||
ColorScheme cs,
|
||||
String name,
|
||||
String phone,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 20),
|
||||
child: Column(
|
||||
@@ -95,23 +132,19 @@ class SettingsTab extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
'https://i.pravatar.cc/150?u=ilya',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => CircleAvatar(
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Icon(
|
||||
Symbols.person,
|
||||
color: cs.onPrimaryContainer,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
_profile!.baseUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, _, __) =>
|
||||
_buildPlaceholderAvatar(cs, name),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, name),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Илья Беларуских',
|
||||
name,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
@@ -119,39 +152,70 @@ class SettingsTab extends StatelessWidget {
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'@everrnyan',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: _PhoneSpoiler(
|
||||
text: phone,
|
||||
isVisible: _isPhoneVisible,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_isPhoneVisible ? Symbols.visibility : Symbols.visibility_off,
|
||||
size: 14,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
|
||||
return Container(
|
||||
color: cs.primaryContainer,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(
|
||||
BuildContext context,
|
||||
ColorScheme cs, {
|
||||
required List<_SettingsItem> items,
|
||||
}) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: List.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final isLast = index == items.length - 1;
|
||||
return _buildSettingsRow(context, cs, item, isLast: isLast);
|
||||
}),
|
||||
),
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: List.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final isLast = index == items.length - 1;
|
||||
return _buildSettingsRow(context, cs, item, isLast: isLast);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -168,6 +232,9 @@ class SettingsTab extends StatelessWidget {
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {},
|
||||
borderRadius: isLast
|
||||
? const BorderRadius.vertical(bottom: Radius.circular(20))
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
|
||||
child: Row(
|
||||
@@ -220,3 +287,93 @@ class _SettingsItem {
|
||||
|
||||
const _SettingsItem({required this.icon, required this.label});
|
||||
}
|
||||
|
||||
class _PhoneSpoiler extends StatefulWidget {
|
||||
final String text;
|
||||
final bool isVisible;
|
||||
final TextStyle style;
|
||||
|
||||
const _PhoneSpoiler({
|
||||
required this.text,
|
||||
required this.isVisible,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PhoneSpoiler> createState() => _PhoneSpoilerState();
|
||||
}
|
||||
|
||||
class _PhoneSpoilerState extends State<_PhoneSpoiler>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
crossFadeState: widget.isVisible
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: SizedBox(
|
||||
child: CustomPaint(
|
||||
size: const Size(110, 16),
|
||||
painter: _SpoilerPainter(_controller, widget.style.color!),
|
||||
),
|
||||
),
|
||||
secondChild: Text(widget.text, style: widget.style),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpoilerPainter extends CustomPainter {
|
||||
final Animation<double> animation;
|
||||
final Color color;
|
||||
|
||||
_SpoilerPainter(this.animation, this.color) : super(repaint: animation);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: 0.15)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// Draw the background
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(0, 0, size.width, size.height),
|
||||
const Radius.circular(4),
|
||||
),
|
||||
paint,
|
||||
);
|
||||
|
||||
// Draw "noisy" particles
|
||||
final particlePaint = Paint()..style = PaintingStyle.fill;
|
||||
|
||||
// Simple noise effect with dots using animation value for movement
|
||||
for (int i = 0; i < 60; i++) {
|
||||
double dx = (i * 17.5 + animation.value * 20) % size.width;
|
||||
double dy = (i * 13.7 + animation.value * 15) % size.height;
|
||||
double opacity = (0.2 + 0.3 * (i % 5) / 5.0).clamp(0.0, 1.0);
|
||||
particlePaint.color = color.withValues(alpha: opacity);
|
||||
canvas.drawCircle(Offset(dx, dy), 1.2, particlePaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_SpoilerPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user