Files
Qlyra/lib/frontend/screens/profile/settings_tab.dart
T

1297 lines
44 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show OverScrollHeaderStretchConfiguration;
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../core/cache/self_presence.dart';
import '../../../core/config/app_colors.dart';
import '../../../core/config/komet_settings.dart';
import '../../../core/config/app_show_extra_info.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/update_checker.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/info_action_sheet.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/settings_card.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/update_dialog.dart';
import '../auth/login_screen.dart';
import '../auth/proxy_settings_sheet.dart';
import '../../../core/config/app_digital_id_mode.dart';
import '../../../core/utils/webview_support.dart';
import '../digital_id/digital_id_screen.dart';
import '../digital_id/digital_id_web_screen.dart';
import '../webapp/web_app_screen.dart';
import 'cloud_storage_screen.dart';
import 'customization_section.dart';
import 'debug_menu_screen.dart';
import 'devices_screen.dart';
import 'edit_profile_screen.dart';
import 'info_screen.dart';
import 'komet_settings_screen.dart';
import 'notifications_screen.dart';
import 'security_screen.dart';
import 'spoof_screen.dart';
class SettingsTab extends StatefulWidget {
const SettingsTab({super.key});
@override
State<SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends State<SettingsTab> {
ProfileData? _profile;
bool _isPhoneVisible = false;
ScrollController? _scrollController;
double _headerDelta = 0;
bool _headerEverExpanded = false;
bool _expandArmed = false;
bool _zoneHapticFired = false;
bool _pastCommitPoint = false;
String? _appVersionLabel;
bool _debugMenuVisible = false;
bool _isCheckingForUpdates = false;
int _versionSecretTapCount = 0;
Timer? _versionSecretTapResetTimer;
StreamSubscription? _profileUpdateSub;
@override
void initState() {
super.initState();
_loadProfile();
_loadAppVersion();
final appState = KometApp.stateOf(context);
if (appState != null) {
_profileUpdateSub = appState.profileUpdateStream.listen((_) {
if (mounted) _loadProfile();
});
}
}
@override
void dispose() {
_versionSecretTapResetTimer?.cancel();
_profileUpdateSub?.cancel();
_scrollController?.dispose();
super.dispose();
}
void _syncHeaderDelta(double delta) {
if (_scrollController == null) {
_scrollController = ScrollController(initialScrollOffset: delta);
_headerDelta = delta;
return;
}
if (_headerDelta == delta) return;
final prev = _headerDelta;
_headerDelta = delta;
WidgetsBinding.instance.addPostFrameCallback((_) {
final c = _scrollController;
if (!mounted || c == null || !c.hasClients) return;
final target = (c.offset + (delta - prev)).clamp(
0.0,
c.position.maxScrollExtent,
);
c.jumpTo(target);
});
}
bool _handleScrollNotification(ScrollNotification n, double delta) {
if (n.depth != 0) return false;
if (n is ScrollStartNotification) {
if (n.dragDetails != null) {
final px = n.metrics.pixels;
_expandArmed = delta > 0 && px <= delta + 8;
_zoneHapticFired = px < delta;
_pastCommitPoint = px < delta / 2;
}
} else if (n is ScrollUpdateNotification) {
if (n.dragDetails != null && delta > 0) {
final px = n.metrics.pixels;
if (px < delta) {
if (!_zoneHapticFired) {
_zoneHapticFired = true;
HapticFeedback.lightImpact();
}
} else {
_zoneHapticFired = false;
}
final pastCommit = px < delta / 2;
if (pastCommit != _pastCommitPoint) {
_pastCommitPoint = pastCommit;
HapticFeedback.mediumImpact();
}
}
} else if (n is ScrollEndNotification) {
_snapHeader(delta);
}
return false;
}
void _snapHeader(double delta) {
final c = _scrollController;
if (c == null || !c.hasClients || delta <= 0) return;
final offset = c.offset;
if (offset <= 0 || offset >= delta) return;
final target = offset < delta / 2 ? 0.0 : delta;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !c.hasClients) return;
c.animateTo(
target,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
});
}
void _scheduleVersionSecretTapReset() {
_versionSecretTapResetTimer?.cancel();
_versionSecretTapResetTimer = Timer(const Duration(seconds: 2), () {
if (mounted) setState(() => _versionSecretTapCount = 0);
});
}
void _onVersionLabelTap() {
_scheduleVersionSecretTapReset();
setState(() {
_versionSecretTapCount++;
if (_versionSecretTapCount >= 7) {
_versionSecretTapCount = 0;
_versionSecretTapResetTimer?.cancel();
_debugMenuVisible = !_debugMenuVisible;
}
});
}
Future<void> _loadProfile() async {
final p = await AppDatabase.loadActiveProfile();
if (mounted) setState(() => _profile = p);
}
Future<void> _loadAppVersion() async {
final info = await PackageInfo.fromPlatform();
if (!mounted) return;
setState(() {
_appVersionLabel = 'Версия ${info.version} (${info.buildNumber})';
});
}
Future<void> _checkForUpdates() async {
if (_isCheckingForUpdates) return;
setState(() => _isCheckingForUpdates = true);
final result = await UpdateChecker.checkNow();
if (!mounted) return;
setState(() => _isCheckingForUpdates = false);
switch (result.status) {
case UpdateCheckStatus.updateAvailable:
await showUpdateDialog(context, result.update!);
return;
case UpdateCheckStatus.upToDate:
showCustomNotification(
context,
AppLocalizations.of(context)!.updateUpToDate,
);
return;
case UpdateCheckStatus.failed:
showCustomNotification(
context,
AppLocalizations.of(context)!.updateCheckFailed,
);
return;
}
}
Future<void> _openCloudStorage(BuildContext context) async {
final cs = Theme.of(context).colorScheme;
final ok = await showInfoActionSheet(
context,
headerIcon: Symbols.cloud,
title: 'Облачное хранилище',
subtitle: 'Через МАХ',
items: [
const InfoActionSheetItem(
icon: Symbols.cloud_done,
title: 'Работает при белых списках',
body: 'Вы сможете передать файл даже при ограниченном интернете.',
),
const InfoActionSheetItem(
icon: Symbols.inventory_2,
title: 'Файлы до 4ГБ, безлимитное количество.',
body: 'Можете хранить массивный обьем информации.',
),
InfoActionSheetItem(
icon: Symbols.gpp_maybe,
title: 'Не обеспечивается конфединциальность файлов',
body:
'Облачное хранилище работает через ваш аккаунт на сервере МАХ, '
'нужные люди всё равно могут его посмотреть.',
titleColor: cs.error,
),
],
confirmLabel: 'ОК',
confirmDelay: const Duration(seconds: 3),
seenKey: 'cloud_storage_intro_seen',
);
if (!ok || !context.mounted) return;
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const CloudStorageScreen()),
);
}
Future<void> _confirmLogout() async {
final cs = Theme.of(context).colorScheme;
final confirmed = await showModalBottomSheet<bool>(
context: context,
backgroundColor: cs.surfaceContainerHigh,
shape: kSheetShape,
builder: (ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Выйти из аккаунта?',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Данные аккаунта будут удалены с этого устройства.',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: cs.error,
foregroundColor: cs.onError,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text('Выйти'),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Отмена'),
),
],
),
),
);
},
);
if (confirmed != true || !mounted) return;
await _doLogout();
}
Future<void> _doLogout() async {
final navState = KometApp.navigatorKey.currentState;
try {
await accountModule.logout();
} catch (e) {
if (mounted) showCustomNotification(context, 'Не удалось выйти: $e');
return;
}
await resetDigitalIdSession();
try {
await api.connect();
} catch (_) {}
if (navState != null) {
await navState.pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(route) => false,
);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
if (_profile == null) {
return const Center(child: SmallSpinner(size: 36));
}
final String fullName =
'${_profile!.firstName}${_profile!.lastName != null ? ' ${_profile!.lastName}' : ''}';
final String phone = _profile!.phone == 0
? l10n.profilePhoneRegenFailed
: '+${_profile!.phone}';
final size = MediaQuery.sizeOf(context);
final topPad = MediaQuery.paddingOf(context).top;
final hasPhoto = (_profile!.baseUrl ?? '').isNotEmpty;
return ValueListenableBuilder<bool>(
valueListenable: KometSettings.selfOnlineCheck,
builder: (context, statusEnabled, _) {
final collapsedH = topPad + (statusEnabled ? 268.0 : 242.0);
final expandedH = hasPhoto
? math.max(collapsedH, math.min(size.width, size.height * 0.65))
: collapsedH;
final delta = expandedH - collapsedH;
_syncHeaderDelta(delta);
return Scaffold(
backgroundColor: cs.surface,
body: NotificationListener<ScrollNotification>(
onNotification: (n) => _handleScrollNotification(n, delta),
child: CustomScrollView(
key: ValueKey(delta),
controller: _scrollController ??= ScrollController(
initialScrollOffset: delta,
),
physics: _HeaderPullScrollPhysics(
delta: delta,
isArmed: () => _expandArmed,
parent: const BouncingScrollPhysics(),
),
slivers: [
SliverPersistentHeader(
delegate: _ProfileHeaderDelegate(
collapsedExtent: collapsedH,
expandedExtent: expandedH,
headerBuilder: (ctx, t) =>
_buildHeader(ctx, cs, fullName, phone, t),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: ValueListenableBuilder<bool>(
valueListenable: AppShowExtraInfo.current,
builder: (context, showExtraInfo, _) {
return _buildSection(
context,
items: [
_SettingsItem(
icon: Symbols.badge,
label: 'Цифровой ID',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppDigitalIdNative.current.value ||
!webViewSupported
? const DigitalIdScreen()
: const DigitalIdWebScreen(),
),
);
},
),
_SettingsItem(
icon: Symbols.language,
label: 'Войти в Сферум',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WebAppScreen(
title: 'Сферум',
loader: () => webAppModule.fetchSferum(),
),
),
);
},
),
if (showExtraInfo)
_SettingsItem(
icon: Symbols.info,
label: 'Info',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const InfoScreen(),
),
);
},
),
],
);
},
),
),
),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 12, 16, 0),
child: CustomizationSection(),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: _buildSection(
context,
items: [
_SettingsItem(
icon: Symbols.notifications_active,
label: 'Уведомления',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const NotificationsScreen(),
),
);
},
),
_SettingsItem(
icon: Symbols.cloud,
label: 'Облачное хранилище [BETA]',
onTap: () => _openCloudStorage(context),
),
_SettingsItem(
icon: Symbols.vpn_lock,
label: 'Прокси',
onTap: () {
final cs = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: kSheetShape,
builder: (_) {
return SafeArea(
child: const ProxySettingsSheet(),
);
},
);
},
),
_SettingsItem(
icon: Symbols.shield_lock,
label: AppLocalizations.of(context)!.profileMenuSpoof,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SpoofScreen(),
),
);
},
),
_SettingsItem(
icon: Symbols.lock,
label: 'Безопасность',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(
name: 'SecurityScreen',
),
builder: (context) => const SecurityScreen(),
),
);
},
),
_SettingsItem(
icon: Symbols.devices,
label: 'Устройства',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DevicesScreen(),
),
);
},
),
],
),
),
),
SliverToBoxAdapter(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 340),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
return ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: animation.value.clamp(0.0, 1.0),
child: FadeTransition(
opacity: animation,
child: child,
),
),
);
},
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
clipBehavior: Clip.none,
children: <Widget>[...previousChildren, ?currentChild],
);
},
child: _debugMenuVisible
? KeyedSubtree(
key: const ValueKey('developers_settings_row'),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: _buildSection(
context,
items: [
_SettingsItem(
icon: Symbols.construction,
label: 'Для разработчиков',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const DebugMenuScreen(),
),
);
},
),
],
),
),
)
: const SizedBox.shrink(
key: ValueKey('developers_settings_hidden'),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: _buildSection(
context,
items: [
_SettingsItem(
icon: Symbols.system_update,
label: _isCheckingForUpdates
? l10n.updateChecking
: l10n.updateCheck,
onTap: _isCheckingForUpdates
? null
: _checkForUpdates,
),
_SettingsItem(
leading: Image.asset(
'assets/komet.png',
width: 22,
height: 22,
color: cs.onSurfaceVariant,
),
label: 'Komet',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const KometSettingsScreen(),
),
);
},
),
_SettingsItem(
icon: Symbols.logout,
label: 'Выйти из аккаунта',
tintColor: cs.error,
onTap: _confirmLogout,
),
],
),
),
),
if (_appVersionLabel != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 28, 16, 12),
child: Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _onVersionLabelTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
child: Text(
_appVersionLabel!,
textAlign: TextAlign.center,
style: TextStyle(
color: cs.onSurfaceVariant.withValues(
alpha: 0.75,
),
fontSize: 13,
fontWeight: FontWeight.w400,
),
),
),
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 120)),
],
),
),
);
},
);
}
Widget _buildHeader(
BuildContext context,
ColorScheme cs,
String name,
String phone,
double t,
) {
final topPad = MediaQuery.paddingOf(context).top;
final hasPhoto = (_profile?.baseUrl ?? '').isNotEmpty;
final phoneMissing = (_profile?.phone ?? 0) == 0;
final pt = hasPhoto ? t : 0.0;
if (pt > 0) _headerEverExpanded = true;
return ClipRect(
child: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
const avatarSize = 88.0;
final avatarRect = Rect.lerp(
Rect.fromLTWH(
(w - avatarSize) / 2,
topPad + 68,
avatarSize,
avatarSize,
),
Rect.fromLTWH(0, 0, w, h),
pt,
)!;
final radius = lerpDouble(avatarSize / 2, 0, pt)!;
final iconColor = Color.lerp(cs.onSurfaceVariant, Colors.white, pt)!;
final nameColor = Color.lerp(cs.onSurface, Colors.white, pt)!;
final subColor = Color.lerp(
cs.onSurfaceVariant,
Colors.white.withValues(alpha: 0.85),
pt,
)!;
return Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned.fromRect(
rect: avatarRect,
child: GestureDetector(
onTap: () => AvatarHistoryScreen.open(
context,
contactId: _profile?.id ?? 0,
name: name,
currentAvatarUrl: _profile?.baseUrl,
),
child: _buildMorphAvatar(cs, name, radius, pt),
),
),
if (hasPhoto)
Positioned(
left: 0,
right: 0,
top: 0,
height: topPad + 72,
child: IgnorePointer(
child: Opacity(
opacity: pt,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black38, Colors.transparent],
),
),
),
),
),
),
if (hasPhoto)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 150,
child: IgnorePointer(
child: Opacity(
opacity: pt,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
),
),
),
Positioned(
left: 8,
right: 8,
top: topPad + 8,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(
Symbols.qr_code_2,
color: iconColor,
size: 26,
weight: 400,
),
onPressed: () {},
),
Expanded(
child: Opacity(
opacity: 1 - pt,
child: const ConnectionStatusLine(
textAlign: TextAlign.center,
),
),
),
IconButton(
icon: Icon(
Symbols.edit,
color: iconColor,
size: 22,
weight: 400,
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const EditProfileScreen(),
),
);
},
),
],
),
),
Positioned(
left: 0,
right: 0,
bottom: lerpDouble(20, 14, pt)!,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_headerAligned(
pt,
Text(
name,
style: TextStyle(
color: nameColor,
fontSize: lerpDouble(20, 26, pt),
fontWeight: FontWeight.w700,
fontFamily: 'Outfit',
),
),
),
_headerAligned(
pt,
_buildOnlineStatus(cs, textColor: subColor),
),
const SizedBox(height: 6),
_headerAligned(
pt,
phoneMissing
? Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: Text(
phone,
textAlign: TextAlign.center,
style: TextStyle(
color: subColor,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.3,
),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => setState(
() => _isPhoneVisible = !_isPhoneVisible,
),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: _PhoneSpoiler(
text: phone,
isVisible: _isPhoneVisible,
style: TextStyle(
color: subColor,
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.5,
),
),
),
),
const SizedBox(width: 4),
Icon(
_isPhoneVisible
? Symbols.visibility
: Symbols.visibility_off,
size: 14,
color: Color.lerp(
cs.mutedText,
Colors.white70,
pt,
),
),
],
),
),
],
),
),
],
);
},
),
);
}
Widget _headerAligned(double t, Widget child) {
return Align(
alignment: Alignment.lerp(Alignment.center, Alignment.centerLeft, t)!,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: lerpDouble(12, 18, t)!),
child: child,
),
);
}
Widget _buildMorphAvatar(
ColorScheme cs,
String name,
double radius,
double pt,
) {
final base = _profile?.baseUrl;
final borderOpacity = (1 - pt * 2).clamp(0.0, 1.0);
if (base == null || base.isEmpty) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: cs.primary.withValues(alpha: 0.5),
width: 2.5,
),
),
child: KometAvatar(name: name, size: 88, fontSize: 32),
);
}
final letterFallback = ColoredBox(
color: cs.primaryContainer,
child: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
),
);
return Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: base,
fit: BoxFit.cover,
memCacheWidth: 264,
memCacheHeight: 264,
errorWidget: (_, _, _) => letterFallback,
),
if (_headerEverExpanded &&
_profile?.baseRawUrl != null &&
_profile!.baseRawUrl!.isNotEmpty)
CachedNetworkImage(
imageUrl: _profile!.baseRawUrl!,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
],
),
),
if (borderOpacity > 0)
IgnorePointer(
child: Opacity(
opacity: borderOpacity,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
border: Border.all(
color: cs.primary.withValues(alpha: 0.5),
width: 2.5,
),
),
),
),
),
],
);
}
String _formatSelfSeen(int seconds) {
final dt = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
final now = DateTime.now();
final time = formatClock(dt);
final isToday =
dt.year == now.year && dt.month == now.month && dt.day == now.day;
if (isToday) return time;
final datePart = dt.year == now.year
? '${dt.day} ${kRuMonthsShort[dt.month - 1]}'
: '${dt.day} ${kRuMonthsShort[dt.month - 1]} ${dt.year}';
return '$datePart, $time';
}
Widget _buildOnlineStatus(ColorScheme cs, {Color? textColor}) {
return ValueListenableBuilder<bool>(
valueListenable: KometSettings.selfOnlineCheck,
builder: (context, enabled, _) {
if (!enabled) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 6),
child: ValueListenableBuilder<bool>(
valueListenable: SelfPresence.isOnline,
builder: (context, online, _) => ValueListenableBuilder<int?>(
valueListenable: SelfPresence.lastSeenSeconds,
builder: (context, seen, _) {
final label = online
? 'онлайн'
: (seen != null
? 'Был(-а) ${_formatSelfSeen(seen)}'
: 'офлайн');
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.check_circle,
fill: 1,
size: 15,
color: online ? kOnlineGreen : cs.mutedText,
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
color: textColor ?? cs.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
],
);
},
),
),
);
},
);
}
Widget _buildSection(
BuildContext context, {
required List<_SettingsItem> items,
}) {
return SettingsCard(
children: List.generate(items.length, (index) {
final item = items[index];
return SettingsNavTile(
icon: item.icon,
leading: item.leading,
label: item.label,
tintColor: item.tintColor,
onTap: item.onTap,
isLast: index == items.length - 1,
);
}),
);
}
}
class _HeaderPullScrollPhysics extends ScrollPhysics {
final double delta;
final ValueGetter<bool> isArmed;
const _HeaderPullScrollPhysics({
required this.delta,
required this.isArmed,
super.parent,
});
static final SpringDescription _expressiveSpring =
SpringDescription.withDampingRatio(mass: 1, stiffness: 380, ratio: 0.9);
static const double _flingVelocity = 400;
@override
_HeaderPullScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _HeaderPullScrollPhysics(
delta: delta,
isArmed: isArmed,
parent: buildParent(ancestor),
);
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
if (delta <= 0 || offset <= 0 || position.pixels <= 0) {
return super.applyPhysicsToUserOffset(position, offset);
}
final px = position.pixels;
final free = math.max(0.0, px - delta);
if (offset <= free) return offset;
if (!isArmed()) return free;
final inZone = offset - free;
final expandedFraction = (1 - math.min(px, delta) / delta).clamp(0.0, 1.0);
final friction = lerpDouble(0.58, 0.3, expandedFraction)!;
return free + inZone * friction;
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
if (delta > 0) {
final px = position.pixels;
final tolerance = toleranceFor(position);
if (px > 0 && px < delta) {
final double target;
if (velocity <= -_flingVelocity) {
target = 0;
} else if (velocity >= _flingVelocity) {
target = delta;
} else {
target = px < delta / 2 ? 0 : delta;
}
if ((target - px).abs() < tolerance.distance &&
velocity.abs() < tolerance.velocity) {
return null;
}
return ScrollSpringSimulation(
_expressiveSpring,
px,
target,
velocity,
tolerance: tolerance,
);
}
if (px >= delta && velocity < 0) {
return BouncingScrollSimulation(
position: px,
velocity: velocity,
leadingExtent: delta,
trailingExtent: math.max(delta, position.maxScrollExtent),
spring: spring,
tolerance: tolerance,
);
}
}
return super.createBallisticSimulation(position, velocity);
}
}
class _ProfileHeaderDelegate extends SliverPersistentHeaderDelegate {
final double collapsedExtent;
final double expandedExtent;
final Widget Function(BuildContext context, double t) headerBuilder;
_ProfileHeaderDelegate({
required this.collapsedExtent,
required this.expandedExtent,
required this.headerBuilder,
});
@override
double get minExtent => collapsedExtent;
@override
double get maxExtent => expandedExtent;
@override
OverScrollHeaderStretchConfiguration get stretchConfiguration =>
OverScrollHeaderStretchConfiguration();
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
final range = expandedExtent - collapsedExtent;
final t = range <= 0 ? 0.0 : (1 - shrinkOffset / range).clamp(0.0, 1.0);
return headerBuilder(context, t);
}
@override
bool shouldRebuild(covariant _ProfileHeaderDelegate oldDelegate) => true;
}
class _SettingsItem {
final IconData? icon;
final Widget? leading;
final String label;
final VoidCallback? onTap;
final Color? tintColor;
const _SettingsItem({
this.icon,
this.leading,
required this.label,
this.onTap,
this.tintColor,
});
}
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),
);
if (!widget.isVisible) {
_controller.repeat();
}
}
@override
void didUpdateWidget(covariant _PhoneSpoiler oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isVisible == oldWidget.isVisible) return;
if (widget.isVisible) {
_controller.stop();
} else if (!_controller.isAnimating) {
_controller.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;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
const Radius.circular(4),
),
paint,
);
final particlePaint = Paint()..style = PaintingStyle.fill;
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;
}