feat: redesign chats drawer to make it more minimal

This commit is contained in:
cogwheel0
2025-09-19 14:44:58 +05:30
parent 4e41a8fd4d
commit 6be8d0f5ab
4 changed files with 453 additions and 267 deletions

View File

@@ -190,12 +190,6 @@ final apiServiceProvider = Provider<ApiService?>((ref) {
); );
}; };
// Initialize with any existing token immediately
final token = ref.read(authTokenProvider3);
if (token != null && token.isNotEmpty) {
apiService.updateAuthToken(token);
}
return apiService; return apiService;
}, },
orElse: () => null, orElse: () => null,

View File

@@ -1010,7 +1010,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5, drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5,
drawerScrimColor: Colors.black.withValues(alpha: 0.32), drawerScrimColor: Colors.black.withValues(alpha: 0.32),
drawer: Drawer( drawer: Drawer(
width: (MediaQuery.of(context).size.width * 0.88).clamp( width: (MediaQuery.of(context).size.width * 0.80).clamp(
280.0, 280.0,
420.0, 420.0,
), ),

View File

@@ -331,9 +331,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final Brightness brightness = Theme.of(context).brightness; final Brightness brightness = Theme.of(context).brightness;
final bool isActive = _focusNode.hasFocus || _hasText; final bool isActive = _focusNode.hasFocus || _hasText;
final Color composerSurface = context.conduitTheme.inputBackground; final Color composerSurface = context.conduitTheme.inputBackground;
final Color shellBackground = brightness == Brightness.dark
? composerSurface.withValues(alpha: 0.78)
: composerSurface;
final Color placeholderBase = context.conduitTheme.inputPlaceholder; final Color placeholderBase = context.conduitTheme.inputPlaceholder;
final Color placeholderFocused = context.conduitTheme.inputText.withValues( final Color placeholderFocused = context.conduitTheme.inputText.withValues(
alpha: 0.64, alpha: 0.64,
@@ -429,7 +426,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
decoration: BoxDecoration( decoration: BoxDecoration(
color: shellBackground, color: brightness == Brightness.dark
? composerSurface.withValues(alpha: 0.78)
: composerSurface,
borderRadius: BorderRadius.circular(_composerRadius), borderRadius: BorderRadius.circular(_composerRadius),
border: Border.all(color: outlineColor, width: BorderWidth.thin), border: Border.all(color: outlineColor, width: BorderWidth.thin),
boxShadow: [ boxShadow: [
@@ -465,7 +464,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
child: Container( child: Container(
padding: const EdgeInsets.all(Spacing.sm), padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration( decoration: BoxDecoration(
color: shellBackground, color: Colors.transparent,
borderRadius: BorderRadius.circular(_composerRadius), borderRadius: BorderRadius.circular(_composerRadius),
), ),
child: Row( child: Row(
@@ -600,14 +599,17 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
? FontStyle.italic ? FontStyle.italic
: FontStyle.normal, : FontStyle.normal,
), ),
filled: true, filled: false,
fillColor: shellBackground,
border: InputBorder.none, border: InputBorder.none,
enabledBorder: InputBorder.none, enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none, focusedBorder: InputBorder.none,
errorBorder: InputBorder.none, errorBorder: InputBorder.none,
disabledBorder: InputBorder.none, disabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero, contentPadding:
const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: Spacing.sm,
),
isDense: true, isDense: true,
alignLabelWithHint: true, alignLabelWithHint: true,
), ),

View File

@@ -70,12 +70,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
Widget _buildRefreshableScrollable({required List<Widget> children}) { Widget _buildRefreshableScrollable({required List<Widget> children}) {
// Common padding used in both scrollable variants // Common padding used in both scrollable variants
const padding = EdgeInsets.fromLTRB( const padding = EdgeInsets.fromLTRB(0, Spacing.sm, 0, Spacing.md);
Spacing.md,
Spacing.sm,
Spacing.md,
Spacing.md,
);
if (Platform.isIOS) { if (Platform.isIOS) {
// Use Cupertino-style pull-to-refresh on iOS // Use Cupertino-style pull-to-refresh on iOS
@@ -187,21 +182,22 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode, focusNode: _searchFocusNode,
onChanged: (_) => _onSearchChanged(), onChanged: (_) => _onSearchChanged(),
style: TextStyle( style: AppTypography.standard.copyWith(color: theme.inputText),
color: theme.inputText,
fontSize: AppTypography.bodyMedium,
),
decoration: InputDecoration( decoration: InputDecoration(
isDense: true,
hintText: AppLocalizations.of(context)!.searchConversations, hintText: AppLocalizations.of(context)!.searchConversations,
hintStyle: TextStyle( hintStyle: AppTypography.standard.copyWith(
color: theme.inputPlaceholder, color: theme.inputPlaceholder,
fontSize: AppTypography.bodyMedium,
), ),
prefixIcon: Icon( prefixIcon: Icon(
Platform.isIOS ? CupertinoIcons.search : Icons.search, Platform.isIOS ? CupertinoIcons.search : Icons.search,
color: theme.iconSecondary, color: theme.iconSecondary,
size: IconSize.input, size: IconSize.input,
), ),
prefixIconConstraints: const BoxConstraints(
minWidth: TouchTarget.minimum,
minHeight: TouchTarget.minimum,
),
suffixIcon: _query.isNotEmpty suffixIcon: _query.isNotEmpty
? IconButton( ? IconButton(
onPressed: () { onPressed: () {
@@ -218,6 +214,10 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
), ),
) )
: null, : null,
suffixIconConstraints: const BoxConstraints(
minWidth: TouchTarget.minimum,
minHeight: TouchTarget.minimum,
),
filled: true, filled: true,
fillColor: theme.inputBackground, fillColor: theme.inputBackground,
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -234,7 +234,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
), ),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md, horizontal: Spacing.md,
vertical: Spacing.md, vertical: Spacing.xs,
), ),
), ),
); );
@@ -296,9 +296,15 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final children = <Widget>[ final children = <Widget>[
if (pinned.isNotEmpty) ...[ if (pinned.isNotEmpty) ...[
_buildSectionHeader( Padding(
AppLocalizations.of(context)!.pinned, padding: const EdgeInsets.only(
pinned.length, left: Spacing.md,
right: Spacing.md,
),
child: _buildSectionHeader(
AppLocalizations.of(context)!.pinned,
pinned.length,
),
), ),
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
...pinned.map((conv) => _buildTileFor(conv)), ...pinned.map((conv) => _buildTileFor(conv)),
@@ -306,7 +312,13 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
], ],
// Folders section (shown even if empty) // Folders section (shown even if empty)
_buildFoldersSectionHeader(), Padding(
padding: const EdgeInsets.only(
left: Spacing.md,
right: Spacing.md,
),
child: _buildFoldersSectionHeader(),
),
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
if (_isDragging && _draggingHasFolder) ...[ if (_isDragging && _draggingHasFolder) ...[
_buildUnfileDropTarget(), _buildUnfileDropTarget(),
@@ -356,9 +368,15 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.md),
if (regular.isNotEmpty) ...[ if (regular.isNotEmpty) ...[
_buildSectionHeader( Padding(
AppLocalizations.of(context)!.recent, padding: const EdgeInsets.only(
regular.length, left: Spacing.md,
right: Spacing.md,
),
child: _buildSectionHeader(
AppLocalizations.of(context)!.recent,
regular.length,
),
), ),
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
...regular.map(_buildTileFor), ...regular.map(_buildTileFor),
@@ -366,7 +384,10 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
if (archived.isNotEmpty) ...[ if (archived.isNotEmpty) ...[
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.md),
_buildArchivedSection(archived), Padding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
child: _buildArchivedSection(archived),
),
], ],
]; ];
return _buildRefreshableScrollable(children: children); return _buildRefreshableScrollable(children: children);
@@ -434,19 +455,28 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final archived = list.where((c) => c.archived == true).toList(); final archived = list.where((c) => c.archived == true).toList();
final children = <Widget>[ final children = <Widget>[
_buildSectionHeader('Results', list.length), Padding(
padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md),
child: _buildSectionHeader('Results', list.length),
),
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
if (pinned.isNotEmpty) ...[ if (pinned.isNotEmpty) ...[
_buildSectionHeader( Padding(
AppLocalizations.of(context)!.pinned, padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
pinned.length, child: _buildSectionHeader(
AppLocalizations.of(context)!.pinned,
pinned.length,
),
), ),
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
...pinned.map((conv) => _buildTileFor(conv)), ...pinned.map((conv) => _buildTileFor(conv)),
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.md),
], ],
// Folders section (shown even if empty) // Folders section (shown even if empty)
_buildFoldersSectionHeader(), Padding(
padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md),
child: _buildFoldersSectionHeader(),
),
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
if (_isDragging && _draggingHasFolder) ...[ if (_isDragging && _draggingHasFolder) ...[
_buildUnfileDropTarget(), _buildUnfileDropTarget(),
@@ -491,16 +521,22 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
), ),
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.md),
if (regular.isNotEmpty) ...[ if (regular.isNotEmpty) ...[
_buildSectionHeader( Padding(
AppLocalizations.of(context)!.recent, padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
regular.length, child: _buildSectionHeader(
AppLocalizations.of(context)!.recent,
regular.length,
),
), ),
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
...regular.map(_buildTileFor), ...regular.map(_buildTileFor),
], ],
if (archived.isNotEmpty) ...[ if (archived.isNotEmpty) ...[
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.md),
_buildArchivedSection(archived), Padding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
child: _buildArchivedSection(archived),
),
], ],
]; ];
return _buildRefreshableScrollable(children: children); return _buildRefreshableScrollable(children: children);
@@ -645,21 +681,31 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
} }
}, },
builder: (context, candidateData, rejectedData) { builder: (context, candidateData, rejectedData) {
final baseColor = theme.surfaceContainer;
final hoverColor = theme.buttonPrimary.withValues(alpha: 0.08);
final borderColor = isHover
? theme.buttonPrimary.withValues(alpha: 0.60)
: theme.surfaceContainerHighest.withValues(alpha: 0.40);
Color? overlayForStates(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed)) {
return theme.buttonPrimary.withValues(alpha: Alpha.buttonPressed);
}
if (states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return theme.buttonPrimary.withValues(alpha: Alpha.hover);
}
return Colors.transparent;
}
return Material( return Material(
color: isHover color: isHover ? hoverColor : baseColor,
? theme.buttonPrimary.withValues(alpha: 0.08)
: theme.surfaceContainer.withValues(alpha: 0.05),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.zero,
side: BorderSide( side: BorderSide(color: borderColor, width: BorderWidth.thin),
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.6)
: theme.dividerColor,
width: BorderWidth.regular,
),
), ),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.zero,
onTap: () { onTap: () {
final current = {...ref.read(_expandedFoldersProvider)}; final current = {...ref.read(_expandedFoldersProvider)};
current[folderId] = !isExpanded; current[folderId] = !isExpanded;
@@ -669,53 +715,75 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
_showFolderContextMenu(context, folderId, name); _showFolderContextMenu(context, folderId, name);
}, },
child: Padding( overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
padding: const EdgeInsets.symmetric( child: ConstrainedBox(
horizontal: Spacing.md, constraints: const BoxConstraints(
vertical: Spacing.sm, minHeight: TouchTarget.listItem,
), ),
child: Row( child: Padding(
children: [ padding: const EdgeInsets.symmetric(
Icon( horizontal: Spacing.md,
isExpanded vertical: Spacing.xs,
? (Platform.isIOS ),
? CupertinoIcons.folder_open child: LayoutBuilder(
: Icons.folder_open) builder: (context, constraints) {
: (Platform.isIOS final hasFiniteWidth = constraints.maxWidth.isFinite;
? CupertinoIcons.folder final textFit = hasFiniteWidth
: Icons.folder), ? FlexFit.tight
color: theme.iconPrimary, : FlexFit.loose;
size: IconSize.listItem,
), return Row(
const SizedBox(width: Spacing.sm), mainAxisSize: hasFiniteWidth
Expanded( ? MainAxisSize.max
child: Text( : MainAxisSize.min,
name, children: [
style: AppTypography.standard.copyWith( Icon(
color: theme.textPrimary, isExpanded
fontWeight: FontWeight.w600, ? (Platform.isIOS
), ? CupertinoIcons.folder_open
), : Icons.folder_open)
), : (Platform.isIOS
Text( ? CupertinoIcons.folder
'$count', : Icons.folder),
style: AppTypography.bodySmallStyle.copyWith( color: theme.iconPrimary,
color: theme.textSecondary, size: IconSize.listItem,
), ),
), const SizedBox(width: Spacing.sm),
const SizedBox(width: Spacing.xs), Flexible(
Icon( fit: textFit,
isExpanded child: Text(
? (Platform.isIOS name,
? CupertinoIcons.chevron_up maxLines: 1,
: Icons.expand_less) overflow: TextOverflow.ellipsis,
: (Platform.isIOS style: AppTypography.standard.copyWith(
? CupertinoIcons.chevron_down color: theme.textPrimary,
: Icons.expand_more), fontWeight: FontWeight.w400,
color: theme.iconSecondary, ),
size: IconSize.listItem, ),
), ),
], const SizedBox(width: Spacing.sm),
Text(
'$count',
style: AppTypography.standard.copyWith(
color: theme.textSecondary,
),
),
const SizedBox(width: Spacing.xs),
Icon(
isExpanded
? (Platform.isIOS
? CupertinoIcons.chevron_up
: Icons.expand_less)
: (Platform.isIOS
? CupertinoIcons.chevron_down
: Icons.expand_more),
color: theme.iconSecondary,
size: IconSize.listItem,
),
],
);
},
),
), ),
), ),
), ),
@@ -929,9 +997,11 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final bool isLoadingSelected = final bool isLoadingSelected =
(_pendingConversationId == conv.id) && (_pendingConversationId == conv.id) &&
(ref.watch(chat.isLoadingConversationProvider) == true); (ref.watch(chat.isLoadingConversationProvider) == true);
final bool isPinned = conv.pinned == true;
final tile = _ConversationTile( final tile = _ConversationTile(
title: title, title: title,
pinned: conv.pinned == true, pinned: isPinned,
selected: isActive, selected: isActive,
isLoading: isLoadingSelected, isLoading: isLoadingSelected,
onTap: _isLoadingConversation onTap: _isLoadingConversation
@@ -943,6 +1013,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
_showConversationContextMenu(context, conv); _showConversationContextMenu(context, conv);
}, },
); );
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: Spacing.xs, bottom: Spacing.xs,
@@ -951,41 +1022,10 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
child: LongPressDraggable<_DragConversationData>( child: LongPressDraggable<_DragConversationData>(
data: _DragConversationData(id: conv.id, title: title), data: _DragConversationData(id: conv.id, title: title),
dragAnchorStrategy: pointerDragAnchorStrategy, dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: Material( feedback: _ConversationDragFeedback(
color: Colors.transparent, title: title,
elevation: 6, pinned: isPinned,
borderRadius: BorderRadius.circular(AppBorderRadius.md), theme: theme,
child: Opacity(
opacity: 0.9,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
decoration: BoxDecoration(
color: theme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: theme.dividerColor,
width: BorderWidth.regular,
),
boxShadow: ConduitShadows.card,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Platform.isIOS
? CupertinoIcons.chat_bubble_2
: Icons.chat_bubble_outline,
size: IconSize.listItem,
),
const SizedBox(width: Spacing.xs),
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
),
),
), ),
childWhenDragging: Opacity( childWhenDragging: Opacity(
opacity: 0.5, opacity: 0.5,
@@ -1017,60 +1057,97 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Material( Material(
color: theme.surfaceContainer.withValues(alpha: 0.05), color: show
? theme.navigationSelectedBackground
: theme.surfaceContainer,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.zero,
side: BorderSide( side: BorderSide(
color: theme.dividerColor, color: show
width: BorderWidth.regular, ? theme.navigationSelected
: theme.surfaceContainerHighest.withValues(alpha: 0.40),
width: BorderWidth.thin,
), ),
), ),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.zero,
onTap: () => ref.read(_showArchivedProvider.notifier).state = !show, onTap: () => ref.read(_showArchivedProvider.notifier).state = !show,
child: Padding( overlayColor: WidgetStateProperty.resolveWith((states) {
padding: const EdgeInsets.symmetric( if (states.contains(WidgetState.pressed)) {
horizontal: Spacing.md, return theme.buttonPrimary.withValues(
vertical: Spacing.sm, alpha: Alpha.buttonPressed,
);
}
if (states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return theme.buttonPrimary.withValues(alpha: Alpha.hover);
}
return Colors.transparent;
}),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: TouchTarget.listItem,
), ),
child: Row( child: Padding(
children: [ padding: const EdgeInsets.symmetric(
Icon( horizontal: Spacing.md,
Platform.isIOS vertical: Spacing.xs,
? CupertinoIcons.archivebox ),
: Icons.archive_rounded, child: LayoutBuilder(
color: theme.iconPrimary, builder: (context, constraints) {
size: IconSize.listItem, final hasFiniteWidth = constraints.maxWidth.isFinite;
), final textFit = hasFiniteWidth
const SizedBox(width: Spacing.sm), ? FlexFit.tight
Expanded( : FlexFit.loose;
child: Text(
AppLocalizations.of(context)!.archived, return Row(
style: AppTypography.bodyLargeStyle.copyWith( mainAxisSize: hasFiniteWidth
color: theme.textPrimary, ? MainAxisSize.max
fontWeight: FontWeight.w600, : MainAxisSize.min,
), children: [
), Icon(
), Platform.isIOS
Text( ? CupertinoIcons.archivebox
'${archived.length}', : Icons.archive_rounded,
style: AppTypography.bodySmallStyle.copyWith( color: theme.iconPrimary,
color: theme.textSecondary, size: IconSize.listItem,
), ),
), const SizedBox(width: Spacing.sm),
const SizedBox(width: Spacing.xs), Flexible(
Icon( fit: textFit,
show child: Text(
? (Platform.isIOS AppLocalizations.of(context)!.archived,
? CupertinoIcons.chevron_up maxLines: 1,
: Icons.expand_less) overflow: TextOverflow.ellipsis,
: (Platform.isIOS style: AppTypography.standard.copyWith(
? CupertinoIcons.chevron_down color: theme.textPrimary,
: Icons.expand_more), fontWeight: FontWeight.w400,
color: theme.iconSecondary, ),
size: IconSize.listItem, ),
), ),
], const SizedBox(width: Spacing.sm),
Text(
'${archived.length}',
style: AppTypography.standard.copyWith(
color: theme.textSecondary,
),
),
const SizedBox(width: Spacing.xs),
Icon(
show
? (Platform.isIOS
? CupertinoIcons.chevron_up
: Icons.expand_less)
: (Platform.isIOS
? CupertinoIcons.chevron_down
: Icons.expand_more),
color: theme.iconSecondary,
size: IconSize.listItem,
),
],
);
},
),
), ),
), ),
), ),
@@ -1212,14 +1289,21 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
], ],
), ),
), ),
TextButton( IconButton(
tooltip: AppLocalizations.of(context)!.manage,
onPressed: () { onPressed: () {
Navigator.of(context).maybePop(); Navigator.of(context).maybePop();
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ProfilePage()), MaterialPageRoute(builder: (_) => const ProfilePage()),
); );
}, },
child: Text(AppLocalizations.of(context)!.manage), icon: Icon(
Platform.isIOS
? CupertinoIcons.settings
: Icons.settings_rounded,
color: theme.iconSecondary,
size: IconSize.listItem,
),
), ),
], ],
), ),
@@ -1436,6 +1520,148 @@ class _DragConversationData {
const _DragConversationData({required this.id, required this.title}); const _DragConversationData({required this.id, required this.title});
} }
class _ConversationDragFeedback extends StatelessWidget {
final String title;
final bool pinned;
final ConduitThemeExtension theme;
const _ConversationDragFeedback({
required this.title,
required this.pinned,
required this.theme,
});
@override
Widget build(BuildContext context) {
final borderColor = pinned
? theme.navigationSelected.withValues(alpha: 0.35)
: theme.surfaceContainerHighest.withValues(alpha: 0.45);
return Material(
color: Colors.transparent,
elevation: Elevation.low,
borderRadius: BorderRadius.zero,
child: Container(
constraints: const BoxConstraints(minHeight: TouchTarget.listItem),
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
),
decoration: BoxDecoration(
color: theme.surfaceContainer,
borderRadius: BorderRadius.zero,
border: Border.all(color: borderColor, width: BorderWidth.thin),
),
child: _ConversationTileContent(
title: title,
pinned: pinned,
selected: false,
isLoading: false,
onMorePressed: null,
),
),
);
}
}
class _ConversationTileContent extends StatelessWidget {
final String title;
final bool pinned;
final bool selected;
final bool isLoading;
final VoidCallback? onMorePressed;
const _ConversationTileContent({
required this.title,
required this.pinned,
required this.selected,
required this.isLoading,
this.onMorePressed,
});
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
final textStyle = AppTypography.standard.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w400,
height: 1.4,
);
return LayoutBuilder(
builder: (context, constraints) {
final hasFiniteWidth = constraints.maxWidth.isFinite;
final textFit = hasFiniteWidth ? FlexFit.tight : FlexFit.loose;
final trailing = <Widget>[];
if (pinned) {
trailing.addAll([
const SizedBox(width: Spacing.xs),
Icon(
Platform.isIOS ? CupertinoIcons.pin_fill : Icons.push_pin_rounded,
color: theme.iconSecondary,
size: IconSize.xs,
),
]);
}
if (isLoading) {
trailing.addAll([
const SizedBox(width: Spacing.sm),
SizedBox(
width: IconSize.sm,
height: IconSize.sm,
child: CircularProgressIndicator(
strokeWidth: BorderWidth.medium,
valueColor: AlwaysStoppedAnimation<Color>(
theme.loadingIndicator,
),
),
),
]);
} else if (onMorePressed != null) {
trailing.addAll([
const SizedBox(width: Spacing.sm),
IconButton(
iconSize: IconSize.sm,
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: TouchTarget.listItem,
minHeight: TouchTarget.listItem,
),
icon: Icon(
Platform.isIOS
? CupertinoIcons.ellipsis
: Icons.more_vert_rounded,
color: theme.iconSecondary,
),
onPressed: onMorePressed,
tooltip: AppLocalizations.of(context)!.more,
),
]);
}
return Row(
mainAxisSize: hasFiniteWidth ? MainAxisSize.max : MainAxisSize.min,
children: [
Flexible(
fit: textFit,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
),
),
...trailing,
],
);
},
);
}
}
class _ConversationTile extends StatelessWidget { class _ConversationTile extends StatelessWidget {
final String title; final String title;
final bool pinned; final bool pinned;
@@ -1458,102 +1684,66 @@ class _ConversationTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.conduitTheme; final theme = context.conduitTheme;
final Color selectedBackground = theme.buttonPrimary.withValues( final borderColor = selected
alpha: 0.10, ? theme.navigationSelected
); // subtle highlight : pinned
final Color selectedBorder = theme.buttonPrimary.withValues(alpha: 0.60); ? theme.navigationSelected.withValues(alpha: 0.30)
: theme.surfaceContainerHighest.withValues(alpha: 0.40);
final backgroundColor = theme.surfaceContainer;
final highlightColor = theme.navigationSelectedBackground.withValues(
alpha: 0.45,
);
Color? overlayForStates(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed)) {
return theme.buttonPrimary.withValues(alpha: Alpha.buttonPressed);
}
if (states.contains(WidgetState.focused) ||
states.contains(WidgetState.hovered)) {
return theme.buttonPrimary.withValues(alpha: Alpha.hover);
}
return Colors.transparent;
}
return Semantics( return Semantics(
selected: selected, selected: selected,
button: true, button: true,
child: Material( child: Material(
color: selected ? selectedBackground : Colors.transparent, color: backgroundColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.zero,
side: BorderSide( side: BorderSide(color: borderColor, width: BorderWidth.thin),
color: selected ? selectedBorder : theme.dividerColor,
width: BorderWidth.regular,
),
), ),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.zero,
onTap: isLoading ? null : onTap, onTap: isLoading ? null : onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
child: Stack( overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
children: [ child: AnimatedContainer(
// Left accent bar for active conversation duration: const Duration(milliseconds: 160),
AnimatedPositioned( curve: Curves.easeOut,
duration: const Duration(milliseconds: 160), decoration: BoxDecoration(
curve: Curves.easeOut, color: selected ? highlightColor : Colors.transparent,
left: 0, borderRadius: BorderRadius.zero,
top: 0, ),
bottom: 0, child: ConstrainedBox(
child: AnimatedContainer( constraints: const BoxConstraints(
duration: const Duration(milliseconds: 160), minHeight: TouchTarget.listItem,
width: selected ? 3.0 : 0.0,
decoration: BoxDecoration(
color: theme.buttonPrimary,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppBorderRadius.md),
bottomLeft: Radius.circular(AppBorderRadius.md),
),
),
),
), ),
Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: Spacing.md, horizontal: Spacing.md,
vertical: Spacing.sm, vertical: Spacing.xs,
), ),
child: Row( child: _ConversationTileContent(
children: [ title: title,
Expanded( pinned: pinned,
child: Text( selected: selected,
title, isLoading: isLoading,
maxLines: 1, onMorePressed: onMorePressed,
overflow: TextOverflow.ellipsis,
style: AppTypography.standard.copyWith(
color: theme.textPrimary,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
),
),
),
const SizedBox(width: Spacing.xs),
if (isLoading)
SizedBox(
width: IconSize.sm,
height: IconSize.sm,
child: CircularProgressIndicator(
strokeWidth: BorderWidth.medium,
valueColor: AlwaysStoppedAnimation<Color>(
theme.loadingIndicator,
),
),
)
else if (onMorePressed != null)
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: TouchTarget.listItem,
minHeight: TouchTarget.listItem,
),
icon: Icon(
Platform.isIOS
? CupertinoIcons.ellipsis
: Icons.more_vert_rounded,
color: theme.iconSecondary,
size: IconSize.listItem,
),
onPressed: onMorePressed,
tooltip: AppLocalizations.of(context)!.more,
),
],
), ),
), ),
], ),
), ),
), ),
), ),