ну немножечко так оптимизировал чутка, добавил раздел 'производительность' в насторйки, немного сделал чуточку в кастомизации, пару мелочей переделал
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+97
-50
@@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+2
-58
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user