diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2735665..4ab092f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + thumbnail(int size); + Future originFile(); +} + +abstract class GallerySource { + Future ensurePermission(); + Future> load({int limit}); + Future openSettings(); + Future manageAccess(); + + factory GallerySource.create() { + if (Platform.isAndroid || Platform.isIOS) { + return _PhotoManagerSource(); + } + return _DesktopGallerySource(); + } +} + +class _PhotoManagerSource implements GallerySource { + @override + Future ensurePermission() async { + final state = await PhotoManager.requestPermissionExtend(); + if (state.isAuth) return GalleryPermission.granted; + if (state.hasAccess) return GalleryPermission.limited; + return GalleryPermission.denied; + } + + @override + Future> 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 openSettings() => PhotoManager.openSetting(); + + @override + Future 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 thumbnail(int size) => + asset.thumbnailDataWithSize(ThumbnailSize.square(size)); + + @override + Future originFile() => asset.file; +} + +class _DesktopGallerySource implements GallerySource { + static const _imageExtensions = { + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.heif', + }; + + @override + Future ensurePermission() async => + GalleryPermission.granted; + + @override + Future> 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 openSettings() async {} + + @override + Future manageAccess() async {} + + List _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 thumbnail(int size) async => null; + + @override + Future originFile() async => file; +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index ddd75c1..659fb9a 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -11,6 +11,7 @@ import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/swipe_route.dart'; +import '../../widgets/sliding_pill_nav.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -82,6 +83,17 @@ class _ChatListScreenState extends State int _currentNavIndex = 0; + static const List _chatsNavItems = [ + PillNavItem(icon: Symbols.chat_bubble, label: 'Чаты'), + PillNavItem(icon: Symbols.call, label: 'Звонки'), + PillNavItem(icon: Symbols.person_pin, label: 'Контакты'), + PillNavItem( + icon: Symbols.settings, + label: 'Настройки', + longPressable: true, + ), + ]; + double _navPageAnimStart = 0; double _navPageAnimEnd = 0; final ValueNotifier _navDragDx = ValueNotifier(0); @@ -255,14 +267,20 @@ class _ChatListScreenState extends State final myId = _profile?.id; if (myId == null) return; - await ChatsModule.refreshChats(api, selectedBefore.map((c) => c.id).toList()); + await ChatsModule.refreshChats( + api, + selectedBefore.map((c) => c.id).toList(), + ); if (!mounted) return; final selectedAfter = _selectedChatObjects(); if (selectedAfter.isEmpty) return; final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet(); if (cats.contains(_DeleteKind.blocked) || cats.length > 1) { - showCustomNotification(context, 'Статус чатов изменился, попробуйте ещё раз'); + showCustomNotification( + context, + 'Статус чатов изменился, попробуйте ещё раз', + ); return; } final kind = cats.single; @@ -561,7 +579,9 @@ class _ChatListScreenState extends State if (mounted) { setState(() { _profile = p; - _chats = chats.where((c) => !CloudStorageModule.isCloudStorageGroup(c)).toList(); + _chats = chats + .where((c) => !CloudStorageModule.isCloudStorageGroup(c)) + .toList(); _folders = folders; _foldersListKnown = foldersKnown; if (_selectedFolderId != null && @@ -674,8 +694,10 @@ class _ChatListScreenState extends State final Map> _pageChatsCache = {}; List _chatsForPageIndex(int pageIndex) { - final baseKey = - Object.hash(identityHashCode(_chats), identityHashCode(_folders)); + final baseKey = Object.hash( + identityHashCode(_chats), + identityHashCode(_folders), + ); if (_pageChatsBaseKey != baseKey) { _pageChatsBaseKey = baseKey; _pageChatsCache.clear(); @@ -692,7 +714,9 @@ class _ChatListScreenState extends State final folder = _folders[pageIndex]; base = FoldersModule.isAllChatsFolder(folder) ? _chats - : _chats.where((c) => FoldersModule.chatMatchesFolder(c, folder)).toList(); + : _chats + .where((c) => FoldersModule.chatMatchesFolder(c, folder)) + .toList(); } final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() ..sort((a, b) => a.favIndex!.compareTo(b.favIndex!)); @@ -1087,163 +1111,167 @@ class _ChatListScreenState extends State child: _shouldCollapseSearch ? const SizedBox(width: double.infinity, height: 52) : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 6, 20, 3), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - if (AppStories.current.value && - _pullRatio < 0.8) - Opacity( - opacity: 1.0 - _pullRatio, - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only(right: 8), - child: Stack( - children: [ - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (AppStories.current.value && + _pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only( + right: 8, + ), + child: Stack( + children: [ + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), ), ), - ), - Text( - _sessionState == SessionState.online - ? (_profile?.firstName ?? 'Чат') - : 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], - ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Кнопка 1', - Symbols.settings, - ), - _buildPopupMenuItem( - 2, - 'Кнопка 2', - Symbols.notifications, - ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, - ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, - ), - ], - ), - ], - ), - ), - if (AppStories.current.value) - SizedBox( - height: 96 * _pullRatio, - child: Opacity( - opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, - ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, + Text( + _sessionState == SessionState.online + ? (_profile?.firstName ?? 'Чат') + : 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, ), + ], + ), + PopupMenuButton( + 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 ); } - final chatIndex = hasSeparator && index > pinnedCount ? index - 1 : index; + final chatIndex = hasSeparator && index > pinnedCount + ? index - 1 + : index; final chat = chats[chatIndex]; final isPinned = (chat.favIndex ?? 0) > 0; - if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { + if (chat.type.isNotEmpty && + chat.type == "DIALOG" && + chat.id != 0) { int secondId = _profile?.id ?? 0; for (final entry in chat.participants.entries) { if (entry.key != _profile?.id) { @@ -1404,7 +1436,8 @@ class _ChatListScreenState extends State final avatar = ContactCache.getAvatar(secondId); // ContactCache.isOfficial covers contacts loaded via opcode 32; // chat.isOfficial covers contacts from the login payload. - final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial; + final isVerified = + ContactCache.isOfficial(secondId) || chat.isOfficial; final isPlaceholder = chat.lastMsgText == ChatsModule.lastMsgPlaceholder; @@ -1531,34 +1564,15 @@ class _ChatListScreenState extends State double navInnerW, double bottomInset, ) { - final totalWeight = 5.2; - final unitWidth = navInnerW / totalWeight; - final activeWidth = unitWidth * 2.2; - final inactiveWidth = unitWidth * 1.0; + final geometry = PillNavGeometry.fromInnerWidth(navInnerW, 4); + final inactiveWidth = geometry.inactiveWidth; + final bubbleW = geometry.activeWidth - 8; - double bubbleLeftForIndex(int index) { - double lo = 0; - for (int i = 0; i < index; i++) { - lo += inactiveWidth; - } - return lo + 4; - } + double bubbleLeftForIndex(int index) => index * inactiveWidth + 4; - final leftOffset = bubbleLeftForIndex(_currentNavIndex); - final bubbleW = activeWidth - 8; final minBubbleLeft = bubbleLeftForIndex(0); final maxBubbleLeft = bubbleLeftForIndex(3); - double navInterpolatedWidth(int tabIndex, double rowT) { - final rt = rowT.clamp(0.0, 3.0); - final i0 = rt.floor().clamp(0, 3); - final i1 = rt.ceil().clamp(0, 3); - final frac = i0 == i1 ? 0.0 : (rt - i0); - double at(int sel, int tab) => - (tab == sel) ? (activeWidth - 0.5) : (inactiveWidth - 0.5); - return at(i0, tabIndex) + (at(i1, tabIndex) - at(i0, tabIndex)) * frac; - } - int indexForBubbleLeft(double left) { final cx = left + bubbleW / 2; var best = 0; @@ -1581,140 +1595,68 @@ class _ChatListScreenState extends State right: 8, bottom: _isSelectionMode ? -100 : bottomInset + 10.0, child: RepaintBoundary( - child: Container( - height: 68, - padding: const EdgeInsets.symmetric(horizontal: 2), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(34), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: (_) { - if (_isSelectionMode) return; - _navPageAnimController.stop(); - _navPageAnimController.value = 1.0; - _navDragDx.value = 0; - setState(() { - _navDragging = true; - _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); - }); - }, - onHorizontalDragUpdate: (details) { - if (!_navDragging) return; - _navDragDx.value += details.delta.dx; - }, - onHorizontalDragEnd: (_) { - if (!_navDragging) return; - final left = (_navDragBaseLeft + _navDragDx.value).clamp( - minBubbleLeft, - maxBubbleLeft, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) { + if (_isSelectionMode) return; + _navPageAnimController.stop(); + _navPageAnimController.value = 1.0; + _navDragDx.value = 0; + setState(() { + _navDragging = true; + _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); + }); + }, + onHorizontalDragUpdate: (details) { + if (!_navDragging) return; + _navDragDx.value += details.delta.dx; + }, + onHorizontalDragEnd: (_) { + if (!_navDragging) return; + final left = (_navDragBaseLeft + _navDragDx.value).clamp( + minBubbleLeft, + maxBubbleLeft, + ); + final next = indexForBubbleLeft(left); + _navDragDx.value = 0; + setState(() { + _currentNavIndex = next; + _navDragging = false; + }); + }, + onHorizontalDragCancel: () { + if (!_navDragging) return; + _navDragDx.value = 0; + setState(() { + _navDragging = false; + }); + }, + child: ValueListenableBuilder( + 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( - 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 width: pageW * 4, height: pageH, child: AnimatedBuilder( - animation: Listenable.merge( - [_navPageAnimController, _navDragDx]), + animation: Listenable.merge([ + _navPageAnimController, + _navDragDx, + ]), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1902,53 +1846,64 @@ class _ChatListScreenState extends State ), ], ), - child: Builder(builder: (_) { - final selected = _selectedChatObjects(); - final deleteCategory = _selectionDeleteCategoryFor(selected); - final anyMuted = selected.any((c) => c.isMuted); - final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0); - return Row( - children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: _clearSelection, - ), - const SizedBox(width: 8), - Text( - _selectedChats.length.toString(), - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - if (deleteCategory != null) + child: Builder( + builder: (_) { + final selected = _selectedChatObjects(); + final deleteCategory = _selectionDeleteCategoryFor( + selected, + ); + final anyMuted = selected.any((c) => c.isMuted); + final anyPinned = selected.any( + (c) => (c.favIndex ?? 0) > 0, + ); + return Row( + children: [ IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: _onDeleteTap, + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + ), + onPressed: _clearSelection, ), - IconButton( - icon: Icon(Symbols.archive, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon( - anyPinned ? Symbols.keep_off : Symbols.keep, - color: cs.onSurface, + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), ), - onPressed: selected.isEmpty ? null : _onPinTap, - ), - IconButton( - icon: Icon( - anyMuted ? Symbols.volume_up : Symbols.volume_off, - color: cs.onSurface, + const Spacer(), + if (deleteCategory != null) + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: _onDeleteTap, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, ), - onPressed: selected.isEmpty ? null : _onMuteTap, - ), - ], - ); - }), + IconButton( + icon: Icon( + anyPinned ? Symbols.keep_off : Symbols.keep, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onPinTap, + ), + IconButton( + icon: Icon( + anyMuted + ? Symbols.volume_up + : Symbols.volume_off, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onMuteTap, + ), + ], + ); + }, + ), ), ), ], @@ -1981,7 +1936,11 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 26, - backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), ), ), const SizedBox(height: 6), @@ -2114,18 +2073,26 @@ class _ChatListScreenState extends State return; } if (imageUrl.isNotEmpty) { - unawaited(precacheImage( - CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), - context, - )); + unawaited( + precacheImage( + CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + context, + ), + ); } if (widget.onChatSelected != null) { - widget.onChatSelected!(DesktopChatSelection( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - )); + widget.onChatSelected!( + DesktopChatSelection( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + ), + ); } else { pushSwipeable( context, @@ -2155,7 +2122,11 @@ class _ChatListScreenState extends State radius: 24, backgroundColor: cs.surfaceContainerHighest, backgroundImage: imageUrl.isNotEmpty - ? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144) + ? CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ) : null, child: imageUrl.isEmpty ? Text( @@ -2337,71 +2308,6 @@ class _ChatListScreenState extends State ); } - Widget _buildNavItem( - int index, - IconData icon, - String label, { - bool? selectedOverride, - bool instant = false, - }) { - final cs = Theme.of(context).colorScheme; - final bool isSelected = selectedOverride ?? (_currentNavIndex == index); - final Duration animDur = instant - ? Duration.zero - : const Duration(milliseconds: 350); - final Duration opacityDur = instant - ? Duration.zero - : const Duration(milliseconds: 200); - final bool isSettings = index == 3; - return GestureDetector( - onTap: () => _onNavTabSelected(index), - onLongPressStart: isSettings - ? (details) => _openAccountSwitcher(details.globalPosition) - : null, - behavior: HitTestBehavior.opaque, - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - color: isSelected ? cs.onPrimary : cs.onSurface, - size: 20, - fill: 1, - ), - AnimatedContainer( - duration: animDur, - curve: Curves.easeOutCubic, - width: isSelected ? null : 0, - child: AnimatedOpacity( - duration: opacityDur, - opacity: isSelected ? 1.0 : 0.0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - color: cs.onPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } - void _openAccountSwitcher(Offset point) { Haptics.medium(); final controller = AccountSwitcherController()..attach(point); @@ -2537,7 +2443,11 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 12, - backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), ), ), ); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 35981c2..c3fa1f5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -29,6 +29,7 @@ import '../../widgets/message_bubble.dart'; import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; +import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/swipe_to_pop.dart'; class _UploadStatus { @@ -1526,7 +1527,7 @@ class _ChatScreenState extends State ), _AttachButton( hasText: _hasText, - panelOpen: _showAttachmentPanel, + onOpen: _openAttachmentSheet, uploadStatus: _uploadStatus, mutedIcon: mutedIcon, cs: cs, @@ -1712,6 +1713,10 @@ class _ChatScreenState extends State } } + void _openAttachmentSheet() { + showAttachmentSheet(context); + } + Future _pickAndUploadFile() async { final result = await FilePicker.platform.pickFiles(); if (result == null || result.files.isEmpty) return; @@ -1820,14 +1825,14 @@ class _ChatScreenState extends State class _AttachButton extends StatelessWidget { final ValueNotifier hasText; - final ValueNotifier panelOpen; + final VoidCallback onOpen; final ValueNotifier<_UploadStatus> uploadStatus; final Color mutedIcon; final ColorScheme cs; const _AttachButton({ required this.hasText, - required this.panelOpen, + required this.onOpen, required this.uploadStatus, required this.mutedIcon, required this.cs, @@ -1836,19 +1841,16 @@ class _AttachButton extends StatelessWidget { @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: Listenable.merge([hasText, panelOpen, uploadStatus]), + listenable: Listenable.merge([hasText, uploadStatus]), builder: (context, _) { final isText = hasText.value; - final open = panelOpen.value; final status = uploadStatus.value; final iconColor = status.awaitingResponse ? cs.primary - : (status.active || open + : (status.active ? cs.onSurfaceVariant.withValues(alpha: 0.5) : mutedIcon); - final onTap = (isText || status.active || open) - ? null - : () => panelOpen.value = true; + final onTap = (isText || status.active) ? null : onOpen; return AnimatedContainer( duration: const Duration(milliseconds: 200), width: isText ? 0 : 36, diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart new file mode 100644 index 0000000..81d5bd6 --- /dev/null +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -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 _navItems = [ + PillNavItem(icon: Symbols.image, label: 'Галерея'), + PillNavItem(icon: Symbols.description, label: 'Файл'), + PillNavItem(icon: Symbols.location_on, label: 'Геопозиция'), + PillNavItem(icon: Symbols.person, label: 'Контакт'), +]; + +Future showAttachmentSheet(BuildContext context) { + return showModalBottomSheet( + 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 createState() => _AttachmentSheetState(); +} + +class _AttachmentSheetState extends State { + final GallerySource _source = GallerySource.create(); + final ValueNotifier> _selected = ValueNotifier({}); + final PageController _pageController = PageController(); + + bool _navDragging = false; + double _navDragBasePageT = 0; + double _navDragAccumDx = 0; + + bool _loading = true; + GalleryPermission _permission = GalleryPermission.granted; + List _items = const []; + + @override + void initState() { + super.initState(); + _loadGallery(); + } + + @override + void dispose() { + _pageController.dispose(); + _selected.dispose(); + super.dispose(); + } + + Future _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.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> 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? _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( + 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); +} diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart new file mode 100644 index 0000000..def6650 --- /dev/null +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -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 items; + final double position; + final Duration animationDuration; + final PillNavGeometry geometry; + final ValueChanged 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, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9d4e8db..9bf17e2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -601,10 +601,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -749,6 +749,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + photo_manager: + dependency: "direct main" + description: + name: photo_manager + sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2 + url: "https://pub.dev" + source: hosted + version: "3.9.0" platform: dependency: transitive description: @@ -982,10 +990,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ecc0898..3b6d708 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + photo_manager: ^3.0.0 image: ^4.3.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2