Merge pull request #195 from cogwheel0/improve-chat-drawer-ux

improve-chat-drawer-ux
This commit is contained in:
cogwheel
2025-11-28 15:00:13 +05:30
committed by GitHub
5 changed files with 558 additions and 364 deletions

View File

@@ -30,6 +30,11 @@ final class PreferenceKeys {
static const String ttsServerVoiceName = 'tts_server_voice_name'; static const String ttsServerVoiceName = 'tts_server_voice_name';
static const String voiceSilenceDuration = 'voice_silence_duration'; static const String voiceSilenceDuration = 'voice_silence_duration';
static const String androidAssistantTrigger = 'android_assistant_trigger'; static const String androidAssistantTrigger = 'android_assistant_trigger';
// Drawer section collapsed states
static const String drawerShowPinned = 'drawer_show_pinned';
static const String drawerShowFolders = 'drawer_show_folders';
static const String drawerShowRecent = 'drawer_show_recent';
} }
final class LegacyPreferenceKeys { final class LegacyPreferenceKeys {

View File

@@ -709,12 +709,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
?.cast<String, dynamic>(); ?.cast<String, dynamic>();
final name = final name =
meta?['name']?.toString() ?? parsed.host; meta?['name']?.toString() ?? parsed.host;
final collectionName = final collectionName = result?['collection_name']
result?['collection_name']?.toString(); ?.toString();
// Add as appropriate type // Add as appropriate type
final notifier = final notifier = ref.read(
ref.read(contextAttachmentsProvider.notifier); contextAttachmentsProvider.notifier,
);
if (isYoutube) { if (isYoutube) {
notifier.addYoutube( notifier.addYoutube(
displayName: name, displayName: name,
@@ -1680,28 +1681,29 @@ class _ChatPageState extends ConsumerState<ChatPage> {
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: constraints.maxWidth, maxWidth: constraints.maxWidth,
), ),
child: FittedBox( child: Column(
fit: BoxFit.scaleDown, mainAxisSize: MainAxisSize.min,
alignment: Alignment.center, crossAxisAlignment: CrossAxisAlignment.center,
child: Column( children: [
mainAxisSize: MainAxisSize.min, AnimatedSwitcher(
crossAxisAlignment: duration: const Duration(
CrossAxisAlignment.center, milliseconds: 250,
children: [ ),
AnimatedSwitcher( switchInCurve: Curves.easeOutCubic,
duration: const Duration( switchOutCurve: Curves.easeInCubic,
milliseconds: 250, child: displayConversationTitle != null
), ? Column(
switchInCurve: Curves.easeOutCubic, key: ValueKey<String>(
switchOutCurve: Curves.easeInCubic, displayConversationTitle,
child: displayConversationTitle != null ),
? Column( mainAxisSize: MainAxisSize.min,
key: ValueKey<String>( children: [
displayConversationTitle, ConstrainedBox(
), constraints: BoxConstraints(
mainAxisSize: MainAxisSize.min, maxWidth:
children: [ constraints.maxWidth,
StreamingTitleText( ),
child: StreamingTitleText(
title: title:
displayConversationTitle, displayConversationTitle,
style: AppTypography style: AppTypography
@@ -1720,96 +1722,45 @@ class _ChatPageState extends ConsumerState<ChatPage> {
.textPrimary .textPrimary
.withValues(alpha: 0.8), .withValues(alpha: 0.8),
), ),
const SizedBox(
height: Spacing.xs,
),
],
)
: const SizedBox.shrink(
key: ValueKey<String>(
'empty-title',
), ),
const SizedBox(
height: Spacing.xs,
),
],
)
: const SizedBox.shrink(
key: ValueKey<String>(
'empty-title',
), ),
), ),
Transform.translate( ),
offset: const Offset(0, 0), Transform.translate(
child: () { offset: const Offset(0, 0),
const double iconPaddingX = child: () {
Spacing.xs; const double iconPaddingX = Spacing.xs;
const double iconPaddingY = const double iconPaddingY = Spacing.xxs;
Spacing.xxs; const double iconWidth = IconSize.small;
const double iconWidth = const double iconBoxWidth =
IconSize.small; (iconPaddingX * 2) +
const double iconBoxWidth = (BorderWidth.thin * 2) +
(iconPaddingX * 2) + iconWidth;
(BorderWidth.thin * 2) + final double maxLabelWidth =
iconWidth; (constraints.maxWidth -
final double maxLabelWidth = (iconBoxWidth * 2) -
(constraints.maxWidth - (Spacing.xs * 2))
(iconBoxWidth * 2) - .clamp(
(Spacing.xs * 2)) 48.0,
.clamp( constraints.maxWidth,
48.0, );
constraints.maxWidth,
);
final row = Row( final row = Row(
mainAxisAlignment: mainAxisAlignment:
MainAxisAlignment.center, MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Opacity( Opacity(
opacity: 0.0, opacity: 0.0,
child: Container( child: Container(
padding:
const EdgeInsets.symmetric(
horizontal:
iconPaddingX,
vertical: iconPaddingY,
),
decoration: BoxDecoration(
color: context
.conduitTheme
.surfaceBackground
.withValues(alpha: 0.3),
borderRadius:
BorderRadius.circular(
AppBorderRadius.badge,
),
border: Border.all(
color: context
.conduitTheme
.dividerColor,
width: BorderWidth.thin,
),
),
child: Icon(
Platform.isIOS
? CupertinoIcons
.chevron_down
: Icons
.keyboard_arrow_down,
color: context
.conduitTheme
.iconSecondary,
size: iconWidth,
),
),
),
const SizedBox(width: Spacing.xs),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxLabelWidth,
),
child: MiddleEllipsisText(
modelLabel,
style: modelTextStyle,
textAlign: TextAlign.center,
semanticsLabel: modelLabel,
),
),
const SizedBox(width: Spacing.xs),
Container(
padding: padding:
const EdgeInsets.symmetric( const EdgeInsets.symmetric(
horizontal: iconPaddingX, horizontal: iconPaddingX,
@@ -1843,64 +1794,107 @@ class _ChatPageState extends ConsumerState<ChatPage> {
size: iconWidth, size: iconWidth,
), ),
), ),
],
);
final constrainedRow = ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth,
), ),
child: row, const SizedBox(width: Spacing.xs),
); ConstrainedBox(
return hasConversationTitle constraints: BoxConstraints(
? SizedBox( maxWidth: maxLabelWidth,
height: 24, ),
child: constrainedRow, child: MiddleEllipsisText(
) modelLabel,
: constrainedRow; style: modelTextStyle,
}(), textAlign: TextAlign.center,
), semanticsLabel: modelLabel,
if (isReviewerMode) ),
Padding( ),
padding: const EdgeInsets.only( const SizedBox(width: Spacing.xs),
top: 2.0, Container(
padding:
const EdgeInsets.symmetric(
horizontal: iconPaddingX,
vertical: iconPaddingY,
),
decoration: BoxDecoration(
color: context
.conduitTheme
.surfaceBackground
.withValues(alpha: 0.3),
borderRadius:
BorderRadius.circular(
AppBorderRadius.badge,
),
border: Border.all(
color: context
.conduitTheme
.dividerColor,
width: BorderWidth.thin,
),
),
child: Icon(
Platform.isIOS
? CupertinoIcons
.chevron_down
: Icons.keyboard_arrow_down,
color: context
.conduitTheme
.iconSecondary,
size: iconWidth,
),
),
],
);
final constrainedRow = ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth,
), ),
child: Container( child: row,
padding: const EdgeInsets.symmetric( );
horizontal: Spacing.sm, return hasConversationTitle
vertical: 1.0, ? SizedBox(
height: 24,
child: constrainedRow,
)
: constrainedRow;
}(),
),
if (isReviewerMode)
Padding(
padding: const EdgeInsets.only(
top: 2.0,
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: 1.0,
),
decoration: BoxDecoration(
color: context.conduitTheme.success
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(
AppBorderRadius.badge,
), ),
decoration: BoxDecoration( border: Border.all(
color: context color: context
.conduitTheme .conduitTheme
.success .success
.withValues(alpha: 0.1), .withValues(alpha: 0.3),
borderRadius: width: BorderWidth.thin,
BorderRadius.circular(
AppBorderRadius.badge,
),
border: Border.all(
color: context
.conduitTheme
.success
.withValues(alpha: 0.3),
width: BorderWidth.thin,
),
),
child: Text(
'REVIEWER MODE',
style: AppTypography.captionStyle
.copyWith(
color: context
.conduitTheme
.success,
fontWeight: FontWeight.w600,
fontSize: 9,
),
), ),
), ),
child: Text(
'REVIEWER MODE',
style: AppTypography.captionStyle
.copyWith(
color: context
.conduitTheme
.success,
fontWeight: FontWeight.w600,
fontSize: 9,
),
),
), ),
], ),
), ],
), ),
), ),
); );

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../shared/widgets/middle_ellipsis_text.dart';
/// Displays a chat title that reveals characters with a streaming animation /// Displays a chat title that reveals characters with a streaming animation
/// whenever the title changes. /// whenever the title changes.
class StreamingTitleText extends StatefulWidget { class StreamingTitleText extends StatefulWidget {
@@ -141,36 +143,53 @@ class _StreamingTitleTextState extends State<StreamingTitleText>
? widget.style.fontSize! * (widget.style.height ?? 1.1) ? widget.style.fontSize! * (widget.style.height ?? 1.1)
: 18.0); : 18.0);
return Row( // When animation is complete, use middle ellipsis for overflow.
mainAxisSize: MainAxisSize.min, // During animation, show partial text with standard Text widget.
mainAxisAlignment: MainAxisAlignment.center, final bool animationComplete = revealedGlyphs >= totalGlyphs;
crossAxisAlignment: CrossAxisAlignment.center,
children: [ // Use middle ellipsis when animation is complete
Flexible( if (animationComplete) {
child: Text( return MiddleEllipsisText(
// When the animation completes we fall back to the full string. _activeTitle,
revealedGlyphs >= totalGlyphs ? _activeTitle : visibleText, style: widget.style,
maxLines: 1, textAlign: TextAlign.center,
overflow: TextOverflow.fade, semanticsLabel: _activeTitle,
softWrap: false, );
textAlign: TextAlign.center, }
style: widget.style,
), // During animation, use IntrinsicWidth to size the row to the text,
), // then clip any overflow from the cursor
if (isAnimating) return ClipRect(
FadeTransition( child: Row(
opacity: _cursorOpacity, mainAxisSize: MainAxisSize.min,
child: Container( mainAxisAlignment: MainAxisAlignment.center,
width: widget.cursorWidth, crossAxisAlignment: CrossAxisAlignment.center,
height: cursorHeight, children: [
margin: const EdgeInsets.only(left: 2), Flexible(
decoration: BoxDecoration( child: Text(
color: cursorColor, visibleText,
borderRadius: BorderRadius.circular(widget.cursorWidth), maxLines: 1,
), overflow: TextOverflow.clip,
softWrap: false,
textAlign: TextAlign.center,
style: widget.style,
), ),
), ),
], if (isAnimating)
FadeTransition(
opacity: _cursorOpacity,
child: Container(
width: widget.cursorWidth,
height: cursorHeight,
margin: const EdgeInsets.only(left: 2),
decoration: BoxDecoration(
color: cursorColor,
borderRadius: BorderRadius.circular(widget.cursorWidth),
),
),
),
],
),
); );
} }

