цэ фронт хуйнюшки которая где медиа отправлять

This commit is contained in:
Jganenok
2026-06-07 13:11:20 +07:00
parent 7964c61699
commit a80cba572f
8 changed files with 1445 additions and 448 deletions
+4
View File
@@ -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}"
+162
View File
@@ -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;
}
+345 -435
View 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,
),
), ),
), ),
); );
+11 -9
View File
@@ -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);
}
+209
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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