цэ фронт хуйнюшки которая где медиа отправлять
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
||||
<application
|
||||
android:label="Komet"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
enum GalleryPermission { granted, limited, denied }
|
||||
|
||||
abstract class GalleryItem {
|
||||
String get id;
|
||||
bool get isVideo;
|
||||
Duration? get duration;
|
||||
File? get localFile;
|
||||
Future<Uint8List?> thumbnail(int size);
|
||||
Future<File?> originFile();
|
||||
}
|
||||
|
||||
abstract class GallerySource {
|
||||
Future<GalleryPermission> ensurePermission();
|
||||
Future<List<GalleryItem>> load({int limit});
|
||||
Future<void> openSettings();
|
||||
Future<void> manageAccess();
|
||||
|
||||
factory GallerySource.create() {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
return _PhotoManagerSource();
|
||||
}
|
||||
return _DesktopGallerySource();
|
||||
}
|
||||
}
|
||||
|
||||
class _PhotoManagerSource implements GallerySource {
|
||||
@override
|
||||
Future<GalleryPermission> ensurePermission() async {
|
||||
final state = await PhotoManager.requestPermissionExtend();
|
||||
if (state.isAuth) return GalleryPermission.granted;
|
||||
if (state.hasAccess) return GalleryPermission.limited;
|
||||
return GalleryPermission.denied;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<GalleryItem>> load({int limit = 120}) async {
|
||||
final paths = await PhotoManager.getAssetPathList(
|
||||
type: RequestType.common,
|
||||
onlyAll: true,
|
||||
filterOption: FilterOptionGroup(
|
||||
orders: const [
|
||||
OrderOption(type: OrderOptionType.createDate, asc: false),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (paths.isEmpty) return const [];
|
||||
final assets = await paths.first.getAssetListRange(start: 0, end: limit);
|
||||
return assets.map((a) => _AssetGalleryItem(a)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() => PhotoManager.openSetting();
|
||||
|
||||
@override
|
||||
Future<void> manageAccess() => PhotoManager.presentLimited();
|
||||
}
|
||||
|
||||
class _AssetGalleryItem implements GalleryItem {
|
||||
final AssetEntity asset;
|
||||
|
||||
_AssetGalleryItem(this.asset);
|
||||
|
||||
@override
|
||||
String get id => asset.id;
|
||||
|
||||
@override
|
||||
bool get isVideo => asset.type == AssetType.video;
|
||||
|
||||
@override
|
||||
Duration? get duration => isVideo ? Duration(seconds: asset.duration) : null;
|
||||
|
||||
@override
|
||||
File? get localFile => null;
|
||||
|
||||
@override
|
||||
Future<Uint8List?> thumbnail(int size) =>
|
||||
asset.thumbnailDataWithSize(ThumbnailSize.square(size));
|
||||
|
||||
@override
|
||||
Future<File?> originFile() => asset.file;
|
||||
}
|
||||
|
||||
class _DesktopGallerySource implements GallerySource {
|
||||
static const _imageExtensions = {
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.heif',
|
||||
};
|
||||
|
||||
@override
|
||||
Future<GalleryPermission> ensurePermission() async =>
|
||||
GalleryPermission.granted;
|
||||
|
||||
@override
|
||||
Future<List<GalleryItem>> load({int limit = 120}) async {
|
||||
final entries = <({File file, DateTime modified})>[];
|
||||
for (final dir in _candidateDirs()) {
|
||||
if (!dir.existsSync()) continue;
|
||||
try {
|
||||
for (final entity in dir.listSync(followLinks: false)) {
|
||||
if (entity is! File || !_isImage(entity.path)) continue;
|
||||
entries.add((file: entity, modified: entity.statSync().modified));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
entries.sort((a, b) => b.modified.compareTo(a.modified));
|
||||
return entries
|
||||
.take(limit)
|
||||
.map((e) => _FileGalleryItem(e.file))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async {}
|
||||
|
||||
@override
|
||||
Future<void> manageAccess() async {}
|
||||
|
||||
List<Directory> _candidateDirs() {
|
||||
final home =
|
||||
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||
if (home == null || home.isEmpty) return const [];
|
||||
return [
|
||||
Directory('$home/Pictures'),
|
||||
Directory('$home/Изображения'),
|
||||
Directory('$home/Images'),
|
||||
];
|
||||
}
|
||||
|
||||
bool _isImage(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
if (dot < 0) return false;
|
||||
return _imageExtensions.contains(path.substring(dot).toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
class _FileGalleryItem implements GalleryItem {
|
||||
final File file;
|
||||
|
||||
_FileGalleryItem(this.file);
|
||||
|
||||
@override
|
||||
String get id => file.path;
|
||||
|
||||
@override
|
||||
bool get isVideo => false;
|
||||
|
||||
@override
|
||||
Duration? get duration => null;
|
||||
|
||||
@override
|
||||
File? get localFile => file;
|
||||
|
||||
@override
|
||||
Future<Uint8List?> thumbnail(int size) async => null;
|
||||
|
||||
@override
|
||||
Future<File?> originFile() async => file;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'create_group_flow.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../../widgets/sliding_pill_nav.dart';
|
||||
|
||||
import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
@@ -82,6 +83,17 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
int _currentNavIndex = 0;
|
||||
|
||||
static const List<PillNavItem> _chatsNavItems = [
|
||||
PillNavItem(icon: Symbols.chat_bubble, label: 'Чаты'),
|
||||
PillNavItem(icon: Symbols.call, label: 'Звонки'),
|
||||
PillNavItem(icon: Symbols.person_pin, label: 'Контакты'),
|
||||
PillNavItem(
|
||||
icon: Symbols.settings,
|
||||
label: 'Настройки',
|
||||
longPressable: true,
|
||||
),
|
||||
];
|
||||
|
||||
double _navPageAnimStart = 0;
|
||||
double _navPageAnimEnd = 0;
|
||||
final ValueNotifier<double> _navDragDx = ValueNotifier(0);
|
||||
@@ -255,14 +267,20 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final myId = _profile?.id;
|
||||
if (myId == null) return;
|
||||
|
||||
await ChatsModule.refreshChats(api, selectedBefore.map((c) => c.id).toList());
|
||||
await ChatsModule.refreshChats(
|
||||
api,
|
||||
selectedBefore.map((c) => c.id).toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final selectedAfter = _selectedChatObjects();
|
||||
if (selectedAfter.isEmpty) return;
|
||||
final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet();
|
||||
if (cats.contains(_DeleteKind.blocked) || cats.length > 1) {
|
||||
showCustomNotification(context, 'Статус чатов изменился, попробуйте ещё раз');
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Статус чатов изменился, попробуйте ещё раз',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final kind = cats.single;
|
||||
@@ -561,7 +579,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_profile = p;
|
||||
_chats = chats.where((c) => !CloudStorageModule.isCloudStorageGroup(c)).toList();
|
||||
_chats = chats
|
||||
.where((c) => !CloudStorageModule.isCloudStorageGroup(c))
|
||||
.toList();
|
||||
_folders = folders;
|
||||
_foldersListKnown = foldersKnown;
|
||||
if (_selectedFolderId != null &&
|
||||
@@ -674,8 +694,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final Map<int, List<CachedChat>> _pageChatsCache = {};
|
||||
|
||||
List<CachedChat> _chatsForPageIndex(int pageIndex) {
|
||||
final baseKey =
|
||||
Object.hash(identityHashCode(_chats), identityHashCode(_folders));
|
||||
final baseKey = Object.hash(
|
||||
identityHashCode(_chats),
|
||||
identityHashCode(_folders),
|
||||
);
|
||||
if (_pageChatsBaseKey != baseKey) {
|
||||
_pageChatsBaseKey = baseKey;
|
||||
_pageChatsCache.clear();
|
||||
@@ -692,7 +714,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final folder = _folders[pageIndex];
|
||||
base = FoldersModule.isAllChatsFolder(folder)
|
||||
? _chats
|
||||
: _chats.where((c) => FoldersModule.chatMatchesFolder(c, folder)).toList();
|
||||
: _chats
|
||||
.where((c) => FoldersModule.chatMatchesFolder(c, folder))
|
||||
.toList();
|
||||
}
|
||||
final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList()
|
||||
..sort((a, b) => a.favIndex!.compareTo(b.favIndex!));
|
||||
@@ -1087,163 +1111,167 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
child: _shouldCollapseSearch
|
||||
? const SizedBox(width: double.infinity, height: 52)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (AppStories.current.value &&
|
||||
_pullRatio < 0.8)
|
||||
Opacity(
|
||||
opacity: 1.0 - _pullRatio,
|
||||
child: Container(
|
||||
width: 50 * (1.0 - _pullRatio),
|
||||
height: 32,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildFoldedStory(
|
||||
cs,
|
||||
'https://i.pravatar.cc/150?u=dasha',
|
||||
0,
|
||||
),
|
||||
_buildFoldedStory(
|
||||
cs,
|
||||
'https://i.pravatar.cc/150?u=mastika',
|
||||
1,
|
||||
),
|
||||
_buildFoldedStory(
|
||||
cs,
|
||||
'https://i.pravatar.cc/150?u=stas',
|
||||
2,
|
||||
),
|
||||
],
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (AppStories.current.value &&
|
||||
_pullRatio < 0.8)
|
||||
Opacity(
|
||||
opacity: 1.0 - _pullRatio,
|
||||
child: Container(
|
||||
width: 50 * (1.0 - _pullRatio),
|
||||
height: 32,
|
||||
margin: const EdgeInsets.only(
|
||||
right: 8,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildFoldedStory(
|
||||
cs,
|
||||
'https://i.pravatar.cc/150?u=dasha',
|
||||
0,
|
||||
),
|
||||
_buildFoldedStory(
|
||||
cs,
|
||||
'https://i.pravatar.cc/150?u=mastika',
|
||||
1,
|
||||
),
|
||||
_buildFoldedStory(
|
||||
cs,
|
||||
'https://i.pravatar.cc/150?u=stas',
|
||||
2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_sessionState == SessionState.online
|
||||
? (_profile?.firstName ?? 'Чат')
|
||||
: 'Подключение...',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
PopupMenuButton<int>(
|
||||
icon: Icon(
|
||||
Symbols.more_vert,
|
||||
color: cs.outline,
|
||||
weight: 400,
|
||||
),
|
||||
offset: const Offset(0, 48),
|
||||
elevation: 4,
|
||||
color: cs.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
_buildPopupMenuItem(
|
||||
1,
|
||||
'Кнопка 1',
|
||||
Symbols.settings,
|
||||
),
|
||||
_buildPopupMenuItem(
|
||||
2,
|
||||
'Кнопка 2',
|
||||
Symbols.notifications,
|
||||
),
|
||||
_buildPopupMenuItem(
|
||||
3,
|
||||
'Кнопка 3',
|
||||
Symbols.shield,
|
||||
),
|
||||
_buildPopupMenuItem(
|
||||
4,
|
||||
'Кнопка 4',
|
||||
Symbols.info,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (AppStories.current.value)
|
||||
SizedBox(
|
||||
height: 96 * _pullRatio,
|
||||
child: Opacity(
|
||||
opacity: _pullRatio,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
children: [
|
||||
_buildStoryItem(
|
||||
'Даша',
|
||||
'https://i.pravatar.cc/150?u=dasha',
|
||||
true,
|
||||
),
|
||||
_buildStoryItem(
|
||||
'Мастика',
|
||||
'https://i.pravatar.cc/150?u=mastika',
|
||||
false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
|
||||
child: Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.search,
|
||||
color: cs.outline,
|
||||
size: 20,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Поиск',
|
||||
hintStyle: TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 15,
|
||||
Text(
|
||||
_sessionState == SessionState.online
|
||||
? (_profile?.firstName ?? 'Чат')
|
||||
: 'Подключение...',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
PopupMenuButton<int>(
|
||||
icon: Icon(
|
||||
Symbols.more_vert,
|
||||
color: cs.outline,
|
||||
weight: 400,
|
||||
),
|
||||
offset: const Offset(0, 48),
|
||||
elevation: 4,
|
||||
color: cs.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
_buildPopupMenuItem(
|
||||
1,
|
||||
'Кнопка 1',
|
||||
Symbols.settings,
|
||||
),
|
||||
_buildPopupMenuItem(
|
||||
2,
|
||||
'Кнопка 2',
|
||||
Symbols.notifications,
|
||||
),
|
||||
_buildPopupMenuItem(
|
||||
3,
|
||||
'Кнопка 3',
|
||||
Symbols.shield,
|
||||
),
|
||||
_buildPopupMenuItem(
|
||||
4,
|
||||
'Кнопка 4',
|
||||
Symbols.info,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (AppStories.current.value)
|
||||
SizedBox(
|
||||
height: 96 * _pullRatio,
|
||||
child: Opacity(
|
||||
opacity: _pullRatio,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
children: [
|
||||
_buildStoryItem(
|
||||
'Даша',
|
||||
'https://i.pravatar.cc/150?u=dasha',
|
||||
true,
|
||||
),
|
||||
_buildStoryItem(
|
||||
'Мастика',
|
||||
'https://i.pravatar.cc/150?u=mastika',
|
||||
false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
|
||||
child: Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.search,
|
||||
color: cs.outline,
|
||||
size: 20,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Поиск',
|
||||
hintStyle: TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 15,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1388,11 +1416,15 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
);
|
||||
}
|
||||
|
||||
final chatIndex = hasSeparator && index > pinnedCount ? index - 1 : index;
|
||||
final chatIndex = hasSeparator && index > pinnedCount
|
||||
? index - 1
|
||||
: index;
|
||||
final chat = chats[chatIndex];
|
||||
final isPinned = (chat.favIndex ?? 0) > 0;
|
||||
|
||||
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
|
||||
if (chat.type.isNotEmpty &&
|
||||
chat.type == "DIALOG" &&
|
||||
chat.id != 0) {
|
||||
int secondId = _profile?.id ?? 0;
|
||||
for (final entry in chat.participants.entries) {
|
||||
if (entry.key != _profile?.id) {
|
||||
@@ -1404,7 +1436,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final avatar = ContactCache.getAvatar(secondId);
|
||||
// ContactCache.isOfficial covers contacts loaded via opcode 32;
|
||||
// chat.isOfficial covers contacts from the login payload.
|
||||
final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial;
|
||||
final isVerified =
|
||||
ContactCache.isOfficial(secondId) || chat.isOfficial;
|
||||
|
||||
final isPlaceholder =
|
||||
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
||||
@@ -1531,34 +1564,15 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
double navInnerW,
|
||||
double bottomInset,
|
||||
) {
|
||||
final totalWeight = 5.2;
|
||||
final unitWidth = navInnerW / totalWeight;
|
||||
final activeWidth = unitWidth * 2.2;
|
||||
final inactiveWidth = unitWidth * 1.0;
|
||||
final geometry = PillNavGeometry.fromInnerWidth(navInnerW, 4);
|
||||
final inactiveWidth = geometry.inactiveWidth;
|
||||
final bubbleW = geometry.activeWidth - 8;
|
||||
|
||||
double bubbleLeftForIndex(int index) {
|
||||
double lo = 0;
|
||||
for (int i = 0; i < index; i++) {
|
||||
lo += inactiveWidth;
|
||||
}
|
||||
return lo + 4;
|
||||
}
|
||||
double bubbleLeftForIndex(int index) => index * inactiveWidth + 4;
|
||||
|
||||
final leftOffset = bubbleLeftForIndex(_currentNavIndex);
|
||||
final bubbleW = activeWidth - 8;
|
||||
final minBubbleLeft = bubbleLeftForIndex(0);
|
||||
final maxBubbleLeft = bubbleLeftForIndex(3);
|
||||
|
||||
double navInterpolatedWidth(int tabIndex, double rowT) {
|
||||
final rt = rowT.clamp(0.0, 3.0);
|
||||
final i0 = rt.floor().clamp(0, 3);
|
||||
final i1 = rt.ceil().clamp(0, 3);
|
||||
final frac = i0 == i1 ? 0.0 : (rt - i0);
|
||||
double at(int sel, int tab) =>
|
||||
(tab == sel) ? (activeWidth - 0.5) : (inactiveWidth - 0.5);
|
||||
return at(i0, tabIndex) + (at(i1, tabIndex) - at(i0, tabIndex)) * frac;
|
||||
}
|
||||
|
||||
int indexForBubbleLeft(double left) {
|
||||
final cx = left + bubbleW / 2;
|
||||
var best = 0;
|
||||
@@ -1581,140 +1595,68 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
right: 8,
|
||||
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
|
||||
child: RepaintBoundary(
|
||||
child: Container(
|
||||
height: 68,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(34),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) {
|
||||
if (_isSelectionMode) return;
|
||||
_navPageAnimController.stop();
|
||||
_navPageAnimController.value = 1.0;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = true;
|
||||
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
||||
});
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value += details.delta.dx;
|
||||
},
|
||||
onHorizontalDragEnd: (_) {
|
||||
if (!_navDragging) return;
|
||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) {
|
||||
if (_isSelectionMode) return;
|
||||
_navPageAnimController.stop();
|
||||
_navPageAnimController.value = 1.0;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = true;
|
||||
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
||||
});
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value += details.delta.dx;
|
||||
},
|
||||
onHorizontalDragEnd: (_) {
|
||||
if (!_navDragging) return;
|
||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
);
|
||||
final next = indexForBubbleLeft(left);
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_currentNavIndex = next;
|
||||
_navDragging = false;
|
||||
});
|
||||
},
|
||||
onHorizontalDragCancel: () {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = false;
|
||||
});
|
||||
},
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _navDragDx,
|
||||
builder: (context, navDragDx, _) {
|
||||
final position = _navDragging
|
||||
? ((_navDragBaseLeft + navDragDx).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
) -
|
||||
4) /
|
||||
inactiveWidth
|
||||
: _currentNavIndex.toDouble();
|
||||
return SlidingPillNav(
|
||||
items: _chatsNavItems,
|
||||
position: position,
|
||||
animationDuration: _navDragging
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350),
|
||||
geometry: geometry,
|
||||
iconSize: 20,
|
||||
labelGap: 4,
|
||||
onTap: _onNavTabSelected,
|
||||
onItemLongPress: (index, pos) {
|
||||
if (index == 3) _openAccountSwitcher(pos);
|
||||
},
|
||||
);
|
||||
final next = indexForBubbleLeft(left);
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_currentNavIndex = next;
|
||||
_navDragging = false;
|
||||
});
|
||||
},
|
||||
onHorizontalDragCancel: () {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = false;
|
||||
});
|
||||
},
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _navDragDx,
|
||||
builder: (context, navDragDx, _) {
|
||||
final bubbleLeft = _navDragging
|
||||
? (_navDragBaseLeft + navDragDx)
|
||||
.clamp(minBubbleLeft, maxBubbleLeft)
|
||||
: leftOffset;
|
||||
final navRowT =
|
||||
((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
|
||||
return Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: _navDragging
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: bubbleLeft,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: bubbleW,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: navInnerW,
|
||||
child: Row(
|
||||
children: List.generate(4, (index) {
|
||||
IconData icon;
|
||||
String label;
|
||||
switch (index) {
|
||||
case 0:
|
||||
icon = Symbols.chat_bubble;
|
||||
label = 'Чаты';
|
||||
break;
|
||||
case 1:
|
||||
icon = Symbols.call;
|
||||
label = 'Звонки';
|
||||
break;
|
||||
case 2:
|
||||
icon = Symbols.person_pin;
|
||||
label = 'Контакты';
|
||||
break;
|
||||
default:
|
||||
icon = Symbols.settings;
|
||||
label = 'Настройки';
|
||||
}
|
||||
|
||||
final isSelected = _currentNavIndex == index;
|
||||
final visualSel = navRowT.round().clamp(0, 3);
|
||||
return AnimatedContainer(
|
||||
duration: _navDragging
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _navDragging
|
||||
? navInterpolatedWidth(index, navRowT)
|
||||
: (isSelected
|
||||
? (activeWidth - 0.5)
|
||||
: (inactiveWidth - 0.5)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
child: _buildNavItem(
|
||||
index,
|
||||
icon,
|
||||
label,
|
||||
selectedOverride: _navDragging
|
||||
? (index == visualSel)
|
||||
: null,
|
||||
instant: _navDragging,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1759,8 +1701,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
width: pageW * 4,
|
||||
height: pageH,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge(
|
||||
[_navPageAnimController, _navDragDx]),
|
||||
animation: Listenable.merge([
|
||||
_navPageAnimController,
|
||||
_navDragDx,
|
||||
]),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -1902,53 +1846,64 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Builder(builder: (_) {
|
||||
final selected = _selectedChatObjects();
|
||||
final deleteCategory = _selectionDeleteCategoryFor(selected);
|
||||
final anyMuted = selected.any((c) => c.isMuted);
|
||||
final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
|
||||
onPressed: _clearSelection,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_selectedChats.length.toString(),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (deleteCategory != null)
|
||||
child: Builder(
|
||||
builder: (_) {
|
||||
final selected = _selectedChatObjects();
|
||||
final deleteCategory = _selectionDeleteCategoryFor(
|
||||
selected,
|
||||
);
|
||||
final anyMuted = selected.any((c) => c.isMuted);
|
||||
final anyPinned = selected.any(
|
||||
(c) => (c.favIndex ?? 0) > 0,
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||||
onPressed: _onDeleteTap,
|
||||
icon: Icon(
|
||||
Symbols.arrow_back,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
onPressed: _clearSelection,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.archive, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
anyPinned ? Symbols.keep_off : Symbols.keep,
|
||||
color: cs.onSurface,
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_selectedChats.length.toString(),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
onPressed: selected.isEmpty ? null : _onPinTap,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
anyMuted ? Symbols.volume_up : Symbols.volume_off,
|
||||
color: cs.onSurface,
|
||||
const Spacer(),
|
||||
if (deleteCategory != null)
|
||||
IconButton(
|
||||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||||
onPressed: _onDeleteTap,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.archive, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
onPressed: selected.isEmpty ? null : _onMuteTap,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
anyPinned ? Symbols.keep_off : Symbols.keep,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
onPressed: selected.isEmpty ? null : _onPinTap,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
anyMuted
|
||||
? Symbols.volume_up
|
||||
: Symbols.volume_off,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
onPressed: selected.isEmpty ? null : _onMuteTap,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1981,7 +1936,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -2114,18 +2073,26 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
return;
|
||||
}
|
||||
if (imageUrl.isNotEmpty) {
|
||||
unawaited(precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
|
||||
context,
|
||||
));
|
||||
unawaited(
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
context,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (widget.onChatSelected != null) {
|
||||
widget.onChatSelected!(DesktopChatSelection(
|
||||
chatId: int.parse(id),
|
||||
name: name,
|
||||
imageUrl: imageUrl,
|
||||
chatType: chatType,
|
||||
));
|
||||
widget.onChatSelected!(
|
||||
DesktopChatSelection(
|
||||
chatId: int.parse(id),
|
||||
name: name,
|
||||
imageUrl: imageUrl,
|
||||
chatType: chatType,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
pushSwipeable(
|
||||
context,
|
||||
@@ -2155,7 +2122,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
radius: 24,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
backgroundImage: imageUrl.isNotEmpty
|
||||
? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144)
|
||||
? CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
)
|
||||
: null,
|
||||
child: imageUrl.isEmpty
|
||||
? Text(
|
||||
@@ -2337,71 +2308,6 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem(
|
||||
int index,
|
||||
IconData icon,
|
||||
String label, {
|
||||
bool? selectedOverride,
|
||||
bool instant = false,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final bool isSelected = selectedOverride ?? (_currentNavIndex == index);
|
||||
final Duration animDur = instant
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350);
|
||||
final Duration opacityDur = instant
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 200);
|
||||
final bool isSettings = index == 3;
|
||||
return GestureDetector(
|
||||
onTap: () => _onNavTabSelected(index),
|
||||
onLongPressStart: isSettings
|
||||
? (details) => _openAccountSwitcher(details.globalPosition)
|
||||
: null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected ? cs.onPrimary : cs.onSurface,
|
||||
size: 20,
|
||||
fill: 1,
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: animDur,
|
||||
curve: Curves.easeOutCubic,
|
||||
width: isSelected ? null : 0,
|
||||
child: AnimatedOpacity(
|
||||
duration: opacityDur,
|
||||
opacity: isSelected ? 1.0 : 0.0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAccountSwitcher(Offset point) {
|
||||
Haptics.medium();
|
||||
final controller = AccountSwitcherController()..attach(point);
|
||||
@@ -2537,7 +2443,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import '../../widgets/message_bubble.dart';
|
||||
import '../../widgets/theme_reveal.dart';
|
||||
import '../../widgets/message_actions_overlay.dart';
|
||||
import '../../widgets/attachment_panel.dart';
|
||||
import '../../widgets/attachment/attachment_sheet.dart';
|
||||
import '../../widgets/swipe_to_pop.dart';
|
||||
|
||||
class _UploadStatus {
|
||||
@@ -1526,7 +1527,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
_AttachButton(
|
||||
hasText: _hasText,
|
||||
panelOpen: _showAttachmentPanel,
|
||||
onOpen: _openAttachmentSheet,
|
||||
uploadStatus: _uploadStatus,
|
||||
mutedIcon: mutedIcon,
|
||||
cs: cs,
|
||||
@@ -1712,6 +1713,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _openAttachmentSheet() {
|
||||
showAttachmentSheet(context);
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadFile() async {
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
@@ -1820,14 +1825,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
class _AttachButton extends StatelessWidget {
|
||||
final ValueNotifier<bool> hasText;
|
||||
final ValueNotifier<bool> panelOpen;
|
||||
final VoidCallback onOpen;
|
||||
final ValueNotifier<_UploadStatus> uploadStatus;
|
||||
final Color mutedIcon;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _AttachButton({
|
||||
required this.hasText,
|
||||
required this.panelOpen,
|
||||
required this.onOpen,
|
||||
required this.uploadStatus,
|
||||
required this.mutedIcon,
|
||||
required this.cs,
|
||||
@@ -1836,19 +1841,16 @@ class _AttachButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([hasText, panelOpen, uploadStatus]),
|
||||
listenable: Listenable.merge([hasText, uploadStatus]),
|
||||
builder: (context, _) {
|
||||
final isText = hasText.value;
|
||||
final open = panelOpen.value;
|
||||
final status = uploadStatus.value;
|
||||
final iconColor = status.awaitingResponse
|
||||
? cs.primary
|
||||
: (status.active || open
|
||||
: (status.active
|
||||
? cs.onSurfaceVariant.withValues(alpha: 0.5)
|
||||
: mutedIcon);
|
||||
final onTap = (isText || status.active || open)
|
||||
? null
|
||||
: () => panelOpen.value = true;
|
||||
final onTap = (isText || status.active) ? null : onOpen;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: isText ? 0 : 36,
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import 'package:komet/core/media/gallery_source.dart';
|
||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||
import 'package:komet/frontend/widgets/sliding_pill_nav.dart';
|
||||
|
||||
const List<PillNavItem> _navItems = [
|
||||
PillNavItem(icon: Symbols.image, label: 'Галерея'),
|
||||
PillNavItem(icon: Symbols.description, label: 'Файл'),
|
||||
PillNavItem(icon: Symbols.location_on, label: 'Геопозиция'),
|
||||
PillNavItem(icon: Symbols.person, label: 'Контакт'),
|
||||
];
|
||||
|
||||
Future<void> showAttachmentSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.45),
|
||||
builder: (_) => const AttachmentSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class AttachmentSheet extends StatefulWidget {
|
||||
const AttachmentSheet({super.key});
|
||||
|
||||
@override
|
||||
State<AttachmentSheet> createState() => _AttachmentSheetState();
|
||||
}
|
||||
|
||||
class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
final GallerySource _source = GallerySource.create();
|
||||
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
|
||||
final PageController _pageController = PageController();
|
||||
|
||||
bool _navDragging = false;
|
||||
double _navDragBasePageT = 0;
|
||||
double _navDragAccumDx = 0;
|
||||
|
||||
bool _loading = true;
|
||||
GalleryPermission _permission = GalleryPermission.granted;
|
||||
List<GalleryItem> _items = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadGallery();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_selected.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadGallery() async {
|
||||
setState(() => _loading = true);
|
||||
final permission = await _source.ensurePermission();
|
||||
if (!mounted) return;
|
||||
if (permission == GalleryPermission.denied) {
|
||||
setState(() {
|
||||
_permission = permission;
|
||||
_items = const [];
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final items = await _source.load(limit: 120);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_permission = permission;
|
||||
_items = items;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleSelection(GalleryItem item) {
|
||||
final next = Set<String>.from(_selected.value);
|
||||
if (!next.remove(item.id)) next.add(item.id);
|
||||
_selected.value = next;
|
||||
}
|
||||
|
||||
void _onSectionTap(int index) {
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: _navAnim,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void _onCameraTap() {
|
||||
showCustomNotification(context, 'Камера скоро появится');
|
||||
}
|
||||
|
||||
void _onSend() {
|
||||
final count = _selected.value.length;
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
Navigator.of(context).pop();
|
||||
showCustomNotificationOnOverlay(
|
||||
overlay,
|
||||
'Отправка $count выбранных скоро появится',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.62,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.94,
|
||||
expand: false,
|
||||
snap: true,
|
||||
snapSizes: const [0.62, 0.94],
|
||||
builder: (context, scrollController) {
|
||||
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
|
||||
final barReserve = _barHeight + bottomInset;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHandle(cs),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildPages(scrollController, cs, barReserve),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _buildBottomBar(),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: barReserve + 8,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
_selected,
|
||||
_pageController,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final count = _selected.value.length;
|
||||
final galleryT = (1 - _currentPageT()).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
if (count == 0 || galleryT == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Opacity(
|
||||
opacity: galleryT,
|
||||
child: IgnorePointer(
|
||||
ignoring: galleryT < 0.5,
|
||||
child: _buildSendButton(cs, count),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static const double _pillMargin = 10;
|
||||
static const double _barHeight = SlidingPillNav.height + _pillMargin;
|
||||
static const Duration _navAnim = Duration(milliseconds: 300);
|
||||
|
||||
Widget _buildHandle(ColorScheme cs) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 10),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPages(
|
||||
ScrollController scrollController,
|
||||
ColorScheme cs,
|
||||
double bottomReserve,
|
||||
) {
|
||||
return PageView(
|
||||
controller: _pageController,
|
||||
children: [
|
||||
_KeepAlivePage(
|
||||
child: _buildGalleryPage(scrollController, cs, bottomReserve),
|
||||
),
|
||||
_buildPlaceholderPage(cs, bottomReserve),
|
||||
_buildPlaceholderPage(cs, bottomReserve),
|
||||
_buildPlaceholderPage(cs, bottomReserve),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGalleryPage(
|
||||
ScrollController scrollController,
|
||||
ColorScheme cs,
|
||||
double bottomReserve,
|
||||
) {
|
||||
if (_loading) {
|
||||
return Center(child: CircularProgressIndicator(color: cs.primary));
|
||||
}
|
||||
if (_permission == GalleryPermission.denied) {
|
||||
return _buildDenied(scrollController, cs, bottomReserve);
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
return _buildMessage(
|
||||
scrollController,
|
||||
cs,
|
||||
'Изображений не найдено',
|
||||
bottomReserve,
|
||||
);
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
if (_permission == GalleryPermission.limited)
|
||||
SliverToBoxAdapter(child: _buildLimitedBanner(cs)),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(2, 2, 2, bottomReserve + 6),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index == 0) return _CameraTile(onTap: _onCameraTap, cs: cs);
|
||||
final item = _items[index - 1];
|
||||
return _GalleryTile(
|
||||
key: ValueKey(item.id),
|
||||
item: item,
|
||||
selectedIds: _selected,
|
||||
onTap: () => _toggleSelection(item),
|
||||
cs: cs,
|
||||
);
|
||||
}, childCount: _items.length + 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLimitedBanner(ColorScheme cs) {
|
||||
return InkWell(
|
||||
onTap: () => _source.manageAccess().then((_) => _loadGallery()),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: cs.surfaceContainerHighest,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.info, size: 18, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Доступны не все фото',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Изменить',
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderPage(ColorScheme cs, double bottomReserve) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomReserve),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.construction, size: 48, color: cs.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Раздел в разработке',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDenied(
|
||||
ScrollController scrollController,
|
||||
ColorScheme cs,
|
||||
double bottomReserve,
|
||||
) {
|
||||
return _scrollableCenter(
|
||||
scrollController,
|
||||
bottomReserve,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.no_photography, size: 48, color: cs.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Нет доступа к галерее',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Разрешите доступ к фото, чтобы выбрать их отсюда',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _loadGallery,
|
||||
child: const Text('Разрешить'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => _source.openSettings(),
|
||||
child: const Text('Настройки'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessage(
|
||||
ScrollController scrollController,
|
||||
ColorScheme cs,
|
||||
String text,
|
||||
double bottomReserve,
|
||||
) {
|
||||
return _scrollableCenter(
|
||||
scrollController,
|
||||
bottomReserve,
|
||||
Text(text, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scrollableCenter(
|
||||
ScrollController scrollController,
|
||||
double bottomReserve,
|
||||
Widget child,
|
||||
) {
|
||||
return CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomReserve),
|
||||
child: Center(child: child),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSendButton(ColorScheme cs, int count) {
|
||||
return Material(
|
||||
color: cs.primary,
|
||||
shape: const StadiumBorder(),
|
||||
elevation: 3,
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: _onSend,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.send, color: cs.onPrimary, size: 22, weight: 500),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _currentPageT() {
|
||||
if (!_pageController.hasClients) return 0;
|
||||
return _pageController.page ?? 0;
|
||||
}
|
||||
|
||||
void _onPillDragStart() {
|
||||
_navDragging = true;
|
||||
_navDragBasePageT = _currentPageT();
|
||||
_navDragAccumDx = 0;
|
||||
}
|
||||
|
||||
void _onPillDragUpdate(double dx, double inactiveWidth) {
|
||||
if (!_navDragging || !_pageController.hasClients) return;
|
||||
_navDragAccumDx += dx;
|
||||
final pageT = (_navDragBasePageT + _navDragAccumDx / inactiveWidth).clamp(
|
||||
0.0,
|
||||
3.0,
|
||||
);
|
||||
_pageController.jumpTo(pageT * _pageController.position.viewportDimension);
|
||||
}
|
||||
|
||||
void _onPillDragEnd() {
|
||||
if (!_navDragging) return;
|
||||
_navDragging = false;
|
||||
final target = _currentPageT().round().clamp(0, 3);
|
||||
_pageController.animateToPage(
|
||||
target,
|
||||
duration: _navAnim,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final geometry = PillNavGeometry.fromInnerWidth(
|
||||
constraints.maxWidth - 4,
|
||||
_navItems.length,
|
||||
);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) => _onPillDragStart(),
|
||||
onHorizontalDragUpdate: (d) =>
|
||||
_onPillDragUpdate(d.delta.dx, geometry.inactiveWidth),
|
||||
onHorizontalDragEnd: (_) => _onPillDragEnd(),
|
||||
onHorizontalDragCancel: _onPillDragEnd,
|
||||
child: AnimatedBuilder(
|
||||
animation: _pageController,
|
||||
builder: (context, _) {
|
||||
return SlidingPillNav(
|
||||
items: _navItems,
|
||||
position: _currentPageT(),
|
||||
geometry: geometry,
|
||||
onTap: _onSectionTap,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeepAlivePage extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const _KeepAlivePage({required this.child});
|
||||
|
||||
@override
|
||||
State<_KeepAlivePage> createState() => _KeepAlivePageState();
|
||||
}
|
||||
|
||||
class _KeepAlivePageState extends State<_KeepAlivePage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
class _CameraTile extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _CameraTile({required this.onTap, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
color: cs.surfaceContainerHighest,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Symbols.photo_camera,
|
||||
size: 34,
|
||||
color: cs.onSurface,
|
||||
weight: 400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryTile extends StatefulWidget {
|
||||
final GalleryItem item;
|
||||
final ValueListenable<Set<String>> selectedIds;
|
||||
final VoidCallback onTap;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _GalleryTile({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.selectedIds,
|
||||
required this.onTap,
|
||||
required this.cs,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_GalleryTile> createState() => _GalleryTileState();
|
||||
}
|
||||
|
||||
class _GalleryTileState extends State<_GalleryTile> {
|
||||
late bool _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = widget.selectedIds.value.contains(widget.item.id);
|
||||
widget.selectedIds.addListener(_onSelectionChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.selectedIds.removeListener(_onSelectionChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSelectionChanged() {
|
||||
final selected = widget.selectedIds.value.contains(widget.item.id);
|
||||
if (selected != _selected) setState(() => _selected = selected);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
AnimatedScale(
|
||||
scale: _selected ? 0.86 : 1.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
child: _Thumbnail(item: item, cs: widget.cs),
|
||||
),
|
||||
if (item.isVideo)
|
||||
Positioned(
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.play_arrow,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
fill: 1,
|
||||
),
|
||||
if (item.duration != null)
|
||||
Text(
|
||||
_formatDuration(item.duration!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [Shadow(blurRadius: 3, color: Colors.black54)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: _SelectionCheck(selected: _selected, cs: widget.cs),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final m = d.inMinutes;
|
||||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionCheck extends StatelessWidget {
|
||||
final bool selected;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _SelectionCheck({required this.selected, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? cs.primary : Colors.black.withValues(alpha: 0.25),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: selected
|
||||
? Icon(Symbols.check, size: 16, color: cs.onPrimary, weight: 700)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Thumbnail extends StatefulWidget {
|
||||
final GalleryItem item;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _Thumbnail({required this.item, required this.cs});
|
||||
|
||||
@override
|
||||
State<_Thumbnail> createState() => _ThumbnailState();
|
||||
}
|
||||
|
||||
class _ThumbnailState extends State<_Thumbnail> {
|
||||
static const int _pixelSize = 320;
|
||||
Future<Uint8List?>? _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.item.localFile == null) {
|
||||
_future = widget.item.thumbnail(_pixelSize);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final file = widget.item.localFile;
|
||||
if (file != null) {
|
||||
return Image.file(
|
||||
file,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: _pixelSize,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
}
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
final data = snapshot.data;
|
||||
if (data == null) return _placeholder();
|
||||
return Image.memory(
|
||||
data,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() => ColoredBox(color: widget.cs.surfaceContainerHighest);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PillNavItem {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool longPressable;
|
||||
|
||||
const PillNavItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.longPressable = false,
|
||||
});
|
||||
}
|
||||
|
||||
class PillNavGeometry {
|
||||
final double navInnerW;
|
||||
final double activeWidth;
|
||||
final double inactiveWidth;
|
||||
|
||||
const PillNavGeometry(this.navInnerW, this.activeWidth, this.inactiveWidth);
|
||||
|
||||
factory PillNavGeometry.fromInnerWidth(double navInnerW, int itemCount) {
|
||||
final totalWeight = (itemCount - 1) + _activeWeight;
|
||||
final unit = navInnerW / totalWeight;
|
||||
return PillNavGeometry(navInnerW, unit * _activeWeight, unit);
|
||||
}
|
||||
|
||||
static const double _activeWeight = 2.2;
|
||||
}
|
||||
|
||||
class SlidingPillNav extends StatelessWidget {
|
||||
final List<PillNavItem> items;
|
||||
final double position;
|
||||
final Duration animationDuration;
|
||||
final PillNavGeometry geometry;
|
||||
final ValueChanged<int> onTap;
|
||||
final void Function(int index, Offset globalPosition)? onItemLongPress;
|
||||
final double iconSize;
|
||||
final double labelGap;
|
||||
|
||||
const SlidingPillNav({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.position,
|
||||
required this.geometry,
|
||||
required this.onTap,
|
||||
this.animationDuration = Duration.zero,
|
||||
this.onItemLongPress,
|
||||
this.iconSize = 22,
|
||||
this.labelGap = 6,
|
||||
});
|
||||
|
||||
static const double height = 68;
|
||||
|
||||
double _interpWidth(int tab) {
|
||||
final maxIndex = items.length - 1;
|
||||
final rt = position.clamp(0.0, maxIndex.toDouble());
|
||||
final i0 = rt.floor();
|
||||
final i1 = rt.ceil();
|
||||
final frac = i0 == i1 ? 0.0 : rt - i0;
|
||||
double at(int sel) =>
|
||||
(tab == sel ? geometry.activeWidth : geometry.inactiveWidth) - 0.5;
|
||||
return at(i0) + (at(i1) - at(i0)) * frac;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final visualSel = position.round().clamp(0, items.length - 1);
|
||||
return Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(34),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
left: position * geometry.inactiveWidth + 4,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: geometry.activeWidth - 8,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: geometry.navInnerW,
|
||||
child: Row(
|
||||
children: List.generate(items.length, (i) {
|
||||
return AnimatedContainer(
|
||||
duration: animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _interpWidth(i),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
child: _PillNavCell(
|
||||
item: items[i],
|
||||
selected: i == visualSel,
|
||||
cs: cs,
|
||||
animationDuration: animationDuration,
|
||||
iconSize: iconSize,
|
||||
labelGap: labelGap,
|
||||
onTap: () => onTap(i),
|
||||
onLongPress:
|
||||
(onItemLongPress == null || !items[i].longPressable)
|
||||
? null
|
||||
: (pos) => onItemLongPress!(i, pos),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PillNavCell extends StatelessWidget {
|
||||
final PillNavItem item;
|
||||
final bool selected;
|
||||
final ColorScheme cs;
|
||||
final Duration animationDuration;
|
||||
final double iconSize;
|
||||
final double labelGap;
|
||||
final VoidCallback onTap;
|
||||
final void Function(Offset globalPosition)? onLongPress;
|
||||
|
||||
const _PillNavCell({
|
||||
required this.item,
|
||||
required this.selected,
|
||||
required this.cs,
|
||||
required this.animationDuration,
|
||||
required this.iconSize,
|
||||
required this.labelGap,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final opacityDuration = animationDuration == Duration.zero
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 200);
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPressStart: onLongPress == null
|
||||
? null
|
||||
: (d) => onLongPress!(d.globalPosition),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
item.icon,
|
||||
color: selected ? cs.onPrimary : cs.onSurface,
|
||||
size: iconSize,
|
||||
fill: 1,
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
width: selected ? null : 0,
|
||||
child: AnimatedOpacity(
|
||||
duration: opacityDuration,
|
||||
opacity: selected ? 1.0 : 0.0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: labelGap),
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -601,10 +601,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -749,6 +749,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
photo_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: photo_manager
|
||||
sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -982,10 +990,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -45,6 +45,7 @@ dependencies:
|
||||
flutter_timezone: ^5.0.1
|
||||
timezone: ^0.11.0
|
||||
file_picker: ^8.0.0
|
||||
photo_manager: ^3.0.0
|
||||
image: ^4.3.0
|
||||
sqflite: ^2.4.2
|
||||
sqflite_common_ffi: ^2.4.0+2
|
||||
|
||||
Reference in New Issue
Block a user