View File

@@ -26,6 +26,13 @@ import '../../../shared/widgets/responsive_drawer_layout.dart';
import '../../../core/models/model.dart'; import '../../../core/models/model.dart';
import '../../../core/models/conversation.dart'; import '../../../core/models/conversation.dart';
import '../../../core/models/folder.dart'; import '../../../core/models/folder.dart';
import '../../../core/persistence/persistence_keys.dart';
import '../../../core/persistence/hive_boxes.dart';
import 'package:hive_ce/hive.dart';
import '../../../shared/widgets/middle_ellipsis_text.dart';
/// Defines the section types that can be collapsed in the chats drawer
enum _SectionType { pinned, recent }
class ChatsDrawer extends ConsumerStatefulWidget { class ChatsDrawer extends ConsumerStatefulWidget {
const ChatsDrawer({super.key}); const ChatsDrawer({super.key});
@@ -49,6 +56,12 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
// UI state providers for sections // UI state providers for sections
static final _showArchivedProvider = static final _showArchivedProvider =
NotifierProvider<_ShowArchivedNotifier, bool>(_ShowArchivedNotifier.new); NotifierProvider<_ShowArchivedNotifier, bool>(_ShowArchivedNotifier.new);
static final _showPinnedProvider =
NotifierProvider<_ShowPinnedNotifier, bool>(_ShowPinnedNotifier.new);
static final _showFoldersProvider =
NotifierProvider<_ShowFoldersNotifier, bool>(_ShowFoldersNotifier.new);
static final _showRecentProvider =
NotifierProvider<_ShowRecentNotifier, bool>(_ShowRecentNotifier.new);
static final _expandedFoldersProvider = static final _expandedFoldersProvider =
NotifierProvider<_ExpandedFoldersNotifier, Map<String, bool>>( NotifierProvider<_ExpandedFoldersNotifier, Map<String, bool>>(
_ExpandedFoldersNotifier.new, _ExpandedFoldersNotifier.new,
@@ -318,6 +331,10 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final archived = list.where((c) => c.archived == true).toList(); final archived = list.where((c) => c.archived == true).toList();
final showPinned = ref.watch(_showPinnedProvider);
final showFolders = ref.watch(_showFoldersProvider);
final showRecent = ref.watch(_showRecentProvider);
final slivers = <Widget>[ final slivers = <Widget>[
if (pinned.isNotEmpty) ...[ if (pinned.isNotEmpty) ...[
SliverPadding( SliverPadding(
@@ -326,11 +343,14 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
child: _buildSectionHeader( child: _buildSectionHeader(
AppLocalizations.of(context)!.pinned, AppLocalizations.of(context)!.pinned,
pinned.length, pinned.length,
sectionType: _SectionType.pinned,
), ),
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), if (showPinned) ...[
_conversationsSliver(pinned, modelsById: modelsById), const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(pinned, modelsById: modelsById),
],
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
], ],
@@ -339,89 +359,96 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()), sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()),
), ),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), if (showFolders) ...[
if (_isDragging && _draggingHasFolder) ...[ const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
SliverPadding( if (_isDragging && _draggingHasFolder) ...[
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), SliverPadding(
sliver: SliverToBoxAdapter(child: _buildUnfileDropTarget()), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
), sliver: SliverToBoxAdapter(child: _buildUnfileDropTarget()),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.sm)), ),
], const SliverToBoxAdapter(child: SizedBox(height: Spacing.sm)),
...ref ],
.watch(foldersProvider) ...ref
.when( .watch(foldersProvider)
data: (folders) { .when(
final grouped = <String, List<dynamic>>{}; data: (folders) {
for (final c in foldered) { final grouped = <String, List<dynamic>>{};
final id = c.folderId!; for (final c in foldered) {
grouped.putIfAbsent(id, () => []).add(c); final id = c.folderId!;
} grouped.putIfAbsent(id, () => []).add(c);
}
final expandedMap = ref.watch(_expandedFoldersProvider); final expandedMap = ref.watch(_expandedFoldersProvider);
final out = <Widget>[]; final out = <Widget>[];
for (final folder in folders) { for (final folder in folders) {
final existing = grouped[folder.id] ?? const <dynamic>[]; final existing =
final convs = _resolveFolderConversations( grouped[folder.id] ?? const <dynamic>[];
folder, final convs = _resolveFolderConversations(
existing, folder,
); existing,
final isExpanded = );
expandedMap[folder.id] ?? folder.isExpanded; final isExpanded =
final hasItems = convs.isNotEmpty; expandedMap[folder.id] ?? folder.isExpanded;
out.add( final hasItems = convs.isNotEmpty;
SliverPadding( out.add(
padding: const EdgeInsets.symmetric( SliverPadding(
horizontal: Spacing.md, padding: const EdgeInsets.symmetric(
), horizontal: Spacing.md,
sliver: SliverToBoxAdapter( ),
child: _buildFolderHeader( sliver: SliverToBoxAdapter(
folder.id, child: _buildFolderHeader(
folder.name, folder.id,
convs.length, folder.name,
defaultExpanded: folder.isExpanded, convs.length,
defaultExpanded: folder.isExpanded,
),
), ),
), ),
),
);
if (isExpanded && hasItems) {
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
out.add(
_conversationsSliver(
convs,
inFolder: true,
modelsById: modelsById,
),
);
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
); );
if (isExpanded && hasItems) {
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
out.add(
_conversationsSliver(
convs,
inFolder: true,
modelsById: modelsById,
),
);
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.sm),
),
);
} else {
// Only add spacing after collapsed folders
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
}
} }
out.add( return out.isEmpty
const SliverToBoxAdapter( ? <Widget>[
child: SizedBox(height: Spacing.xs), const SliverToBoxAdapter(
), child: SizedBox.shrink(),
); ),
} ]
return out.isEmpty : out;
? <Widget>[ },
const SliverToBoxAdapter(child: SizedBox.shrink()), loading: () => [
] const SliverToBoxAdapter(child: SizedBox.shrink()),
: out; ],
}, error: (e, st) => [
loading: () => [ const SliverToBoxAdapter(child: SizedBox.shrink()),
const SliverToBoxAdapter(child: SizedBox.shrink()), ],
], ),
error: (e, st) => [ ],
const SliverToBoxAdapter(child: SizedBox.shrink()),
],
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
if (regular.isNotEmpty) ...[ if (regular.isNotEmpty) ...[
@@ -431,11 +458,14 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
child: _buildSectionHeader( child: _buildSectionHeader(
AppLocalizations.of(context)!.recent, AppLocalizations.of(context)!.recent,
regular.length, regular.length,
sectionType: _SectionType.recent,
), ),
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), if (showRecent) ...[
_conversationsSliver(regular, modelsById: modelsById), const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(regular, modelsById: modelsById),
],
], ],
if (archived.isNotEmpty) ...[ if (archived.isNotEmpty) ...[
@@ -525,6 +555,10 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final archived = list.where((c) => c.archived == true).toList(); final archived = list.where((c) => c.archived == true).toList();
final showPinned = ref.watch(_showPinnedProvider);
final showFolders = ref.watch(_showFoldersProvider);
final showRecent = ref.watch(_showRecentProvider);
final slivers = <Widget>[ final slivers = <Widget>[
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
@@ -543,118 +577,145 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
child: _buildSectionHeader( child: _buildSectionHeader(
AppLocalizations.of(context)!.pinned, AppLocalizations.of(context)!.pinned,
pinned.length, pinned.length,
sectionType: _SectionType.pinned,
), ),
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(pinned, modelsById: modelsById),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
]); ]);
if (showPinned) {
slivers.addAll([
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(pinned, modelsById: modelsById),
]);
}
slivers.add(
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
);
} }
slivers.addAll([ slivers.add(
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()), sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()),
), ),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), );
]);
if (_isDragging && _draggingHasFolder) { if (showFolders) {
slivers.add( slivers.add(
SliverPadding( const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildUnfileDropTarget()),
),
); );
slivers.add(
const SliverToBoxAdapter(child: SizedBox(height: Spacing.sm)),
);
}
final folderSlivers = ref if (_isDragging && _draggingHasFolder) {
.watch(foldersProvider) slivers.add(
.when( SliverPadding(
data: (folders) { padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
final grouped = <String, List<dynamic>>{}; sliver: SliverToBoxAdapter(child: _buildUnfileDropTarget()),
for (final c in foldered) { ),
final id = c.folderId!; );
grouped.putIfAbsent(id, () => []).add(c); slivers.add(
} const SliverToBoxAdapter(child: SizedBox(height: Spacing.sm)),
final expandedMap = ref.watch(_expandedFoldersProvider); );
final out = <Widget>[]; }
for (final folder in folders) {
final existing = grouped[folder.id] ?? const <dynamic>[];
final convs = _resolveFolderConversations(folder, existing);
final isExpanded =
expandedMap[folder.id] ?? folder.isExpanded;
final hasItems = convs.isNotEmpty;
out.add( final folderSlivers = ref
SliverPadding( .watch(foldersProvider)
padding: const EdgeInsets.symmetric( .when(
horizontal: Spacing.md, data: (folders) {
), final grouped = <String, List<dynamic>>{};
sliver: SliverToBoxAdapter( for (final c in foldered) {
child: _buildFolderHeader( final id = c.folderId!;
folder.id, grouped.putIfAbsent(id, () => []).add(c);
folder.name, }
convs.length, final expandedMap = ref.watch(_expandedFoldersProvider);
defaultExpanded: folder.isExpanded, final out = <Widget>[];
for (final folder in folders) {
final existing = grouped[folder.id] ?? const <dynamic>[];
final convs = _resolveFolderConversations(folder, existing);
final isExpanded =
expandedMap[folder.id] ?? folder.isExpanded;
final hasItems = convs.isNotEmpty;
out.add(
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
),
sliver: SliverToBoxAdapter(
child: _buildFolderHeader(
folder.id,
folder.name,
convs.length,
defaultExpanded: folder.isExpanded,
),
), ),
), ),
),
);
if (isExpanded && hasItems) {
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
out.add(
_conversationsSliver(
convs,
inFolder: true,
modelsById: modelsById,
),
);
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.sm),
),
); );
if (isExpanded && hasItems) {
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
out.add(
_conversationsSliver(
convs,
inFolder: true,
modelsById: modelsById,
),
);
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.sm),
),
);
} else {
// Only add spacing after collapsed folders
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
}
} }
} return out.isEmpty
return out.isEmpty ? <Widget>[
? <Widget>[ const SliverToBoxAdapter(child: SizedBox.shrink()),
const SliverToBoxAdapter(child: SizedBox.shrink()), ]
] : out;
: out; },
}, loading: () => <Widget>[
loading: () => <Widget>[ const SliverToBoxAdapter(child: SizedBox.shrink()),
const SliverToBoxAdapter(child: SizedBox.shrink()), ],
], error: (e, st) => <Widget>[
error: (e, st) => <Widget>[ const SliverToBoxAdapter(child: SizedBox.shrink()),
const SliverToBoxAdapter(child: SizedBox.shrink()), ],
], );
); slivers.addAll(folderSlivers);
slivers.addAll(folderSlivers); }
slivers.add(
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
);
if (regular.isNotEmpty) { if (regular.isNotEmpty) {
slivers.addAll([ slivers.add(
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: _buildSectionHeader( child: _buildSectionHeader(
AppLocalizations.of(context)!.recent, AppLocalizations.of(context)!.recent,
regular.length, regular.length,
sectionType: _SectionType.recent,
), ),
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), );
_conversationsSliver(regular, modelsById: modelsById), if (showRecent) {
]); slivers.addAll([
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(regular, modelsById: modelsById),
]);
}
} }
if (archived.isNotEmpty) { if (archived.isNotEmpty) {
@@ -693,10 +754,41 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
); );
} }
Widget _buildSectionHeader(String title, int count) { Widget _buildSectionHeader(
String title,
int count, {
_SectionType? sectionType,
}) {
final sidebarTheme = context.sidebarTheme; final sidebarTheme = context.sidebarTheme;
return Row(
// Get the collapsed state for the section type
bool isExpanded = true;
VoidCallback? onToggle;
if (sectionType == _SectionType.pinned) {
isExpanded = ref.watch(_showPinnedProvider);
onToggle = () => ref.read(_showPinnedProvider.notifier).toggle();
} else if (sectionType == _SectionType.recent) {
isExpanded = ref.watch(_showRecentProvider);
onToggle = () => ref.read(_showRecentProvider.notifier).toggle();
}
final headerContent = Row(
children: [ children: [
if (onToggle != null) ...[
Icon(
isExpanded
? (Platform.isIOS
? CupertinoIcons.chevron_down
: Icons.expand_more)
: (Platform.isIOS
? CupertinoIcons.chevron_right
: Icons.chevron_right),
color: sidebarTheme.foreground.withValues(alpha: 0.6),
size: IconSize.sm,
),
const SizedBox(width: Spacing.xxs),
],
Text( Text(
title, title,
style: AppTypography.labelStyle.copyWith( style: AppTypography.labelStyle.copyWith(
@@ -726,18 +818,58 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
), ),
], ],
); );
if (onToggle == null) {
return headerContent;
}
return InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Spacing.xxs),
child: headerContent,
),
);
} }
/// Header for the Folders section with a create button on the right /// Header for the Folders section with a create button on the right
Widget _buildFoldersSectionHeader() { Widget _buildFoldersSectionHeader() {
final theme = context.conduitTheme; final theme = context.conduitTheme;
final sidebarTheme = context.sidebarTheme;
final isExpanded = ref.watch(_showFoldersProvider);
return Row( return Row(
children: [ children: [
Text( InkWell(
AppLocalizations.of(context)!.folders, onTap: () => ref.read(_showFoldersProvider.notifier).toggle(),
style: AppTypography.labelStyle.copyWith( borderRadius: BorderRadius.circular(AppBorderRadius.xs),
color: theme.textSecondary, child: Padding(
decoration: TextDecoration.none, padding: const EdgeInsets.symmetric(vertical: Spacing.xxs),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isExpanded
? (Platform.isIOS
? CupertinoIcons.chevron_down
: Icons.expand_more)
: (Platform.isIOS
? CupertinoIcons.chevron_right
: Icons.chevron_right),
color: sidebarTheme.foreground.withValues(alpha: 0.6),
size: IconSize.sm,
),
const SizedBox(width: Spacing.xxs),
Text(
AppLocalizations.of(context)!.folders,
style: AppTypography.labelStyle.copyWith(
color: theme.textSecondary,
decoration: TextDecoration.none,
),
),
],
),
), ),
), ),
const Spacer(), const Spacer(),
@@ -1580,6 +1712,51 @@ class _ShowArchivedNotifier extends Notifier<bool> {
void set(bool value) => state = value; void set(bool value) => state = value;
} }
class _ShowPinnedNotifier extends Notifier<bool> {
Box<dynamic> get _box => Hive.box<dynamic>(HiveBoxNames.preferences);
@override
bool build() {
return _box.get(PreferenceKeys.drawerShowPinned, defaultValue: true)
as bool;
}
void toggle() {
state = !state;
_box.put(PreferenceKeys.drawerShowPinned, state);
}
}
class _ShowFoldersNotifier extends Notifier<bool> {
Box<dynamic> get _box => Hive.box<dynamic>(HiveBoxNames.preferences);
@override
bool build() {
return _box.get(PreferenceKeys.drawerShowFolders, defaultValue: true)
as bool;
}
void toggle() {
state = !state;
_box.put(PreferenceKeys.drawerShowFolders, state);
}
}
class _ShowRecentNotifier extends Notifier<bool> {
Box<dynamic> get _box => Hive.box<dynamic>(HiveBoxNames.preferences);
@override
bool build() {
return _box.get(PreferenceKeys.drawerShowRecent, defaultValue: true)
as bool;
}
void toggle() {
state = !state;
_box.put(PreferenceKeys.drawerShowRecent, state);
}
}
class _ExpandedFoldersNotifier extends Notifier<Map<String, bool>> { class _ExpandedFoldersNotifier extends Notifier<Map<String, bool>> {
@override @override
Map<String, bool> build() => {}; Map<String, bool> build() => {};
@@ -1736,11 +1913,10 @@ class _ConversationTileContent extends StatelessWidget {
], ],
Flexible( Flexible(
fit: textFit, fit: textFit,
child: Text( child: MiddleEllipsisText(
title, title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle, style: textStyle,
semanticsLabel: title,
), ),
), ),
...trailing, ...trailing,

View File

@@ -210,7 +210,7 @@ class TweakcnThemes {
border: const Color(0xFF282828), border: const Color(0xFF282828),
input: const Color(0xFF343434), input: const Color(0xFF343434),
ring: const Color(0xFF737373), ring: const Color(0xFF737373),
sidebarBackground: const Color(0xFF171717), sidebarBackground: const Color(0xFF0A0A0A),
sidebarForeground: const Color(0xFFFAFAFA), sidebarForeground: const Color(0xFFFAFAFA),
sidebarPrimary: const Color(0xFF1447E6), sidebarPrimary: const Color(0xFF1447E6),
sidebarPrimaryForeground: const Color(0xFFFAFAFA), sidebarPrimaryForeground: const Color(0xFFFAFAFA),