цэ фронт хуйнюшки которая где медиа отправлять
This commit is contained in:
@@ -5,6 +5,10 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
<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
|
<application
|
||||||
android:label="Komet"
|
android:label="Komet"
|
||||||
android:name="${applicationName}"
|
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/adaptive_shell.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/swipe_route.dart';
|
import '../../widgets/swipe_route.dart';
|
||||||
|
import '../../widgets/sliding_pill_nav.dart';
|
||||||
|
|
||||||
import '../calls/calls_tab.dart';
|
import '../calls/calls_tab.dart';
|
||||||
import '../contacts/contacts_tab.dart';
|
import '../contacts/contacts_tab.dart';
|
||||||
@@ -82,6 +83,17 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
|
|
||||||
int _currentNavIndex = 0;
|
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 _navPageAnimStart = 0;
|
||||||
double _navPageAnimEnd = 0;
|
double _navPageAnimEnd = 0;
|
||||||
final ValueNotifier<double> _navDragDx = ValueNotifier(0);
|
final ValueNotifier<double> _navDragDx = ValueNotifier(0);
|
||||||
@@ -255,14 +267,20 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
final myId = _profile?.id;
|
final myId = _profile?.id;
|
||||||
if (myId == null) return;
|
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;
|
if (!mounted) return;
|
||||||
|
|
||||||
final selectedAfter = _selectedChatObjects();
|
final selectedAfter = _selectedChatObjects();
|
||||||
if (selectedAfter.isEmpty) return;
|
if (selectedAfter.isEmpty) return;
|
||||||
final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet();
|
final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet();
|
||||||
if (cats.contains(_DeleteKind.blocked) || cats.length > 1) {
|
if (cats.contains(_DeleteKind.blocked) || cats.length > 1) {
|
||||||
showCustomNotification(context, 'Статус чатов изменился, попробуйте ещё раз');
|
showCustomNotification(
|
||||||
|
context,
|
||||||
|
'Статус чатов изменился, попробуйте ещё раз',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final kind = cats.single;
|
final kind = cats.single;
|
||||||
@@ -561,7 +579,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_profile = p;
|
_profile = p;
|
||||||
_chats = chats.where((c) => !CloudStorageModule.isCloudStorageGroup(c)).toList();
|
_chats = chats
|
||||||
|
.where((c) => !CloudStorageModule.isCloudStorageGroup(c))
|
||||||
|
.toList();
|
||||||
_folders = folders;
|
_folders = folders;
|
||||||
_foldersListKnown = foldersKnown;
|
_foldersListKnown = foldersKnown;
|
||||||
if (_selectedFolderId != null &&
|
if (_selectedFolderId != null &&
|
||||||
@@ -674,8 +694,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
final Map<int, List<CachedChat>> _pageChatsCache = {};
|
final Map<int, List<CachedChat>> _pageChatsCache = {};
|
||||||
|
|
||||||
List<CachedChat> _chatsForPageIndex(int pageIndex) {
|
List<CachedChat> _chatsForPageIndex(int pageIndex) {
|
||||||
final baseKey =
|
final baseKey = Object.hash(
|
||||||
Object.hash(identityHashCode(_chats), identityHashCode(_folders));
|
identityHashCode(_chats),
|
||||||
|
identityHashCode(_folders),
|
||||||
|
);
|
||||||
if (_pageChatsBaseKey != baseKey) {
|
if (_pageChatsBaseKey != baseKey) {
|
||||||
_pageChatsBaseKey = baseKey;
|
_pageChatsBaseKey = baseKey;
|
||||||
_pageChatsCache.clear();
|
_pageChatsCache.clear();
|
||||||
@@ -692,7 +714,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
final folder = _folders[pageIndex];
|
final folder = _folders[pageIndex];
|
||||||
base = FoldersModule.isAllChatsFolder(folder)
|
base = FoldersModule.isAllChatsFolder(folder)
|
||||||
? _chats
|
? _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()
|
final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList()
|
||||||
..sort((a, b) => a.favIndex!.compareTo(b.favIndex!));
|
..sort((a, b) => a.favIndex!.compareTo(b.favIndex!));
|
||||||
@@ -1087,163 +1111,167 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
child: _shouldCollapseSearch
|
child: _shouldCollapseSearch
|
||||||
? const SizedBox(width: double.infinity, height: 52)
|
? const SizedBox(width: double.infinity, height: 52)
|
||||||
: Column(
|
: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 3),
|
padding: const EdgeInsets.fromLTRB(20, 6, 20, 3),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (AppStories.current.value &&
|
if (AppStories.current.value &&
|
||||||
_pullRatio < 0.8)
|
_pullRatio < 0.8)
|
||||||
Opacity(
|
Opacity(
|
||||||
opacity: 1.0 - _pullRatio,
|
opacity: 1.0 - _pullRatio,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 50 * (1.0 - _pullRatio),
|
width: 50 * (1.0 - _pullRatio),
|
||||||
height: 32,
|
height: 32,
|
||||||
margin: const EdgeInsets.only(right: 8),
|
margin: const EdgeInsets.only(
|
||||||
child: Stack(
|
right: 8,
|
||||||
children: [
|
),
|
||||||
_buildFoldedStory(
|
child: Stack(
|
||||||
cs,
|
children: [
|
||||||
'https://i.pravatar.cc/150?u=dasha',
|
_buildFoldedStory(
|
||||||
0,
|
cs,
|
||||||
),
|
'https://i.pravatar.cc/150?u=dasha',
|
||||||
_buildFoldedStory(
|
0,
|
||||||
cs,
|
),
|
||||||
'https://i.pravatar.cc/150?u=mastika',
|
_buildFoldedStory(
|
||||||
1,
|
cs,
|
||||||
),
|
'https://i.pravatar.cc/150?u=mastika',
|
||||||
_buildFoldedStory(
|
1,
|
||||||
cs,
|
),
|
||||||
'https://i.pravatar.cc/150?u=stas',
|
_buildFoldedStory(
|
||||||
2,
|
cs,
|
||||||
),
|
'https://i.pravatar.cc/150?u=stas',
|
||||||
],
|
2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
Text(
|
_sessionState == SessionState.online
|
||||||
_sessionState == SessionState.online
|
? (_profile?.firstName ?? 'Чат')
|
||||||
? (_profile?.firstName ?? 'Чат')
|
: 'Подключение...',
|
||||||
: 'Подключение...',
|
style: TextStyle(
|
||||||
style: TextStyle(
|
color: cs.onSurface,
|
||||||
color: cs.onSurface,
|
fontSize: 20,
|
||||||
fontSize: 20,
|
fontWeight: FontWeight.w600,
|
||||||
fontWeight: FontWeight.w600,
|
fontFamily: 'Outfit',
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
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 chat = chats[chatIndex];
|
||||||
final isPinned = (chat.favIndex ?? 0) > 0;
|
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;
|
int secondId = _profile?.id ?? 0;
|
||||||
for (final entry in chat.participants.entries) {
|
for (final entry in chat.participants.entries) {
|
||||||
if (entry.key != _profile?.id) {
|
if (entry.key != _profile?.id) {
|
||||||
@@ -1404,7 +1436,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
final avatar = ContactCache.getAvatar(secondId);
|
final avatar = ContactCache.getAvatar(secondId);
|
||||||
// ContactCache.isOfficial covers contacts loaded via opcode 32;
|
// ContactCache.isOfficial covers contacts loaded via opcode 32;
|
||||||
// chat.isOfficial covers contacts from the login payload.
|
// chat.isOfficial covers contacts from the login payload.
|
||||||
final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial;
|
final isVerified =
|
||||||
|
ContactCache.isOfficial(secondId) || chat.isOfficial;
|
||||||
|
|
||||||
final isPlaceholder =
|
final isPlaceholder =
|
||||||
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
||||||
@@ -1531,34 +1564,15 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
double navInnerW,
|
double navInnerW,
|
||||||
double bottomInset,
|
double bottomInset,
|
||||||
) {
|
) {
|
||||||
final totalWeight = 5.2;
|
final geometry = PillNavGeometry.fromInnerWidth(navInnerW, 4);
|
||||||
final unitWidth = navInnerW / totalWeight;
|
final inactiveWidth = geometry.inactiveWidth;
|
||||||
final activeWidth = unitWidth * 2.2;
|
final bubbleW = geometry.activeWidth - 8;
|
||||||
final inactiveWidth = unitWidth * 1.0;
|
|
||||||
|
|
||||||
double bubbleLeftForIndex(int index) {
|
double bubbleLeftForIndex(int index) => index * inactiveWidth + 4;
|
||||||
double lo = 0;
|
|
||||||
for (int i = 0; i < index; i++) {
|
|
||||||
lo += inactiveWidth;
|
|
||||||
}
|
|
||||||
return lo + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
final leftOffset = bubbleLeftForIndex(_currentNavIndex);
|
|
||||||
final bubbleW = activeWidth - 8;
|
|
||||||
final minBubbleLeft = bubbleLeftForIndex(0);
|
final minBubbleLeft = bubbleLeftForIndex(0);
|
||||||
final maxBubbleLeft = bubbleLeftForIndex(3);
|
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) {
|
int indexForBubbleLeft(double left) {
|
||||||
final cx = left + bubbleW / 2;
|
final cx = left + bubbleW / 2;
|
||||||
var best = 0;
|
var best = 0;
|
||||||
@@ -1581,140 +1595,68 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
right: 8,
|
right: 8,
|
||||||
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
|
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: Container(
|
child: GestureDetector(
|
||||||
height: 68,
|
behavior: HitTestBehavior.opaque,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
onHorizontalDragStart: (_) {
|
||||||
decoration: BoxDecoration(
|
if (_isSelectionMode) return;
|
||||||
color: cs.surfaceContainerHigh,
|
_navPageAnimController.stop();
|
||||||
borderRadius: BorderRadius.circular(34),
|
_navPageAnimController.value = 1.0;
|
||||||
boxShadow: [
|
_navDragDx.value = 0;
|
||||||
BoxShadow(
|
setState(() {
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
_navDragging = true;
|
||||||
blurRadius: 20,
|
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
||||||
offset: const Offset(0, 10),
|
});
|
||||||
),
|
},
|
||||||
],
|
onHorizontalDragUpdate: (details) {
|
||||||
),
|
if (!_navDragging) return;
|
||||||
child: GestureDetector(
|
_navDragDx.value += details.delta.dx;
|
||||||
behavior: HitTestBehavior.opaque,
|
},
|
||||||
onHorizontalDragStart: (_) {
|
onHorizontalDragEnd: (_) {
|
||||||
if (_isSelectionMode) return;
|
if (!_navDragging) return;
|
||||||
_navPageAnimController.stop();
|
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||||
_navPageAnimController.value = 1.0;
|
minBubbleLeft,
|
||||||
_navDragDx.value = 0;
|
maxBubbleLeft,
|
||||||
setState(() {
|
);
|
||||||
_navDragging = true;
|
final next = indexForBubbleLeft(left);
|
||||||
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
_navDragDx.value = 0;
|
||||||
});
|
setState(() {
|
||||||
},
|
_currentNavIndex = next;
|
||||||
onHorizontalDragUpdate: (details) {
|
_navDragging = false;
|
||||||
if (!_navDragging) return;
|
});
|
||||||
_navDragDx.value += details.delta.dx;
|
},
|
||||||
},
|
onHorizontalDragCancel: () {
|
||||||
onHorizontalDragEnd: (_) {
|
if (!_navDragging) return;
|
||||||
if (!_navDragging) return;
|
_navDragDx.value = 0;
|
||||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
setState(() {
|
||||||
minBubbleLeft,
|
_navDragging = false;
|
||||||
maxBubbleLeft,
|
});
|
||||||
|
},
|
||||||
|
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,
|
width: pageW * 4,
|
||||||
height: pageH,
|
height: pageH,
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: Listenable.merge(
|
animation: Listenable.merge([
|
||||||
[_navPageAnimController, _navDragDx]),
|
_navPageAnimController,
|
||||||
|
_navDragDx,
|
||||||
|
]),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -1902,53 +1846,64 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Builder(builder: (_) {
|
child: Builder(
|
||||||
final selected = _selectedChatObjects();
|
builder: (_) {
|
||||||
final deleteCategory = _selectionDeleteCategoryFor(selected);
|
final selected = _selectedChatObjects();
|
||||||
final anyMuted = selected.any((c) => c.isMuted);
|
final deleteCategory = _selectionDeleteCategoryFor(
|
||||||
final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0);
|
selected,
|
||||||
return Row(
|
);
|
||||||
children: [
|
final anyMuted = selected.any((c) => c.isMuted);
|
||||||
IconButton(
|
final anyPinned = selected.any(
|
||||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
|
(c) => (c.favIndex ?? 0) > 0,
|
||||||
onPressed: _clearSelection,
|
);
|
||||||
),
|
return Row(
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
Text(
|
|
||||||
_selectedChats.length.toString(),
|
|
||||||
style: TextStyle(
|
|
||||||
color: cs.onSurface,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
if (deleteCategory != null)
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
icon: Icon(
|
||||||
onPressed: _onDeleteTap,
|
Symbols.arrow_back,
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
onPressed: _clearSelection,
|
||||||
),
|
),
|
||||||
IconButton(
|
const SizedBox(width: 8),
|
||||||
icon: Icon(Symbols.archive, color: cs.onSurface),
|
Text(
|
||||||
onPressed: () {},
|
_selectedChats.length.toString(),
|
||||||
),
|
style: TextStyle(
|
||||||
IconButton(
|
color: cs.onSurface,
|
||||||
icon: Icon(
|
fontSize: 18,
|
||||||
anyPinned ? Symbols.keep_off : Symbols.keep,
|
fontWeight: FontWeight.w600,
|
||||||
color: cs.onSurface,
|
),
|
||||||
),
|
),
|
||||||
onPressed: selected.isEmpty ? null : _onPinTap,
|
const Spacer(),
|
||||||
),
|
if (deleteCategory != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||||||
anyMuted ? Symbols.volume_up : Symbols.volume_off,
|
onPressed: _onDeleteTap,
|
||||||
color: cs.onSurface,
|
),
|
||||||
|
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(
|
child: CircleAvatar(
|
||||||
radius: 26,
|
radius: 26,
|
||||||
backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
|
backgroundImage: CachedNetworkImageProvider(
|
||||||
|
imageUrl,
|
||||||
|
maxWidth: 144,
|
||||||
|
maxHeight: 144,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -2114,18 +2073,26 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (imageUrl.isNotEmpty) {
|
if (imageUrl.isNotEmpty) {
|
||||||
unawaited(precacheImage(
|
unawaited(
|
||||||
CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
|
precacheImage(
|
||||||
context,
|
CachedNetworkImageProvider(
|
||||||
));
|
imageUrl,
|
||||||
|
maxWidth: 144,
|
||||||
|
maxHeight: 144,
|
||||||
|
),
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (widget.onChatSelected != null) {
|
if (widget.onChatSelected != null) {
|
||||||
widget.onChatSelected!(DesktopChatSelection(
|
widget.onChatSelected!(
|
||||||
chatId: int.parse(id),
|
DesktopChatSelection(
|
||||||
name: name,
|
chatId: int.parse(id),
|
||||||
imageUrl: imageUrl,
|
name: name,
|
||||||
chatType: chatType,
|
imageUrl: imageUrl,
|
||||||
));
|
chatType: chatType,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
pushSwipeable(
|
pushSwipeable(
|
||||||
context,
|
context,
|
||||||
@@ -2155,7 +2122,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: cs.surfaceContainerHighest,
|
backgroundColor: cs.surfaceContainerHighest,
|
||||||
backgroundImage: imageUrl.isNotEmpty
|
backgroundImage: imageUrl.isNotEmpty
|
||||||
? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144)
|
? CachedNetworkImageProvider(
|
||||||
|
imageUrl,
|
||||||
|
maxWidth: 144,
|
||||||
|
maxHeight: 144,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
child: imageUrl.isEmpty
|
child: imageUrl.isEmpty
|
||||||
? Text(
|
? 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) {
|
void _openAccountSwitcher(Offset point) {
|
||||||
Haptics.medium();
|
Haptics.medium();
|
||||||
final controller = AccountSwitcherController()..attach(point);
|
final controller = AccountSwitcherController()..attach(point);
|
||||||
@@ -2537,7 +2443,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
),
|
),
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 12,
|
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/theme_reveal.dart';
|
||||||
import '../../widgets/message_actions_overlay.dart';
|
import '../../widgets/message_actions_overlay.dart';
|
||||||
import '../../widgets/attachment_panel.dart';
|
import '../../widgets/attachment_panel.dart';
|
||||||
|
import '../../widgets/attachment/attachment_sheet.dart';
|
||||||
import '../../widgets/swipe_to_pop.dart';
|
import '../../widgets/swipe_to_pop.dart';
|
||||||
|
|
||||||
class _UploadStatus {
|
class _UploadStatus {
|
||||||
@@ -1526,7 +1527,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
),
|
),
|
||||||
_AttachButton(
|
_AttachButton(
|
||||||
hasText: _hasText,
|
hasText: _hasText,
|
||||||
panelOpen: _showAttachmentPanel,
|
onOpen: _openAttachmentSheet,
|
||||||
uploadStatus: _uploadStatus,
|
uploadStatus: _uploadStatus,
|
||||||
mutedIcon: mutedIcon,
|
mutedIcon: mutedIcon,
|
||||||
cs: cs,
|
cs: cs,
|
||||||
@@ -1712,6 +1713,10 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openAttachmentSheet() {
|
||||||
|
showAttachmentSheet(context);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickAndUploadFile() async {
|
Future<void> _pickAndUploadFile() async {
|
||||||
final result = await FilePicker.platform.pickFiles();
|
final result = await FilePicker.platform.pickFiles();
|
||||||
if (result == null || result.files.isEmpty) return;
|
if (result == null || result.files.isEmpty) return;
|
||||||
@@ -1820,14 +1825,14 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
|
|
||||||
class _AttachButton extends StatelessWidget {
|
class _AttachButton extends StatelessWidget {
|
||||||
final ValueNotifier<bool> hasText;
|
final ValueNotifier<bool> hasText;
|
||||||
final ValueNotifier<bool> panelOpen;
|
final VoidCallback onOpen;
|
||||||
final ValueNotifier<_UploadStatus> uploadStatus;
|
final ValueNotifier<_UploadStatus> uploadStatus;
|
||||||
final Color mutedIcon;
|
final Color mutedIcon;
|
||||||
final ColorScheme cs;
|
final ColorScheme cs;
|
||||||
|
|
||||||
const _AttachButton({
|
const _AttachButton({
|
||||||
required this.hasText,
|
required this.hasText,
|
||||||
required this.panelOpen,
|
required this.onOpen,
|
||||||
required this.uploadStatus,
|
required this.uploadStatus,
|
||||||
required this.mutedIcon,
|
required this.mutedIcon,
|
||||||
required this.cs,
|
required this.cs,
|
||||||
@@ -1836,19 +1841,16 @@ class _AttachButton extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: Listenable.merge([hasText, panelOpen, uploadStatus]),
|
listenable: Listenable.merge([hasText, uploadStatus]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final isText = hasText.value;
|
final isText = hasText.value;
|
||||||
final open = panelOpen.value;
|
|
||||||
final status = uploadStatus.value;
|
final status = uploadStatus.value;
|
||||||
final iconColor = status.awaitingResponse
|
final iconColor = status.awaitingResponse
|
||||||
? cs.primary
|
? cs.primary
|
||||||
: (status.active || open
|
: (status.active
|
||||||
? cs.onSurfaceVariant.withValues(alpha: 0.5)
|
? cs.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
: mutedIcon);
|
: mutedIcon);
|
||||||
final onTap = (isText || status.active || open)
|
final onTap = (isText || status.active) ? null : onOpen;
|
||||||
? null
|
|
||||||
: () => panelOpen.value = true;
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
width: isText ? 0 : 36,
|
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
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -749,6 +749,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.2"
|
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:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -982,10 +990,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ dependencies:
|
|||||||
flutter_timezone: ^5.0.1
|
flutter_timezone: ^5.0.1
|
||||||
timezone: ^0.11.0
|
timezone: ^0.11.0
|
||||||
file_picker: ^8.0.0
|
file_picker: ^8.0.0
|
||||||
|
photo_manager: ^3.0.0
|
||||||
image: ^4.3.0
|
image: ^4.3.0
|
||||||
sqflite: ^2.4.2
|
sqflite: ^2.4.2
|
||||||
sqflite_common_ffi: ^2.4.0+2
|
sqflite_common_ffi: ^2.4.0+2
|
||||||
|
|||||||
Reference in New Issue
Block a user