я как бы еще не доделал, это такой промежуточный пуш знаете

This commit is contained in:
Jganenokk
2026-04-03 19:35:45 +07:00
parent 98c72e5d16
commit 91069f53ea
17 changed files with 987 additions and 417 deletions
@@ -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,
),
),
],
+55 -10
View File
@@ -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: () {
+101 -61
View File
@@ -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,
+25 -9
View File
@@ -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(
+196 -39
View File
@@ -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;
}