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