ну немножечко так оптимизировал чутка, добавил раздел 'производительность' в насторйки, немного сделал чуточку в кастомизации, пару мелочей переделал

This commit is contained in:
Jganenok
2026-05-16 21:10:21 +07:00
parent 4928acc0f9
commit fc7a225b8a
35 changed files with 1818 additions and 944 deletions
+5 -2
View File
@@ -29,6 +29,7 @@ class CachedChat {
final int? lastMsgId;
final int? lastMsgTime;
final String? lastMsgText;
final String? lastMsgTextOneLine;
final int? lastMsgSenderId;
final int unreadCount;
final int lastEventTime;
@@ -40,7 +41,7 @@ class CachedChat {
final Map<int, int> participants;
final Set<String> options;
const CachedChat({
CachedChat({
required this.id,
required this.accountId,
required this.type,
@@ -59,7 +60,9 @@ class CachedChat {
required this.seenTime,
required this.participants,
this.options = const {},
});
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
? lastMsgText.replaceAll('\n', ' ')
: lastMsgText;
bool get isOfficial => options.contains('OFFICIAL');
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppAccent {
static const prefKey = 'app_accent_seed';
static const List<({String label, Color? seed})> presets = [
(label: 'Системный', seed: null),
(label: 'Сиреневый', seed: Color(0xFFC1C4FF)),
(label: 'Синий', seed: Color(0xFF4F8EFF)),
(label: 'Бирюзовый', seed: Color(0xFF00BFA5)),
(label: 'Зелёный', seed: Color(0xFF43A047)),
(label: 'Янтарный', seed: Color(0xFFFFB300)),
(label: 'Розовый', seed: Color(0xFFE91E63)),
(label: 'Красный', seed: Color(0xFFE53935)),
(label: 'Фиолетовый', seed: Color(0xFF7E57C2)),
];
static Future<Color?> load() async {
final prefs = await SharedPreferences.getInstance();
final val = prefs.getInt(prefKey);
if (val == null) return null;
return Color(val);
}
static Future<void> save(Color? color) async {
final prefs = await SharedPreferences.getInstance();
if (color == null) {
await prefs.remove(prefKey);
} else {
await prefs.setInt(prefKey, color.toARGB32());
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum BubbleStyle { mobile, desktop }
class AppBubbleShape {
static const prefKey = 'app_bubble_shape';
static final ValueNotifier<BubbleStyle> current = ValueNotifier(
BubbleStyle.mobile,
);
static Future<BubbleStyle> load() async {
final prefs = await SharedPreferences.getInstance();
final val = prefs.getString(prefKey);
return _parse(val);
}
static Future<void> save(BubbleStyle style) async {
current.value = style;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, style.name);
}
static BubbleStyle _parse(String? val) {
if (val == BubbleStyle.desktop.name) return BubbleStyle.desktop;
return BubbleStyle.mobile;
}
static String label(BubbleStyle style) {
switch (style) {
case BubbleStyle.mobile:
return 'TG Mobile';
case BubbleStyle.desktop:
return 'TG Desktop';
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppCacheExtent {
static const prefKey = 'app_cache_extent';
static const double defaultValue = 5000;
static const double min = 1000;
static const double max = 10000;
static const double lowWarnThreshold = 2500;
static const double highWarnThreshold = 7000;
static final ValueNotifier<double> current = ValueNotifier(defaultValue);
static Future<double> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getDouble(prefKey);
if (raw == null) return defaultValue;
return clamp(raw);
}
static Future<void> save(double value) async {
final clamped = clamp(value);
current.value = clamped;
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(prefKey, clamped);
}
static double clamp(double v) {
if (v < min) return min;
if (v > max) return max;
return v;
}
}
View File
@@ -170,6 +170,8 @@ class _CallsTabState extends State<CallsTab> {
? CachedNetworkImage(
imageUrl: call.avatarUrl!,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, call.name),
@@ -299,6 +299,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: CachedNetworkImage(
imageUrl: widget.imageUrl,
fit: BoxFit.cover,
memCacheWidth: 360,
memCacheHeight: 360,
errorWidget: (context, error, stack) => _avatarLetters(cs),
),
)
@@ -394,7 +396,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: const Color(0xFF007AFF), size: 22),
Icon(icon, color: cs.primary, size: 22),
const SizedBox(height: 4),
Text(
label,
@@ -806,7 +808,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
(avatar != null && avatar.isNotEmpty)
? CircleAvatar(
radius: 22,
backgroundImage: CachedNetworkImageProvider(avatar),
backgroundImage: CachedNetworkImageProvider(avatar, maxWidth: 144, maxHeight: 144),
backgroundColor: cs.primaryContainer,
)
: CircleAvatar(
+155 -133
View File
@@ -74,16 +74,24 @@ class _ChatListScreenState extends State<ChatListScreen>
double _navDragBaseLeft = 0;
double _revealAnimBegin = 0.0;
double _closeAnimBegin = 0.0;
double _pullRatio = 0.0;
static const double _kStoriesPullTriggerPx = 16.0;
final _StoriesUi _storiesUi = _StoriesUi();
double get _pullRatio => _storiesUi.pullRatio;
set _pullRatio(double v) => _storiesUi.pullRatio = v;
bool get _storiesDockedOpen => _storiesUi.dockedOpen;
set _storiesDockedOpen(bool v) => _storiesUi.dockedOpen = v;
bool get _storiesOverscrollRevealArmed => _storiesUi.overscrollRevealArmed;
set _storiesOverscrollRevealArmed(bool v) =>
_storiesUi.overscrollRevealArmed = v;
bool get _shouldCollapseSearch => _storiesUi.shouldCollapseSearch;
set _shouldCollapseSearch(bool v) => _storiesUi.shouldCollapseSearch = v;
bool _navDragging = false;
bool _isFabOpen = false;
bool _showCacheWarning = false;
bool _storiesAnimClosing = false;
bool _storiesDockedOpen = false;
bool _storiesOverscrollRevealArmed = true;
bool _shouldCollapseSearch = false;
Timer? _contactRebuildTimer;
bool get _isSelectionMode => _selectedChats.isNotEmpty;
bool? _foldersListKnown;
@@ -370,11 +378,19 @@ class _ChatListScreenState extends State<ChatListScreen>
for (final id in ids) {
messagesModule.searchContactById(id).whenComplete(() {
_inflightContactIds.remove(id);
if (mounted) setState(() {});
_scheduleContactRebuild();
});
}
}
void _scheduleContactRebuild() {
if (!mounted) return;
_contactRebuildTimer?.cancel();
_contactRebuildTimer = Timer(const Duration(milliseconds: 120), () {
if (mounted) setState(() {});
});
}
List<CachedChat> _chatsForPageIndex(int pageIndex) {
List<CachedChat> base;
if (_folders.isEmpty) {
@@ -436,9 +452,8 @@ class _ChatListScreenState extends State<ChatListScreen>
if (!c.hasClients) return;
final double offset = c.offset;
if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) {
setState(() {
_shouldCollapseSearch = true;
});
_shouldCollapseSearch = true;
_storiesUi.notify();
}
if (offset < 0) {
@@ -453,9 +468,8 @@ class _ChatListScreenState extends State<ChatListScreen>
_startStoriesAutoReveal(dragRatio);
} else if (!_storiesDockedOpen) {
if (dragRatio != _pullRatio) {
setState(() {
_pullRatio = dragRatio;
});
_pullRatio = dragRatio;
_storiesUi.notify();
}
}
} else {
@@ -470,14 +484,9 @@ class _ChatListScreenState extends State<ChatListScreen>
final disarm = offset > 3 && _storiesOverscrollRevealArmed;
final clearPull = _pullRatio > 0;
if (disarm || clearPull) {
setState(() {
if (disarm) {
_storiesOverscrollRevealArmed = false;
}
if (clearPull) {
_pullRatio = 0.0;
}
});
if (disarm) _storiesOverscrollRevealArmed = false;
if (clearPull) _pullRatio = 0.0;
_storiesUi.notify();
}
}
}
@@ -518,85 +527,81 @@ class _ChatListScreenState extends State<ChatListScreen>
animation: _shimmerController,
builder: (context, child) {
final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2);
return Opacity(
opacity: opacity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 48,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 120,
height: 14,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(7),
),
),
Container(
width: double.infinity,
height: 12,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
),
],
),
),
);
return Opacity(opacity: opacity, child: child);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 48,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 120,
height: 14,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(7),
),
),
Container(
width: double.infinity,
height: 12,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
),
],
),
),
);
}
void _onStoriesRevealTick() {
if (!mounted) return;
final t = Curves.easeOutCubic.transform(_storiesRevealController.value);
setState(() {
if (_storiesAnimClosing) {
_pullRatio = _closeAnimBegin * (1.0 - t);
} else {
_pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t;
}
});
if (_storiesAnimClosing) {
_pullRatio = _closeAnimBegin * (1.0 - t);
} else {
_pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t;
}
_storiesUi.notify();
}
void _onStoriesRevealStatus(AnimationStatus status) {
if (!mounted) return;
if (status == AnimationStatus.completed) {
setState(() {
if (_storiesAnimClosing) {
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
_storiesOverscrollRevealArmed = true;
} else {
_pullRatio = 1.0;
_storiesDockedOpen = true;
_storiesRevealLayoutSettleUntil = DateTime.now().add(
const Duration(milliseconds: 520),
);
}
});
if (_storiesAnimClosing) {
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
_storiesOverscrollRevealArmed = true;
} else {
_pullRatio = 1.0;
_storiesDockedOpen = true;
_storiesRevealLayoutSettleUntil = DateTime.now().add(
const Duration(milliseconds: 520),
);
}
_storiesUi.notify();
}
}
@@ -607,10 +612,9 @@ class _ChatListScreenState extends State<ChatListScreen>
_storiesAnimClosing = false;
final from = max(_pullRatio, suggestedFrom.clamp(0.0, 1.0));
if (from >= 1.0) {
setState(() {
_pullRatio = 1.0;
_storiesDockedOpen = true;
});
_pullRatio = 1.0;
_storiesDockedOpen = true;
_storiesUi.notify();
_storiesRevealLayoutSettleUntil = DateTime.now().add(
const Duration(milliseconds: 520),
);
@@ -638,12 +642,11 @@ class _ChatListScreenState extends State<ChatListScreen>
_storiesAnimClosing = true;
final from = _pullRatio.clamp(0.0, 1.0);
if (from <= 0) {
setState(() {
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
_storiesOverscrollRevealArmed = true;
});
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
_storiesOverscrollRevealArmed = true;
_storiesUi.notify();
return;
}
_closeAnimBegin = from;
@@ -659,9 +662,8 @@ class _ChatListScreenState extends State<ChatListScreen>
if (n is ScrollEndNotification) {
if (n.metrics.pixels <= 0.5) {
setState(() {
_storiesOverscrollRevealArmed = true;
});
_storiesOverscrollRevealArmed = true;
_storiesUi.notify();
}
return false;
}
@@ -714,6 +716,8 @@ class _ChatListScreenState extends State<ChatListScreen>
c.removeListener(fn);
c.dispose();
}
_contactRebuildTimer?.cancel();
_storiesUi.dispose();
super.dispose();
}
@@ -774,15 +778,17 @@ class _ChatListScreenState extends State<ChatListScreen>
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRect(
clipBehavior: Clip.hardEdge,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: _shouldCollapseSearch
? const SizedBox(width: double.infinity, height: 52)
: Column(
ListenableBuilder(
listenable: _storiesUi,
builder: (context, _) => ClipRect(
clipBehavior: Clip.hardEdge,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: _shouldCollapseSearch
? const SizedBox(width: double.infinity, height: 52)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -933,7 +939,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 3, 20, 14),
padding: const EdgeInsets.fromLTRB(20, 3, 20, 4),
child: Container(
height: 44,
decoration: BoxDecoration(
@@ -974,13 +980,14 @@ class _ChatListScreenState extends State<ChatListScreen>
),
],
),
),
),
),
if (_folders.length > 1)
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
height: 48,
height: 34,
color: cs.surface,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
@@ -1007,7 +1014,7 @@ class _ChatListScreenState extends State<ChatListScreen>
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
vertical: 2,
),
physics: const BouncingScrollPhysics(),
children: [
@@ -1024,7 +1031,7 @@ class _ChatListScreenState extends State<ChatListScreen>
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
vertical: 2,
),
child: Row(
children: [
@@ -1054,6 +1061,13 @@ class _ChatListScreenState extends State<ChatListScreen>
final chats = _chatsForPageIndex(pageIndex);
final sc = _folderChatScrollControllers[pageIndex];
final cs = Theme.of(context).colorScheme;
final pinnedCount = _isInitialLoading
? 0
: chats.where((c) => (c.favIndex ?? 0) > 0).length;
final hasSeparator = pinnedCount > 0 && pinnedCount < chats.length;
final totalItems = _isInitialLoading
? 10
: chats.length + (hasSeparator ? 1 : 0);
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification n) {
if (_currentNavIndex != 0) return false;
@@ -1098,10 +1112,6 @@ class _ChatListScreenState extends State<ChatListScreen>
return _buildChatShimmer();
}
final pinnedCount = chats.where((c) => (c.favIndex ?? 0) > 0).length;
final hasSeparator = pinnedCount > 0 && pinnedCount < chats.length;
// Insert separator row between pinned and regular sections
if (hasSeparator && index == pinnedCount) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
@@ -1131,7 +1141,7 @@ class _ChatListScreenState extends State<ChatListScreen>
return _buildChatItem(
chat.id.toString(),
name ?? "Пользователь",
chat.lastMsgText?.replaceAll('\n', ' ') ?? '',
chat.lastMsgTextOneLine ?? '',
_formatTime(chat.lastMsgTime),
avatar ?? "",
isOnline: chat.isOnline,
@@ -1172,7 +1182,7 @@ class _ChatListScreenState extends State<ChatListScreen>
chatType: chat.type,
);
}
}, childCount: _isInitialLoading ? 10 : chats.length + (chats.any((c) => (c.favIndex ?? 0) > 0) && chats.any((c) => (c.favIndex ?? 0) <= 0) ? 1 : 0)),
}, childCount: totalItems),
),
SliverPadding(
padding: EdgeInsets.only(
@@ -1673,7 +1683,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
child: CircleAvatar(
radius: 26,
backgroundImage: CachedNetworkImageProvider(imageUrl),
backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
),
),
const SizedBox(height: 6),
@@ -1742,33 +1752,36 @@ class _ChatListScreenState extends State<ChatListScreen>
final isSelected = _selectedFolderId == folderId;
return GestureDetector(
onTap: () {
final i = _folders.indexWhere((f) => f.id == folderId);
if (i < 0) return;
final target = _folders.indexWhere((f) => f.id == folderId);
if (target < 0) return;
setState(() => _selectedFolderId = folderId);
if (_folderPageController.hasClients) {
final cur = _folderPageController.page?.round();
if (cur != i) {
_folderPageController.animateToPage(
i,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
final cur = _folderPageController.page?.round() ?? 0;
if (cur == target) return;
if ((target - cur).abs() > 1) {
final neighbor = target > cur ? target - 1 : target + 1;
_folderPageController.jumpToPage(neighbor);
}
_folderPageController.animateToPage(
target,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
}
},
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(8),
),
child: Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color: isSelected ? cs.onPrimaryContainer : cs.primary,
fontSize: 14,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
@@ -1829,7 +1842,7 @@ Navigator.push(
radius: 24,
backgroundColor: cs.surfaceContainerHighest,
backgroundImage: imageUrl.isNotEmpty
? CachedNetworkImageProvider(imageUrl)
? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144)
: null,
child: imageUrl.isEmpty
? Text(
@@ -2158,9 +2171,18 @@ Navigator.push(
),
child: CircleAvatar(
radius: 12,
backgroundImage: CachedNetworkImageProvider(imageUrl),
backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
),
),
);
}
}
class _StoriesUi extends ChangeNotifier {
double pullRatio = 0.0;
bool dockedOpen = false;
bool overscrollRevealArmed = true;
bool shouldCollapseSearch = false;
void notify() => notifyListeners();
}
+157 -109
View File
@@ -10,6 +10,7 @@ import '../../../backend/api.dart';
import '../../../backend/modules/messages.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/haptics.dart';
import '../../../core/config/app_cache_extent.dart';
import '../../../models/attachment.dart';
import '../../widgets/message_bubble.dart';
import '../../widgets/attachment_panel.dart';
@@ -49,18 +50,18 @@ class _ChatScreenState extends State<ChatScreen>
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final GlobalKey _listKey = GlobalKey();
bool _hasText = false;
final ValueNotifier<bool> _hasText = ValueNotifier(false);
bool _isLoading = true;
bool _showAttachmentPanel = false;
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
late AnimationController _shimmerController;
List<CachedMessage> _messages = [];
int _myId = 0;
CachedChat? chat;
DateTime? _floatingDate;
DateTime? _lastFloatingDate;
final ValueNotifier<DateTime?> _floatingDate = ValueNotifier(null);
Timer? _floatingDateTimer;
late final AnimationController _floatingDateAnimController;
late final CurvedAnimation _floatingDateCurved;
final Map<int, GlobalKey> _separatorKeys = {};
double _lastScrollOffset = 0;
String? _lastSentId;
@@ -79,6 +80,11 @@ class _ChatScreenState extends State<ChatScreen>
duration: const Duration(milliseconds: 220),
reverseDuration: const Duration(milliseconds: 380),
);
_floatingDateCurved = CurvedAnimation(
parent: _floatingDateAnimController,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
);
_loadHistory();
}
@@ -139,7 +145,11 @@ class _ChatScreenState extends State<ChatScreen>
_messageController.removeListener(_onTextChanged);
_scrollController.removeListener(_onScrollForDate);
_floatingDateTimer?.cancel();
_floatingDateCurved.dispose();
_floatingDateAnimController.dispose();
_floatingDate.dispose();
_hasText.dispose();
_showAttachmentPanel.dispose();
_messageController.dispose();
_scrollController.dispose();
_shimmerController.dispose();
@@ -147,11 +157,9 @@ class _ChatScreenState extends State<ChatScreen>
}
void _onTextChanged() {
final bool newHasText = _messageController.text.trim().isNotEmpty;
if (newHasText != _hasText) {
setState(() {
_hasText = newHasText;
});
final newHasText = _messageController.text.trim().isNotEmpty;
if (newHasText != _hasText.value) {
_hasText.value = newHasText;
}
}
@@ -189,11 +197,11 @@ class _ChatScreenState extends State<ChatScreen>
status: 'sending',
);
_hasText.value = false;
setState(() {
_lastSentId = tempId;
_messages.add(tempMessage);
_messageController.clear();
_hasText = false;
});
// Instant tactile "whoosh" the moment the message leaves the composer,
@@ -386,12 +394,8 @@ class _ChatScreenState extends State<ChatScreen>
if (result == null) return;
final bool dateChanged = result != _lastFloatingDate;
_lastFloatingDate = result;
if (result != _floatingDate) {
setState(() => _floatingDate = result);
}
final bool dateChanged = result != _floatingDate.value;
_floatingDate.value = result;
if (dateChanged) {
_floatingDateAnimController.forward(from: 0);
@@ -420,7 +424,7 @@ class _ChatScreenState extends State<ChatScreen>
}
Widget _buildDateSeparatorWidget(BuildContext context, DateTime date,
{Key? key}) {
{Key? key, bool floating = false}) {
final cs = Theme.of(context).colorScheme;
return Padding(
key: key,
@@ -429,7 +433,7 @@ class _ChatScreenState extends State<ChatScreen>
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withValues(alpha: 0.6),
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
@@ -437,7 +441,7 @@ class _ChatScreenState extends State<ChatScreen>
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontStyle: FontStyle.italic,
fontStyle: floating ? FontStyle.normal : FontStyle.italic,
),
),
),
@@ -482,7 +486,7 @@ class _ChatScreenState extends State<ChatScreen>
if (widget.imageUrl.isNotEmpty)
CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(widget.imageUrl),
backgroundImage: CachedNetworkImageProvider(widget.imageUrl, maxWidth: 144, maxHeight: 144),
)
else
CircleAvatar(
@@ -563,16 +567,21 @@ class _ChatScreenState extends State<ChatScreen>
_buildInputArea(context),
],
),
if (_showAttachmentPanel)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AttachmentPanel(
chatId: widget.chatId,
onClose: () => setState(() => _showAttachmentPanel = false),
),
),
ValueListenableBuilder<bool>(
valueListenable: _showAttachmentPanel,
builder: (context, open, _) {
if (!open) return const SizedBox.shrink();
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: AttachmentPanel(
chatId: widget.chatId,
onClose: () => _showAttachmentPanel.value = false,
),
);
},
),
],
),
);
@@ -595,11 +604,13 @@ class _ChatScreenState extends State<ChatScreen>
return Stack(
key: _listKey,
children: [
ListView.builder(
ValueListenableBuilder<double>(
valueListenable: AppCacheExtent.current,
builder: (context, cacheExtent, _) => ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
cacheExtent: 9999,
cacheExtent: cacheExtent,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[items.length - 1 - index];
@@ -641,32 +652,34 @@ class _ChatScreenState extends State<ChatScreen>
return bubble;
},
),
if (_lastFloatingDate != null)
Positioned(
top: 8,
left: 0,
right: 0,
child: IgnorePointer(
child: AnimatedBuilder(
animation: _floatingDateAnimController,
builder: (context, child) {
final t = CurvedAnimation(
parent: _floatingDateAnimController,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
).value;
return Opacity(
opacity: t,
child: Transform.scale(
scale: 0.82 + 0.18 * t,
child: child,
),
);
},
child: _buildDateSeparatorWidget(context, _lastFloatingDate!),
),
),
Positioned(
top: 8,
left: 0,
right: 0,
child: IgnorePointer(
child: ValueListenableBuilder<DateTime?>(
valueListenable: _floatingDate,
builder: (context, date, _) {
if (date == null) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _floatingDateCurved,
builder: (context, child) {
final t = _floatingDateCurved.value;
return Opacity(
opacity: t,
child: Transform.scale(
scale: 0.82 + 0.18 * t,
child: child,
),
);
},
child: _buildDateSeparatorWidget(context, date, floating: true),
);
},
),
),
),
],
);
}
@@ -842,7 +855,7 @@ class _ChatScreenState extends State<ChatScreen>
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter &&
!HardwareKeyboard.instance.isShiftPressed) {
if (_hasText) _sendMessage();
if (_hasText.value) _sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
@@ -868,64 +881,35 @@ class _ChatScreenState extends State<ChatScreen>
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: _hasText ? 0 : 36,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _hasText ? 0 : 1,
child: _hasText
? const SizedBox.shrink()
: GestureDetector(
onTap: _showAttachmentPanel ? null : () => setState(() => _showAttachmentPanel = true),
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Stack(
alignment: Alignment.center,
children: [
if (_showAttachmentPanel)
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.primary,
),
),
Icon(
Symbols.attachment,
color: _showAttachmentPanel
? cs.onSurfaceVariant.withValues(alpha: 0.3)
: mutedIcon,
size: 24,
weight: 400,
),
],
),
),
),
),
_AttachButton(
hasText: _hasText,
panelOpen: _showAttachmentPanel,
mutedIcon: mutedIcon,
cs: cs,
),
],
),
),
),
const SizedBox(width: 8),
Container(
width: 54,
height: 54,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hasText ? cs.primary : cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: GestureDetector(
onTap: _hasText ? _sendMessage : null,
child: Icon(
_hasText ? Symbols.send : Symbols.mic,
color: _hasText ? cs.onPrimary : cs.onSurface,
size: 24,
weight: 400,
ValueListenableBuilder<bool>(
valueListenable: _hasText,
builder: (context, hasText, _) => Container(
width: 54,
height: 54,
alignment: Alignment.center,
decoration: BoxDecoration(
color: hasText ? cs.primary : cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: GestureDetector(
onTap: hasText ? _sendMessage : null,
child: Icon(
hasText ? Symbols.send : Symbols.mic,
color: hasText ? cs.onPrimary : cs.onSurface,
size: 24,
weight: 400,
),
),
),
),
@@ -936,6 +920,70 @@ class _ChatScreenState extends State<ChatScreen>
}
}
class _AttachButton extends StatelessWidget {
final ValueNotifier<bool> hasText;
final ValueNotifier<bool> panelOpen;
final Color mutedIcon;
final ColorScheme cs;
const _AttachButton({
required this.hasText,
required this.panelOpen,
required this.mutedIcon,
required this.cs,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: hasText,
builder: (context, isText, _) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isText ? 0 : 36,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isText ? 0 : 1,
child: isText
? const SizedBox.shrink()
: ValueListenableBuilder<bool>(
valueListenable: panelOpen,
builder: (context, open, _) => GestureDetector(
onTap: open ? null : () => panelOpen.value = true,
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Stack(
alignment: Alignment.center,
children: [
if (open)
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.primary,
),
),
Icon(
Symbols.attachment,
color: open
? cs.onSurfaceVariant.withValues(alpha: 0.3)
: mutedIcon,
size: 24,
weight: 400,
),
],
),
),
),
),
),
);
},
);
}
}
class _SentMessageAnimation extends StatefulWidget {
final Widget child;
final VoidCallback onComplete;
@@ -88,6 +88,8 @@ class _ContactsTabState extends State<ContactsTab> {
? CachedNetworkImage(
imageUrl: contact.baseUrl!,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, nameToDisplay),
@@ -0,0 +1,526 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/config/app_bubble_shape.dart';
import '../../../core/utils/haptics.dart';
import '../../../main.dart';
class AppearanceScreen extends StatefulWidget {
const AppearanceScreen({super.key});
@override
State<AppearanceScreen> createState() => _AppearanceScreenState();
}
class _AppearanceScreenState extends State<AppearanceScreen> {
static const _fallback = Color(0xFFC1C4FF);
final ValueNotifier<Color> _color = ValueNotifier(_fallback);
final ValueNotifier<bool> _isSystem = ValueNotifier(false);
bool _initialized = false;
bool _accentExpanded = false;
Timer? _debounce;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
final seed = KometApp.stateOf(context)?.accentSeed.value;
_isSystem.value = seed == null;
_color.value = seed ?? _fallback;
}
}
@override
void dispose() {
_debounce?.cancel();
_color.dispose();
_isSystem.dispose();
super.dispose();
}
void _onColorChanged(Color color) {
_color.value = color;
_isSystem.value = false;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () {
if (mounted) KometApp.stateOf(context)?.applyAccentColor(color);
});
}
void _resetToSystem() {
Haptics.selection();
_debounce?.cancel();
_isSystem.value = true;
_color.value = _fallback;
KometApp.stateOf(context)?.applyAccentColor(null);
}
void _toggleAccentExpanded() {
Haptics.tap();
setState(() => _accentExpanded = !_accentExpanded);
}
void _onStyleChanged(BubbleStyle style) {
Haptics.selection();
AppBubbleShape.save(style);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Внешний вид',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: [
_PreviewSection(color: _color, isSystem: _isSystem),
const SizedBox(height: 16),
_ColorPickerCard(
color: _color,
isSystem: _isSystem,
expanded: _accentExpanded,
onToggle: _toggleAccentExpanded,
onColorChanged: _onColorChanged,
onReset: _resetToSystem,
),
const SizedBox(height: 12),
_BubbleShapeCard(onChanged: _onStyleChanged),
],
),
),
);
}
}
class _PreviewSection extends StatefulWidget {
final ValueNotifier<Color> color;
final ValueNotifier<bool> isSystem;
const _PreviewSection({required this.color, required this.isSystem});
@override
State<_PreviewSection> createState() => _PreviewSectionState();
}
class _PreviewSectionState extends State<_PreviewSection> {
ColorScheme? _cachedScheme;
Color? _cachedColor;
Brightness? _cachedBrightness;
ColorScheme _schemeFor(Color color, Brightness brightness) {
if (_cachedScheme != null &&
_cachedColor == color &&
_cachedBrightness == brightness) {
return _cachedScheme!;
}
_cachedColor = color;
_cachedBrightness = brightness;
_cachedScheme = ColorScheme.fromSeed(
seedColor: color,
brightness: brightness,
);
return _cachedScheme!;
}
@override
Widget build(BuildContext context) {
final outerCs = Theme.of(context).colorScheme;
final brightness = Theme.of(context).brightness;
return ValueListenableBuilder<bool>(
valueListenable: widget.isSystem,
builder: (context, isSystem, _) {
if (isSystem) {
return Theme(
data: Theme.of(context).copyWith(colorScheme: outerCs),
child: const _ChatPreview(),
);
}
return ValueListenableBuilder<Color>(
valueListenable: widget.color,
builder: (context, color, _) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: _schemeFor(color, brightness),
),
child: const _ChatPreview(),
);
},
);
},
);
}
}
class _ChatPreview extends StatelessWidget {
const _ChatPreview();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ValueListenableBuilder<BubbleStyle>(
valueListenable: AppBubbleShape.current,
builder: (context, style, _) => Container(
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PreviewBubble(text: 'Как тебе?', isMe: true, style: style),
const SizedBox(height: 6),
_PreviewBubble(text: 'отлично выглядит!', isMe: false, style: style),
],
),
),
);
}
}
class _PreviewBubble extends StatelessWidget {
final String text;
final bool isMe;
final BubbleStyle style;
const _PreviewBubble({
required this.text,
required this.isMe,
required this.style,
});
BorderRadius get _radius {
const big = Radius.circular(20);
const small = Radius.circular(4);
final outside = style == BubbleStyle.mobile ? big : small;
return BorderRadius.only(
topLeft: isMe ? outside : big,
topRight: isMe ? big : outside,
bottomLeft: isMe ? outside : big,
bottomRight: isMe ? big : outside,
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final bg = isMe ? cs.primaryContainer : cs.surfaceContainerHighest;
final fg = Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: const BoxConstraints(maxWidth: 220),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(color: bg, borderRadius: _radius),
child: Text(
text,
style: TextStyle(color: fg, fontSize: 15, height: 1.3),
),
),
);
}
}
class _ColorPickerCard extends StatelessWidget {
final ValueNotifier<Color> color;
final ValueNotifier<bool> isSystem;
final bool expanded;
final VoidCallback onToggle;
final ValueChanged<Color> onColorChanged;
final VoidCallback onReset;
const _ColorPickerCard({
required this.color,
required this.isSystem,
required this.expanded,
required this.onToggle,
required this.onColorChanged,
required this.onReset,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ValueListenableBuilder<bool>(
valueListenable: isSystem,
builder: (context, sys, _) {
return ValueListenableBuilder<Color>(
valueListenable: color,
builder: (context, col, _) => _buildBody(cs, col, sys),
);
},
);
}
Widget _buildBody(ColorScheme cs, Color col, bool sys) {
final swatchColor = sys ? cs.primary : col;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
InkWell(
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 16, 18),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: swatchColor,
shape: BoxShape.circle,
border: Border.all(
color: cs.outlineVariant.withValues(alpha: 0.5),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Акцентный цвет',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
sys
? 'Системный'
: 'Основной цвет интерфейса и пузырей',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
AnimatedRotation(
duration: const Duration(milliseconds: 200),
turns: expanded ? 0.5 : 0,
child: Icon(
Symbols.expand_more,
color: cs.onSurfaceVariant,
size: 24,
),
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: expanded
? Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_HueStripPicker(
color: col,
onChanged: onColorChanged,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton.tonal(
onPressed: sys ? null : onReset,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.auto_awesome, size: 18, weight: 500),
const SizedBox(width: 8),
Text(sys
? 'Системный цвет активен'
: 'Сбросить на системный'),
],
),
),
),
],
),
)
: const SizedBox(width: double.infinity),
),
],
),
);
}
}
class _BubbleShapeCard extends StatelessWidget {
final ValueChanged<BubbleStyle> onChanged;
const _BubbleShapeCard({required this.onChanged});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Форма сообщения',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Скругление углов пузырей',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 16),
ValueListenableBuilder<BubbleStyle>(
valueListenable: AppBubbleShape.current,
builder: (context, current, _) {
return SegmentedButton<BubbleStyle>(
segments: const [
ButtonSegment(
value: BubbleStyle.mobile,
label: Text('TG Mobile'),
icon: Icon(Symbols.smartphone),
),
ButtonSegment(
value: BubbleStyle.desktop,
label: Text('TG Desktop'),
icon: Icon(Symbols.desktop_windows),
),
],
selected: {current},
onSelectionChanged: (set) {
if (set.isNotEmpty) onChanged(set.first);
},
);
},
),
],
),
),
);
}
}
class _HueStripPicker extends StatelessWidget {
final Color color;
final ValueChanged<Color> onChanged;
const _HueStripPicker({required this.color, required this.onChanged});
static const _gradient = LinearGradient(
colors: [
Color(0xFFFF0000),
Color(0xFFFFFF00),
Color(0xFF00FF00),
Color(0xFF00FFFF),
Color(0xFF0000FF),
Color(0xFFFF00FF),
Color(0xFFFF0000),
],
);
@override
Widget build(BuildContext context) {
final hue = HSVColor.fromColor(color).hue;
const trackHeight = 26.0;
const thumbDiameter = 30.0;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
void emit(double dx) {
final clamped = dx.clamp(0.0, width);
final newHue = (clamped / width) * 360;
onChanged(HSVColor.fromAHSV(1, newHue, 1, 1).toColor());
}
final thumbLeft = (hue / 360) * width - thumbDiameter / 2;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (d) => emit(d.localPosition.dx),
onPanUpdate: (d) => emit(d.localPosition.dx),
child: SizedBox(
height: thumbDiameter + 4,
child: Stack(
children: [
Center(
child: Container(
height: trackHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(trackHeight / 2),
gradient: _gradient,
),
),
),
Positioned(
left: thumbLeft.clamp(0, width - thumbDiameter),
top: (thumbDiameter + 4 - thumbDiameter) / 2,
child: Container(
width: thumbDiameter,
height: thumbDiameter,
decoration: BoxDecoration(
color: HSVColor.fromAHSV(1, hue, 1, 1).toColor(),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
],
),
),
);
},
);
}
}
@@ -3,6 +3,7 @@ import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/utils/haptics.dart';
import 'appearance_screen.dart';
import 'font_settings_screen.dart';
class _CustomizationCategory {
@@ -23,6 +24,12 @@ class CustomizationScreen extends StatelessWidget {
const CustomizationScreen({super.key});
static const List<_CustomizationCategory> _categories = [
_CustomizationCategory(
icon: Symbols.palette,
title: 'Внешний вид',
subtitle: 'Акцентный цвет интерфейса',
builder: _buildAppearance,
),
_CustomizationCategory(
icon: Symbols.text_fields,
title: 'Шрифты',
@@ -31,6 +38,9 @@ class CustomizationScreen extends StatelessWidget {
),
];
static Widget _buildAppearance(BuildContext context) =>
const AppearanceScreen();
static Widget _buildFontSettings(BuildContext context) =>
const FontSettingsScreen();
@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import '../../../core/config/app_cache_extent.dart';
import '../../../core/utils/haptics.dart';
class PerformanceScreen extends StatefulWidget {
const PerformanceScreen({super.key});
@override
State<PerformanceScreen> createState() => _PerformanceScreenState();
}
class _PerformanceScreenState extends State<PerformanceScreen> {
late double _value;
late double _preZoneValue;
bool _lowWarnDismissed = false;
bool _highWarnDismissed = false;
@override
void initState() {
super.initState();
_value = AppCacheExtent.current.value;
_preZoneValue = _value;
}
bool _isInSafeZone(double v) =>
v >= AppCacheExtent.lowWarnThreshold && v < AppCacheExtent.highWarnThreshold;
void _onChanged(double v) {
setState(() {
_value = v;
if (_isInSafeZone(v)) _preZoneValue = v;
});
}
Future<void> _onChangeEnd(double v) async {
Haptics.selection();
final inLow = v < AppCacheExtent.lowWarnThreshold;
final inHigh = v >= AppCacheExtent.highWarnThreshold;
if (inLow && !_lowWarnDismissed) {
final ok = await _showWarning(
text:
'Производительность приложения может снизиться, вы уверены?',
);
if (ok) {
_lowWarnDismissed = true;
await AppCacheExtent.save(v);
} else {
setState(() => _value = _preZoneValue);
await AppCacheExtent.save(_preZoneValue);
}
return;
}
if (inHigh && !_highWarnDismissed) {
final ok = await _showWarning(
text:
'Это врядли даст хотя-бы немного заметный прирост к FPS, '
'но может потреблять больше памяти. Вы уверены?',
);
if (ok) {
_highWarnDismissed = true;
await AppCacheExtent.save(v);
} else {
setState(() => _value = _preZoneValue);
await AppCacheExtent.save(_preZoneValue);
}
return;
}
await AppCacheExtent.save(v);
}
Future<bool> _showWarning({required String text}) async {
final cs = Theme.of(context).colorScheme;
final res = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
backgroundColor: cs.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
content: Text(
text,
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(
'Нет',
style: TextStyle(color: cs.onSurfaceVariant),
),
),
FilledButton.tonal(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Да'),
),
],
);
},
);
return res ?? false;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final hint = cs.onSurfaceVariant;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Производительность',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: [
Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Кеш сообщений',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Сколько пикселей сообщений держать построенными за пределами видимой области.',
style: TextStyle(color: hint, fontSize: 13, height: 1.3),
),
const SizedBox(height: 18),
Text(
'Текущий cacheExtent: ${_value.round()}',
style: TextStyle(color: hint, fontSize: 12),
),
const SizedBox(height: 4),
Slider(
value: _value,
min: AppCacheExtent.min,
max: AppCacheExtent.max,
onChanged: _onChanged,
onChangeEnd: _onChangeEnd,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Меньше потребление',
style: TextStyle(color: hint, fontSize: 11),
),
Text(
'Больше FPS',
style: TextStyle(color: hint, fontSize: 11),
),
],
),
],
),
),
),
],
),
),
);
}
}
@@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart';
import '../../../main.dart';
import '../auth/proxy_settings_sheet.dart';
import 'customization_screen.dart';
import 'performance_screen.dart';
import 'debug_menu_screen.dart';
import 'devices_screen.dart';
import 'edit_profile_screen.dart';
@@ -162,6 +163,18 @@ child: _buildSection(
);
},
),
_SettingsItem(
icon: Symbols.speed,
label: 'Производительность',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PerformanceScreen(),
),
);
},
),
],
),
),
@@ -393,6 +406,8 @@ child: _buildSection(
? CachedNetworkImage(
imageUrl: _profile!.baseUrl!,
fit: BoxFit.cover,
memCacheWidth: 240,
memCacheHeight: 240,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, name),
View File
View File
File diff suppressed because it is too large Load Diff
+97 -50
View File
@@ -7,6 +7,9 @@ import 'package:m3e_collection/m3e_collection.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'backend/api.dart';
import 'core/config/app_accent.dart';
import 'core/config/app_bubble_shape.dart';
import 'core/config/app_cache_extent.dart';
import 'core/config/app_fonts.dart';
import 'backend/modules/account.dart';
import 'backend/modules/contacts.dart';
@@ -65,6 +68,9 @@ void main() async {
final initialFontScale = AppFonts.clampScale(
prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale,
);
final initialAccentSeed = await AppAccent.load();
AppBubbleShape.current.value = await AppBubbleShape.load();
AppCacheExtent.current.value = await AppCacheExtent.load();
runApp(
KometApp(
initialLocale: initialLocale,
@@ -72,6 +78,7 @@ void main() async {
initialVpnBypass: initialVpnBypass,
initialFontId: initialFontId,
initialFontScale: initialFontScale,
initialAccentSeed: initialAccentSeed,
),
);
}
@@ -84,6 +91,7 @@ class KometApp extends StatefulWidget {
this.initialVpnBypass = false,
required this.initialFontId,
required this.initialFontScale,
this.initialAccentSeed,
});
final Locale initialLocale;
@@ -91,6 +99,7 @@ class KometApp extends StatefulWidget {
final bool initialVpnBypass;
final String initialFontId;
final double initialFontScale;
final Color? initialAccentSeed;
static final navigatorKey = GlobalKey<NavigatorState>();
static KometAppState? stateOf(BuildContext context) {
@@ -107,6 +116,9 @@ class KometAppState extends State<KometApp> {
late Locale _locale;
late String _fontId;
bool _isLoggingOut = false;
late final ValueNotifier<Color?> accentSeed = ValueNotifier(
widget.initialAccentSeed,
);
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
StreamSubscription<LoginStatus>? _loginStatusSub;
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
@@ -205,6 +217,7 @@ class KometAppState extends State<KometApp> {
fpsOverlayEnabled.dispose();
vpnBypassEnabled.dispose();
fontScale.dispose();
accentSeed.dispose();
super.dispose();
}
@@ -237,6 +250,11 @@ class KometAppState extends State<KometApp> {
String get fontId => _fontId;
Future<void> applyAccentColor(Color? seed) async {
await AppAccent.save(seed);
accentSeed.value = seed;
}
Future<void> applyAppFont(String fontId) async {
if (_fontId == fontId) return;
final prefs = await SharedPreferences.getInstance();
@@ -265,6 +283,28 @@ class KometAppState extends State<KometApp> {
ThemeData? _lightTheme;
ThemeData? _darkTheme;
Color? _seedCacheKey;
ColorScheme? _seedCacheLight;
ColorScheme? _seedCacheDark;
({ColorScheme light, ColorScheme dark}) _schemesForSeed(Color seed) {
if (_seedCacheKey == seed &&
_seedCacheLight != null &&
_seedCacheDark != null) {
return (light: _seedCacheLight!, dark: _seedCacheDark!);
}
_seedCacheKey = seed;
_seedCacheLight = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
);
_seedCacheDark = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
);
return (light: _seedCacheLight!, dark: _seedCacheDark!);
}
void _rebuildThemesIfNeeded(ColorScheme light, ColorScheme dark) {
if (_themeCacheFontId == _fontId &&
_themeCacheLight == light &&
@@ -334,65 +374,72 @@ class KometAppState extends State<KometApp> {
Widget build(BuildContext context) {
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
final lightBase =
lightDynamic ??
ColorScheme.fromSeed(
seedColor: _fallbackSeed,
brightness: Brightness.light,
);
final darkBase =
darkDynamic ??
ColorScheme.fromSeed(
seedColor: _fallbackSeed,
brightness: Brightness.dark,
);
return ValueListenableBuilder<Color?>(
valueListenable: accentSeed,
builder: (context, seed, _) {
final ColorScheme lightBase;
final ColorScheme darkBase;
if (seed != null) {
final s = _schemesForSeed(seed);
lightBase = s.light;
darkBase = s.dark;
} else if (lightDynamic != null && darkDynamic != null) {
lightBase = lightDynamic;
darkBase = darkDynamic;
} else {
final s = _schemesForSeed(_fallbackSeed);
lightBase = lightDynamic ?? s.light;
darkBase = darkDynamic ?? s.dark;
}
final lightScheme = _adjustLightScheme(lightBase);
final darkScheme = _adjustDarkScheme(darkBase);
final lightScheme = _adjustLightScheme(lightBase);
final darkScheme = _adjustDarkScheme(darkBase);
_rebuildThemesIfNeeded(lightScheme, darkScheme);
_rebuildThemesIfNeeded(lightScheme, darkScheme);
return MaterialApp(
title: 'Komet',
debugShowCheckedModeBanner: false,
locale: _locale,
themeMode: ThemeMode.system,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: _lightTheme,
darkTheme: _darkTheme,
navigatorKey: KometApp.navigatorKey,
builder: (context, child) {
return ValueListenableBuilder<double>(
valueListenable: fontScale,
child: child ?? const SizedBox.shrink(),
builder: (context, scale, appChild) {
Widget scaledChild = appChild!;
if ((scale - 1.0).abs() > 0.001) {
scaledChild = MediaQuery.withClampedTextScaling(
minScaleFactor: scale,
maxScaleFactor: scale,
child: scaledChild,
);
}
return ValueListenableBuilder<bool>(
valueListenable: fpsOverlayEnabled,
child: scaledChild,
builder: (context, fpsOn, sChild) {
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
sChild!,
if (fpsOn) const FpsOverlayLayer(),
],
return MaterialApp(
title: 'Komet',
debugShowCheckedModeBanner: false,
locale: _locale,
themeMode: ThemeMode.system,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: _lightTheme,
darkTheme: _darkTheme,
navigatorKey: KometApp.navigatorKey,
builder: (context, child) {
return ValueListenableBuilder<double>(
valueListenable: fontScale,
child: child ?? const SizedBox.shrink(),
builder: (context, scale, appChild) {
Widget scaledChild = appChild!;
if ((scale - 1.0).abs() > 0.001) {
scaledChild = MediaQuery.withClampedTextScaling(
minScaleFactor: scale,
maxScaleFactor: scale,
child: scaledChild,
);
}
return ValueListenableBuilder<bool>(
valueListenable: fpsOverlayEnabled,
child: scaledChild,
builder: (context, fpsOn, sChild) {
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
sChild!,
if (fpsOn) const FpsOverlayLayer(),
],
);
},
);
},
);
},
home: const _StartupScreen(),
);
},
home: const _StartupScreen(),
);
},
);
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+2 -58
View File
@@ -129,14 +129,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_lz4:
dependency: "direct main"
description:
@@ -355,54 +347,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.34"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -446,7 +390,7 @@ packages:
source: hosted
version: "1.0.2"
http:
dependency: "direct main"
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -454,7 +398,7 @@ packages:
source: hosted
version: "1.6.0"
http_parser:
dependency: "direct main"
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
-4
View File
@@ -36,7 +36,6 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
dart_lz4: ^1.0.0
libcompress: ^1.0.0
msgpack_dart: ^1.0.1
@@ -44,9 +43,6 @@ dependencies:
device_info_plus: 12.3.0
flutter_timezone: ^5.0.1
timezone: ^0.11.0
flutter_secure_storage: ^10.0.0
http: ^1.4.0
http_parser: ^4.1.0
file_picker: ^8.0.0
sqflite: ^2.4.2
sqflite_common_ffi: ^2.4.0+2