Files
iiEsaywebUIapp/lib/features/navigation/widgets/chats_drawer.dart

1806 lines
61 KiB
Dart
Raw Normal View History

2025-08-21 23:56:47 +05:30
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
2025-08-21 23:56:47 +05:30
import '../../../core/providers/app_providers.dart';
import '../../../shared/theme/theme_extensions.dart';
import '../../chat/providers/chat_providers.dart' as chat;
2025-08-22 01:24:04 +05:30
// import '../../files/views/files_page.dart';
import '../../../core/utils/debug_logger.dart';
import '../../../core/services/navigation_service.dart';
2025-09-07 23:48:47 +05:30
import '../../../shared/widgets/themed_dialogs.dart';
2025-08-23 23:56:53 +05:30
import 'package:conduit/l10n/app_localizations.dart';
2025-09-16 16:24:45 +05:30
import '../../../core/utils/user_display_name.dart';
2025-09-20 22:03:55 +05:30
import '../../../core/utils/model_icon_utils.dart';
2025-09-28 23:18:24 +05:30
import '../../auth/providers/unified_auth_providers.dart';
2025-09-20 22:03:55 +05:30
import '../../../core/utils/user_avatar_utils.dart';
2025-09-19 23:35:46 +05:30
import '../../../shared/utils/conversation_context_menu.dart';
2025-09-20 22:03:55 +05:30
import '../../../shared/widgets/user_avatar.dart';
import '../../../shared/widgets/model_avatar.dart';
import '../../../shared/widgets/responsive_drawer_layout.dart';
2025-09-20 22:03:55 +05:30
import '../../../core/models/model.dart';
import '../../../core/models/conversation.dart';
import '../../../core/models/folder.dart';
2025-08-21 23:56:47 +05:30
class ChatsDrawer extends ConsumerStatefulWidget {
const ChatsDrawer({super.key});
@override
ConsumerState<ChatsDrawer> createState() => _ChatsDrawerState();
}
class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode(debugLabel: 'drawer_search');
final ScrollController _listController = ScrollController();
2025-08-21 23:56:47 +05:30
Timer? _debounce;
String _query = '';
bool _isLoadingConversation = false;
String? _pendingConversationId;
2025-08-22 01:24:04 +05:30
String? _dragHoverFolderId;
bool _isDragging = false;
bool _draggingHasFolder = false;
2025-08-21 23:56:47 +05:30
// UI state providers for sections
2025-09-21 22:31:44 +05:30
static final _showArchivedProvider =
NotifierProvider<_ShowArchivedNotifier, bool>(_ShowArchivedNotifier.new);
static final _expandedFoldersProvider =
NotifierProvider<_ExpandedFoldersNotifier, Map<String, bool>>(
_ExpandedFoldersNotifier.new,
);
2025-08-21 23:56:47 +05:30
2025-08-28 23:15:55 +05:30
Future<void> _refreshChats() async {
try {
// Always refresh folders and conversations cache
refreshConversationsCache(ref, includeFolders: true);
2025-08-28 23:15:55 +05:30
if (_query.trim().isEmpty) {
// Refresh main conversations list
try {
await ref.read(conversationsProvider.future);
} catch (_) {}
} else {
// Refresh server-side search results
ref.invalidate(serverSearchProvider(_query));
try {
await ref.read(serverSearchProvider(_query).future);
} catch (_) {}
}
// Await folders as well so the list stabilizes
try {
await ref.read(foldersProvider.future);
} catch (_) {}
} catch (_) {}
}
// Build a lazily-constructed sliver list of conversation tiles.
Widget _conversationsSliver(
List<dynamic> items, {
bool inFolder = false,
Map<String, Model> modelsById = const <String, Model>{},
}) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTileFor(
items[index],
inFolder: inFolder,
modelsById: modelsById,
),
childCount: items.length,
),
);
}
2025-08-28 23:15:55 +05:30
// Legacy helper removed: drawer now uses slivers with lazy delegates.
Widget _buildRefreshableScrollableSlivers({required List<Widget> slivers}) {
2025-08-28 23:15:55 +05:30
if (Platform.isIOS) {
final scroll = CustomScrollView(
2025-09-07 18:51:52 +05:30
key: const PageStorageKey<String>('chats_drawer_scroll'),
2025-08-28 23:15:55 +05:30
controller: _listController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
2025-09-02 22:24:30 +05:30
CupertinoSliverRefreshControl(onRefresh: _refreshChats),
...slivers,
2025-08-28 23:15:55 +05:30
],
);
return CupertinoScrollbar(controller: _listController, child: scroll);
}
final scroll = CustomScrollView(
key: const PageStorageKey<String>('chats_drawer_scroll'),
controller: _listController,
physics: const AlwaysScrollableScrollPhysics(),
cacheExtent: 800,
slivers: slivers,
);
2025-08-28 23:15:55 +05:30
return RefreshIndicator(
onRefresh: _refreshChats,
child: Scrollbar(controller: _listController, child: scroll),
2025-08-28 23:15:55 +05:30
);
}
2025-08-21 23:56:47 +05:30
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
_searchFocusNode.dispose();
_listController.dispose();
2025-08-21 23:56:47 +05:30
super.dispose();
}
void _onSearchChanged() {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 250), () {
if (!mounted) return;
setState(() => _query = _searchController.text.trim());
});
}
2025-08-22 01:24:04 +05:30
// Payload for drag-and-drop of conversations
// Kept local to this widget
// ignore: unused_element
static _DragConversationData _dragData(String id, String title) =>
_DragConversationData(id: id, title: title);
2025-08-21 23:56:47 +05:30
@override
Widget build(BuildContext context) {
2025-08-22 01:24:04 +05:30
// Bottom section now only shows navigation actions
final sidebarTheme = context.sidebarTheme;
2025-08-21 23:56:47 +05:30
return Container(
decoration: BoxDecoration(
color: sidebarTheme.background,
border: Border(right: BorderSide(color: sidebarTheme.border)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Spacing.inputPadding,
Spacing.sm,
Spacing.md,
Spacing.sm,
),
2025-09-20 18:09:22 +05:30
child: Row(children: [Expanded(child: _buildSearchField(context))]),
),
Expanded(child: _buildConversationList(context)),
Divider(
height: 1,
color: sidebarTheme.border.withValues(alpha: 0.28),
),
_buildBottomSection(context),
],
2025-08-21 23:56:47 +05:30
),
);
}
Widget _buildSearchField(BuildContext context) {
final sidebarTheme = context.sidebarTheme;
return Material(
color: Colors.transparent,
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
onChanged: (_) => _onSearchChanged(),
style: AppTypography.standard.copyWith(color: sidebarTheme.foreground),
decoration: InputDecoration(
isDense: true,
hintText: AppLocalizations.of(context)!.searchConversations,
hintStyle: AppTypography.standard.copyWith(
color: sidebarTheme.foreground.withValues(alpha: 0.6),
),
prefixIcon: Icon(
Platform.isIOS ? CupertinoIcons.search : Icons.search,
color: sidebarTheme.foreground.withValues(alpha: 0.7),
size: IconSize.input,
),
prefixIconConstraints: const BoxConstraints(
minWidth: TouchTarget.minimum,
minHeight: TouchTarget.minimum,
),
suffixIcon: _query.isNotEmpty
? IconButton(
onPressed: () {
_searchController.clear();
setState(() => _query = '');
_searchFocusNode.unfocus();
},
icon: Icon(
Platform.isIOS
? CupertinoIcons.clear_circled_solid
: Icons.clear,
color: sidebarTheme.foreground.withValues(alpha: 0.7),
size: IconSize.input,
),
)
: null,
suffixIconConstraints: const BoxConstraints(
minWidth: TouchTarget.minimum,
minHeight: TouchTarget.minimum,
),
filled: true,
fillColor: sidebarTheme.accent.withValues(alpha: 0.9),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide(
color: sidebarTheme.border.withValues(alpha: 0.28),
width: BorderWidth.thin,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide(
color: sidebarTheme.ring.withValues(alpha: 0.6),
width: BorderWidth.thin,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
),
2025-08-21 23:56:47 +05:30
),
),
);
}
Widget _buildConversationList(BuildContext context) {
final theme = context.conduitTheme;
if (_query.isEmpty) {
final conversationsAsync = ref.watch(conversationsProvider);
return conversationsAsync.when(
data: (items) {
final list = items;
// Build a models map once for this build.
final modelsAsync = ref.watch(modelsProvider);
final Map<String, Model> modelsById = modelsAsync.maybeWhen(
data: (models) => {
for (final m in models)
if (m.id.isNotEmpty) m.id: m,
},
orElse: () => const <String, Model>{},
);
2025-08-21 23:56:47 +05:30
if (list.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Spacing.lg),
child: Text(
2025-08-28 23:46:32 +05:30
AppLocalizations.of(context)!.noConversationsYet,
2025-08-21 23:56:47 +05:30
style: AppTypography.bodyMediumStyle.copyWith(
color: theme.textSecondary,
),
),
),
);
}
// Build sections
final pinned = list.where((c) => c.pinned == true).toList();
2025-08-28 23:05:27 +05:30
// Determine which folder IDs actually exist from the API
final foldersState = ref.watch(foldersProvider);
final availableFolderIds = foldersState.maybeWhen(
data: (folders) => folders.map((f) => f.id).toSet(),
orElse: () => <String>{},
);
// Conversations that reference a non-existent/unknown folder should not disappear.
// Treat those as regular until the folders list is available and contains the ID.
final regular = list.where((c) {
final hasFolder = (c.folderId != null && c.folderId!.isNotEmpty);
2025-09-16 15:57:30 +05:30
final folderKnown =
hasFolder && availableFolderIds.contains(c.folderId);
2025-08-28 23:05:27 +05:30
return c.pinned != true &&
c.archived != true &&
(!hasFolder || !folderKnown);
}).toList();
final foldered = list.where((c) {
final hasFolder = (c.folderId != null && c.folderId!.isNotEmpty);
return c.pinned != true &&
c.archived != true &&
hasFolder &&
availableFolderIds.contains(c.folderId);
}).toList();
2025-08-21 23:56:47 +05:30
final archived = list.where((c) => c.archived == true).toList();
final slivers = <Widget>[
2025-08-28 23:15:55 +05:30
if (pinned.isNotEmpty) ...[
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildSectionHeader(
AppLocalizations.of(context)!.pinned,
pinned.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(pinned, modelsById: modelsById),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
2025-08-28 23:15:55 +05:30
],
// Folders section (shown even if empty)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()),
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
2025-08-28 23:15:55 +05:30
if (_isDragging && _draggingHasFolder) ...[
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildUnfileDropTarget()),
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.sm)),
2025-08-28 23:15:55 +05:30
],
...ref
.watch(foldersProvider)
.when(
data: (folders) {
final grouped = <String, List<dynamic>>{};
for (final c in foldered) {
final id = c.folderId!;
grouped.putIfAbsent(id, () => []).add(c);
}
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(
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
2025-08-28 23:15:55 +05:30
),
sliver: SliverToBoxAdapter(
child: _buildFolderHeader(
folder.id,
folder.name,
convs.length,
defaultExpanded: folder.isExpanded,
2025-08-28 23:15:55 +05:30
),
),
),
2025-08-28 23:15:55 +05:30
);
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),
),
);
}
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
}
return out.isEmpty
? <Widget>[
const SliverToBoxAdapter(child: SizedBox.shrink()),
]
: out;
2025-08-28 23:15:55 +05:30
},
loading: () => [
const SliverToBoxAdapter(child: SizedBox.shrink()),
],
error: (e, st) => [
const SliverToBoxAdapter(child: SizedBox.shrink()),
],
2025-08-28 23:15:55 +05:30
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
2025-08-28 23:15:55 +05:30
if (regular.isNotEmpty) ...[
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildSectionHeader(
AppLocalizations.of(context)!.recent,
regular.length,
),
),
2025-08-28 23:15:55 +05:30
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(regular, modelsById: modelsById),
2025-08-28 23:15:55 +05:30
],
if (archived.isNotEmpty) ...[
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildArchivedHeader(archived.length),
),
),
if (ref.watch(_showArchivedProvider)) ...[
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(archived, modelsById: modelsById),
],
2025-08-28 23:15:55 +05:30
],
];
return _buildRefreshableScrollableSlivers(slivers: slivers);
},
loading: () =>
const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(Spacing.md),
child: Text(
AppLocalizations.of(context)!.failedToLoadChats,
style: AppTypography.bodyMediumStyle.copyWith(
color: theme.textSecondary,
),
),
),
),
);
}
// Server-backed search
final searchAsync = ref.watch(serverSearchProvider(_query));
return searchAsync.when(
data: (list) {
if (list.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Spacing.lg),
child: Text(
'No results for "$_query"',
style: AppTypography.bodyMediumStyle.copyWith(
color: theme.textSecondary,
),
),
),
);
}
final pinned = list.where((c) => c.pinned == true).toList();
// Build a models map once for search builds too.
final modelsAsync = ref.watch(modelsProvider);
final Map<String, Model> modelsById = modelsAsync.maybeWhen(
data: (models) => {
for (final m in models)
if (m.id.isNotEmpty) m.id: m,
},
orElse: () => const <String, Model>{},
);
2025-08-28 23:05:27 +05:30
// For search results, apply the same folder safety logic
final foldersState = ref.watch(foldersProvider);
final availableFolderIds = foldersState.maybeWhen(
data: (folders) => folders.map((f) => f.id).toSet(),
orElse: () => <String>{},
);
final regular = list.where((c) {
final hasFolder = (c.folderId != null && c.folderId!.isNotEmpty);
2025-09-16 15:57:30 +05:30
final folderKnown =
hasFolder && availableFolderIds.contains(c.folderId);
2025-08-28 23:05:27 +05:30
return c.pinned != true &&
c.archived != true &&
(!hasFolder || !folderKnown);
}).toList();
final foldered = list.where((c) {
final hasFolder = (c.folderId != null && c.folderId!.isNotEmpty);
return c.pinned != true &&
c.archived != true &&
hasFolder &&
availableFolderIds.contains(c.folderId);
}).toList();
final archived = list.where((c) => c.archived == true).toList();
final slivers = <Widget>[
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildSectionHeader('Results', list.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
];
if (pinned.isNotEmpty) {
slivers.addAll([
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildSectionHeader(
AppLocalizations.of(context)!.pinned,
pinned.length,
),
),
2025-08-21 23:56:47 +05:30
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(pinned, modelsById: modelsById),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
]);
}
slivers.addAll([
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()),
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
]);
if (_isDragging && _draggingHasFolder) {
slivers.add(
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildUnfileDropTarget()),
),
);
slivers.add(
const SliverToBoxAdapter(child: SizedBox(height: Spacing.sm)),
);
}
2025-08-28 23:15:55 +05:30
final folderSlivers = ref
.watch(foldersProvider)
.when(
data: (folders) {
final grouped = <String, List<dynamic>>{};
for (final c in foldered) {
final id = c.folderId!;
grouped.putIfAbsent(id, () => []).add(c);
}
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(
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
),
sliver: SliverToBoxAdapter(
child: _buildFolderHeader(
2025-08-28 23:15:55 +05:30
folder.id,
folder.name,
convs.length,
defaultExpanded: folder.isExpanded,
2025-08-28 23:15:55 +05:30
),
),
),
);
if (isExpanded && hasItems) {
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
2025-08-28 23:15:55 +05:30
);
out.add(
_conversationsSliver(
convs,
inFolder: true,
modelsById: modelsById,
),
);
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.sm),
),
);
}
}
return out.isEmpty
? <Widget>[
const SliverToBoxAdapter(child: SizedBox.shrink()),
]
: out;
},
loading: () => <Widget>[
const SliverToBoxAdapter(child: SizedBox.shrink()),
],
error: (e, st) => <Widget>[
const SliverToBoxAdapter(child: SizedBox.shrink()),
],
);
slivers.addAll(folderSlivers);
if (regular.isNotEmpty) {
slivers.addAll([
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildSectionHeader(
AppLocalizations.of(context)!.recent,
regular.length,
),
),
2025-08-28 23:15:55 +05:30
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(regular, modelsById: modelsById),
]);
}
if (archived.isNotEmpty) {
slivers.addAll([
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildArchivedHeader(archived.length),
),
),
]);
if (ref.watch(_showArchivedProvider)) {
slivers.add(
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
);
slivers.add(_conversationsSliver(archived, modelsById: modelsById));
}
}
return _buildRefreshableScrollableSlivers(slivers: slivers);
2025-08-21 23:56:47 +05:30
},
loading: () =>
const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
2025-08-21 23:56:47 +05:30
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(Spacing.md),
child: Text(
'Search failed',
style: AppTypography.bodyMediumStyle.copyWith(
color: context.sidebarTheme.foreground.withValues(alpha: 0.7),
2025-08-21 23:56:47 +05:30
),
),
),
),
);
}
Widget _buildSectionHeader(String title, int count) {
final sidebarTheme = context.sidebarTheme;
2025-08-21 23:56:47 +05:30
return Row(
children: [
Text(
title,
style: AppTypography.labelStyle.copyWith(
color: sidebarTheme.foreground.withValues(alpha: 0.9),
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
2025-08-21 23:56:47 +05:30
),
const SizedBox(width: Spacing.xs),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: sidebarTheme.accent.withValues(alpha: 0.7),
2025-08-21 23:56:47 +05:30
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
border: Border.all(
color: sidebarTheme.border.withValues(alpha: 0.35),
width: BorderWidth.micro,
),
2025-08-21 23:56:47 +05:30
),
child: Text(
'$count',
style: AppTypography.tiny.copyWith(
color: sidebarTheme.foreground.withValues(alpha: 0.8),
decoration: TextDecoration.none,
),
2025-08-21 23:56:47 +05:30
),
),
],
);
}
2025-08-22 01:24:04 +05:30
/// Header for the Folders section with a create button on the right
Widget _buildFoldersSectionHeader() {
final theme = context.conduitTheme;
return Row(
children: [
Text(
AppLocalizations.of(context)!.folders,
style: AppTypography.labelStyle.copyWith(
color: theme.textSecondary,
decoration: TextDecoration.none,
),
2025-08-22 01:24:04 +05:30
),
const Spacer(),
IconButton(
visualDensity: VisualDensity.compact,
2025-08-23 23:56:53 +05:30
tooltip: AppLocalizations.of(context)!.newFolder,
2025-08-22 01:24:04 +05:30
icon: Icon(
Platform.isIOS
? CupertinoIcons.folder_badge_plus
: Icons.create_new_folder_outlined,
2025-08-22 01:24:04 +05:30
color: theme.iconPrimary,
),
onPressed: _promptCreateFolder,
),
],
);
}
Future<void> _promptCreateFolder() async {
2025-09-07 23:48:47 +05:30
final name = await ThemedDialogs.promptTextInput(
context,
title: AppLocalizations.of(context)!.newFolder,
hintText: AppLocalizations.of(context)!.folderName,
confirmText: AppLocalizations.of(context)!.create,
cancelText: AppLocalizations.of(context)!.cancel,
2025-08-22 01:24:04 +05:30
);
if (name == null) return;
if (name.isEmpty) return;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.createFolder(name: name);
HapticFeedback.lightImpact();
refreshConversationsCache(ref, includeFolders: true);
} catch (e, stackTrace) {
2025-08-22 01:24:04 +05:30
if (!mounted) return;
DebugLogger.error(
'create-folder-failed',
scope: 'drawer',
error: e,
stackTrace: stackTrace,
);
await _showDrawerError(
AppLocalizations.of(context)!.failedToCreateFolder,
);
2025-08-22 01:24:04 +05:30
}
}
Widget _buildFolderHeader(
String folderId,
String name,
int count, {
bool defaultExpanded = false,
}) {
2025-08-21 23:56:47 +05:30
final theme = context.conduitTheme;
final expandedMap = ref.watch(_expandedFoldersProvider);
final isExpanded = expandedMap[folderId] ?? defaultExpanded;
2025-08-22 01:24:04 +05:30
final isHover = _dragHoverFolderId == folderId;
return DragTarget<_DragConversationData>(
onWillAcceptWithDetails: (details) {
setState(() => _dragHoverFolderId = folderId);
return true;
},
onLeave: (_) => setState(() => _dragHoverFolderId = null),
onAcceptWithDetails: (details) async {
setState(() {
_dragHoverFolderId = null;
_isDragging = false;
});
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.moveConversationToFolder(details.data.id, folderId);
HapticFeedback.selectionClick();
refreshConversationsCache(ref, includeFolders: true);
} catch (e, stackTrace) {
DebugLogger.error(
'move-conversation-failed',
scope: 'drawer',
error: e,
stackTrace: stackTrace,
);
2025-08-22 01:24:04 +05:30
if (mounted) {
await _showDrawerError(
AppLocalizations.of(context)!.failedToMoveChat,
);
2025-08-22 01:24:04 +05:30
}
}
},
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;
}
2025-08-22 01:24:04 +05:30
return Material(
color: isHover ? hoverColor : baseColor,
2025-08-22 01:24:04 +05:30
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.small),
side: BorderSide(color: borderColor, width: BorderWidth.thin),
2025-08-22 01:24:04 +05:30
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.small),
2025-08-22 01:24:04 +05:30
onTap: () {
final current = {...ref.read(_expandedFoldersProvider)};
final next = !isExpanded;
current[folderId] = next;
2025-09-21 22:31:44 +05:30
ref.read(_expandedFoldersProvider.notifier).set(current);
2025-08-22 01:24:04 +05:30
},
onLongPress: () {
HapticFeedback.selectionClick();
_showFolderContextMenu(context, folderId, name);
},
overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: TouchTarget.listItem,
2025-08-22 01:24:04 +05:30
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
),
child: LayoutBuilder(
builder: (context, constraints) {
final hasFiniteWidth = constraints.maxWidth.isFinite;
final textFit = hasFiniteWidth
? FlexFit.tight
: FlexFit.loose;
return Row(
mainAxisSize: hasFiniteWidth
? MainAxisSize.max
: MainAxisSize.min,
children: [
Icon(
isExpanded
? (Platform.isIOS
? CupertinoIcons.folder_open
: Icons.folder_open)
: (Platform.isIOS
? CupertinoIcons.folder
: Icons.folder),
color: theme.iconPrimary,
size: IconSize.listItem,
),
const SizedBox(width: Spacing.sm),
Flexible(
fit: textFit,
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.standard.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w400,
),
),
),
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,
),
],
);
},
),
2025-08-22 01:24:04 +05:30
),
),
),
);
},
);
}
List<dynamic> _resolveFolderConversations(
Folder folder,
List<dynamic> existing,
) {
// Preserve the current conversational ordering while ensuring items from
// the folder metadata appear even if the main list has not fetched them
// yet. This primarily happens when chats live exclusively inside folders
// and the conversations endpoint omits them.
final result = <dynamic>[];
final existingMap = <String, dynamic>{};
for (final item in existing) {
final id = _conversationId(item);
if (id != null) {
existingMap[id] = item;
}
}
if (folder.conversationIds.isNotEmpty) {
for (final convId in folder.conversationIds) {
final existingItem = existingMap.remove(convId);
if (existingItem != null) {
result.add(existingItem);
} else {
result.add(_placeholderConversation(convId, folder.id));
}
}
// Append any remaining conversations that claim this folder but are
// missing from the folder metadata list (defensive for API drift).
result.addAll(existingMap.values);
} else {
result.addAll(existingMap.values);
}
return result;
}
Conversation _placeholderConversation(
String conversationId,
String folderId,
) {
const fallbackTitle = 'Chat';
final epoch = DateTime.fromMillisecondsSinceEpoch(0);
return Conversation(
id: conversationId,
title: fallbackTitle,
createdAt: epoch,
updatedAt: epoch,
folderId: folderId,
messages: const [],
);
}
String? _conversationId(dynamic item) {
if (item is Conversation) return item.id;
try {
final value = (item as dynamic).id;
if (value is String) {
return value;
}
} catch (_) {
return null;
}
return null;
}
Future<void> _showDrawerError(String message) async {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
final theme = context.conduitTheme;
await ThemedDialogs.show<void>(
context,
title: l10n.errorMessage,
content: Text(
message,
style: AppTypography.bodyMediumStyle.copyWith(
color: theme.textSecondary,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.ok),
),
],
);
}
void _showFolderContextMenu(
BuildContext context,
String folderId,
String folderName,
) {
2025-09-19 23:35:46 +05:30
final l10n = AppLocalizations.of(context)!;
2025-09-19 21:12:15 +05:30
2025-09-19 23:35:46 +05:30
showConduitContextMenu(
context: context,
2025-09-19 23:35:46 +05:30
actions: [
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_rounded,
label: l10n.rename,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
await _renameFolder(context, folderId, folderName);
},
),
2025-09-19 23:35:46 +05:30
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.delete,
materialIcon: Icons.delete_rounded,
label: l10n.delete,
destructive: true,
onBeforeClose: () => HapticFeedback.mediumImpact(),
onSelected: () async {
await _confirmAndDeleteFolder(context, folderId, folderName);
},
),
],
);
}
Future<void> _renameFolder(
BuildContext context,
String folderId,
String currentName,
) async {
2025-09-07 23:48:47 +05:30
final newName = await ThemedDialogs.promptTextInput(
context,
title: AppLocalizations.of(context)!.rename,
hintText: AppLocalizations.of(context)!.folderName,
initialValue: currentName,
confirmText: AppLocalizations.of(context)!.save,
cancelText: AppLocalizations.of(context)!.cancel,
);
if (newName == null) return;
if (newName.isEmpty || newName == currentName) return;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.updateFolder(folderId, name: newName);
HapticFeedback.selectionClick();
refreshConversationsCache(ref, includeFolders: true);
} catch (e, stackTrace) {
if (!mounted) return;
DebugLogger.error(
'rename-folder-failed',
scope: 'drawer',
error: e,
stackTrace: stackTrace,
);
await _showDrawerError('Failed to rename folder');
}
}
Future<void> _confirmAndDeleteFolder(
BuildContext context,
String folderId,
String folderName,
) async {
2025-09-16 18:15:44 +05:30
final l10n = AppLocalizations.of(context)!;
2025-09-07 23:48:47 +05:30
final confirmed = await ThemedDialogs.confirm(
context,
2025-09-16 18:15:44 +05:30
title: l10n.deleteFolderTitle,
message: l10n.deleteFolderMessage,
confirmText: l10n.delete,
isDestructive: true,
);
2025-09-16 18:15:44 +05:30
if (!mounted) return;
if (!confirmed) return;
2025-09-16 18:15:44 +05:30
final deleteFolderError = l10n.failedToDeleteFolder;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.deleteFolder(folderId);
HapticFeedback.mediumImpact();
refreshConversationsCache(ref, includeFolders: true);
} catch (e, stackTrace) {
if (!mounted) return;
DebugLogger.error(
'delete-folder-failed',
scope: 'drawer',
error: e,
stackTrace: stackTrace,
);
await _showDrawerError(deleteFolderError);
}
}
2025-08-22 01:24:04 +05:30
Widget _buildUnfileDropTarget() {
final theme = context.conduitTheme;
2025-09-16 18:15:44 +05:30
final l10n = AppLocalizations.of(context)!;
2025-08-22 01:24:04 +05:30
final isHover = _dragHoverFolderId == '__UNFILE__';
return DragTarget<_DragConversationData>(
onWillAcceptWithDetails: (details) {
setState(() => _dragHoverFolderId = '__UNFILE__');
return true;
},
onLeave: (_) => setState(() => _dragHoverFolderId = null),
onAcceptWithDetails: (details) async {
setState(() {
_dragHoverFolderId = null;
_isDragging = false;
});
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.moveConversationToFolder(details.data.id, null);
HapticFeedback.selectionClick();
refreshConversationsCache(ref, includeFolders: true);
} catch (e, stackTrace) {
DebugLogger.error(
'unfile-conversation-failed',
scope: 'drawer',
error: e,
stackTrace: stackTrace,
);
2025-08-22 01:24:04 +05:30
if (mounted) {
await _showDrawerError(l10n.failedToMoveChat);
2025-08-22 01:24:04 +05:30
}
}
},
builder: (context, candidate, rejected) {
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: BoxDecoration(
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.08)
: theme.surfaceContainer.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
2025-08-22 01:24:04 +05:30
border: Border.all(
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.5)
: theme.dividerColor.withValues(alpha: 0.5),
width: BorderWidth.standard,
2025-08-22 01:24:04 +05:30
),
),
padding: const EdgeInsets.all(Spacing.sm),
2025-08-21 23:56:47 +05:30
child: Row(
children: [
Icon(
2025-08-22 01:24:04 +05:30
Platform.isIOS
? CupertinoIcons.folder_badge_minus
: Icons.folder_off_outlined,
2025-08-21 23:56:47 +05:30
color: theme.iconPrimary,
size: IconSize.small,
2025-08-21 23:56:47 +05:30
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
2025-08-22 01:24:04 +05:30
'Drop here to remove from folder',
style: AppTypography.bodySmallStyle.copyWith(
2025-08-21 23:56:47 +05:30
color: theme.textPrimary,
fontWeight: FontWeight.w500,
2025-08-21 23:56:47 +05:30
),
),
),
],
),
2025-08-22 01:24:04 +05:30
);
},
2025-08-21 23:56:47 +05:30
);
}
Widget _buildTileFor(
dynamic conv, {
bool inFolder = false,
Map<String, Model> modelsById = const <String, Model>{},
}) {
// Only rebuild this tile when its own selected state changes.
final isActive = ref.watch(
activeConversationProvider.select((c) => c?.id == conv.id),
);
2025-08-22 01:24:04 +05:30
final title = conv.title?.isEmpty == true ? 'Chat' : (conv.title ?? 'Chat');
final theme = context.conduitTheme;
final bool isLoadingSelected =
(_pendingConversationId == conv.id) &&
(ref.watch(chat.isLoadingConversationProvider) == true);
final bool isPinned = conv.pinned == true;
2025-09-20 22:03:55 +05:30
Model? model;
final modelId = (conv.model is String && (conv.model as String).isNotEmpty)
? conv.model as String
: null;
if (modelId != null) {
model = modelsById[modelId];
2025-09-20 22:03:55 +05:30
}
final api = ref.watch(apiServiceProvider);
final modelIconUrl = resolveModelIconUrlForModel(api, model);
Widget? leading;
if (modelId != null) {
leading = ModelAvatar(
size: 28,
imageUrl: modelIconUrl,
label: model?.name ?? modelId,
);
}
2025-08-22 01:24:04 +05:30
final tile = _ConversationTile(
title: title,
pinned: isPinned,
2025-08-22 01:24:04 +05:30
selected: isActive,
isLoading: isLoadingSelected,
2025-09-20 22:03:55 +05:30
leading: leading,
onTap: _isLoadingConversation
? null
: () => _selectConversation(context, conv.id),
2025-08-22 01:24:04 +05:30
onLongPress: null,
onMorePressed: () {
2025-09-19 23:35:46 +05:30
showConversationContextMenu(
context: context,
ref: ref,
conversation: conv,
);
2025-08-22 01:24:04 +05:30
},
);
return RepaintBoundary(
child: Padding(
padding: EdgeInsets.only(
bottom: Spacing.xs,
left: inFolder ? Spacing.md : 0,
2025-08-22 01:24:04 +05:30
),
child: LongPressDraggable<_DragConversationData>(
data: _DragConversationData(id: conv.id, title: title),
dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: _ConversationDragFeedback(
title: title,
pinned: isPinned,
theme: theme,
),
childWhenDragging: Opacity(
opacity: 0.5,
child: IgnorePointer(child: tile),
),
onDragStarted: () {
HapticFeedback.lightImpact();
final hasFolder =
(conv.folderId != null && (conv.folderId as String).isNotEmpty);
setState(() {
_isDragging = true;
_draggingHasFolder = hasFolder;
});
},
onDragEnd: (_) => setState(() {
_dragHoverFolderId = null;
_isDragging = false;
_draggingHasFolder = false;
}),
child: tile,
2025-08-22 01:24:04 +05:30
),
2025-08-21 23:56:47 +05:30
),
);
}
Widget _buildArchivedHeader(int count) {
2025-08-21 23:56:47 +05:30
final theme = context.conduitTheme;
final show = ref.watch(_showArchivedProvider);
return Material(
color: show ? theme.navigationSelectedBackground : theme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.small),
side: BorderSide(
color: show
? theme.navigationSelected
: theme.surfaceContainerHighest.withValues(alpha: 0.40),
width: BorderWidth.thin,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.small),
onTap: () => ref.read(_showArchivedProvider.notifier).set(!show),
overlayColor: WidgetStateProperty.resolveWith((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;
}),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: TouchTarget.listItem),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
),
child: LayoutBuilder(
builder: (context, constraints) {
final hasFiniteWidth = constraints.maxWidth.isFinite;
final textFit = hasFiniteWidth ? FlexFit.tight : FlexFit.loose;
return Row(
mainAxisSize: hasFiniteWidth
? MainAxisSize.max
: MainAxisSize.min,
children: [
Icon(
Platform.isIOS
? CupertinoIcons.archivebox
: Icons.archive_rounded,
color: theme.iconPrimary,
size: IconSize.listItem,
),
const SizedBox(width: Spacing.sm),
Flexible(
fit: textFit,
child: Text(
AppLocalizations.of(context)!.archived,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.standard.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w400,
),
),
),
const SizedBox(width: Spacing.sm),
Text(
'$count',
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,
),
],
);
},
2025-08-21 23:56:47 +05:30
),
),
),
),
2025-08-21 23:56:47 +05:30
);
}
Future<void> _selectConversation(BuildContext context, String id) async {
if (_isLoadingConversation) return;
setState(() => _isLoadingConversation = true);
// Keep a reference only if needed in the future; currently unused.
// Capture a provider container detached from this widget's lifecycle so
// we can continue to read/write providers after the drawer is closed.
final container = ProviderScope.containerOf(context, listen: false);
2025-08-21 23:56:47 +05:30
try {
// Mark global loading to show skeletons in chat
2025-09-21 22:31:44 +05:30
container.read(chat.isLoadingConversationProvider.notifier).set(true);
_pendingConversationId = id;
2025-08-21 23:56:47 +05:30
// Immediately clear current chat to show loading skeleton in the chat view
2025-09-21 22:31:44 +05:30
container.read(activeConversationProvider.notifier).clear();
container.read(chat.chatMessagesProvider.notifier).clearMessages();
// Close the slide drawer for faster perceived performance
// (only on mobile; on tablet, drawer stays visible)
if (mounted) {
ResponsiveDrawerLayout.of(context)?.close();
}
// Load the full conversation details in the background
final api = container.read(apiServiceProvider);
2025-08-21 23:56:47 +05:30
if (api != null) {
final full = await api.getConversation(id);
2025-09-21 22:31:44 +05:30
container.read(activeConversationProvider.notifier).set(full);
2025-08-21 23:56:47 +05:30
} else {
// Fallback: use the lightweight item to update the active conversation
container
.read(activeConversationProvider.notifier)
.set(
2025-09-21 22:31:44 +05:30
(await container.read(
conversationsProvider.future,
)).firstWhere((c) => c.id == id),
2025-09-21 22:31:44 +05:30
);
2025-08-21 23:56:47 +05:30
}
// Clear loading after data is ready
2025-09-21 22:31:44 +05:30
container.read(chat.isLoadingConversationProvider.notifier).set(false);
_pendingConversationId = null;
2025-08-21 23:56:47 +05:30
} catch (_) {
2025-09-21 22:31:44 +05:30
container.read(chat.isLoadingConversationProvider.notifier).set(false);
_pendingConversationId = null;
2025-08-21 23:56:47 +05:30
} finally {
if (mounted) setState(() => _isLoadingConversation = false);
}
}
Widget _buildBottomSection(BuildContext context) {
final theme = context.conduitTheme;
final sidebarTheme = context.sidebarTheme;
2025-08-25 10:35:48 +05:30
final currentUserAsync = ref.watch(currentUserProvider);
final userFromProfile = currentUserAsync.maybeWhen(
data: (u) => u,
orElse: () => null,
);
2025-09-28 23:18:24 +05:30
final authUser = ref.watch(currentUserProvider2);
2025-08-25 10:35:48 +05:30
final user = userFromProfile ?? authUser;
2025-09-20 22:03:55 +05:30
final api = ref.watch(apiServiceProvider);
2025-08-25 10:35:48 +05:30
2025-09-16 18:15:44 +05:30
String initialFor(String name) {
2025-08-25 10:35:48 +05:30
if (name.isEmpty) return 'U';
final ch = name.characters.first;
return ch.toUpperCase();
}
2025-09-16 16:24:45 +05:30
final displayName = deriveUserDisplayName(user);
2025-09-16 18:15:44 +05:30
final initial = initialFor(displayName);
2025-09-20 22:03:55 +05:30
final avatarUrl = resolveUserAvatarUrlForUser(api, user);
return Padding(
padding: const EdgeInsets.fromLTRB(Spacing.sm, 0, Spacing.sm, Spacing.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (user != null) ...[
const SizedBox(height: Spacing.sm),
Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: sidebarTheme.accent.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: sidebarTheme.border.withValues(alpha: 0.28),
width: BorderWidth.thin,
2025-08-21 23:56:47 +05:30
),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppBorderRadius.avatar,
2025-08-21 23:56:47 +05:30
),
border: Border.all(
color: theme.buttonPrimary.withValues(alpha: 0.25),
width: BorderWidth.thin,
2025-08-21 23:56:47 +05:30
),
),
// Hard-edge clipping is cheaper than anti-aliased clipping
// and sufficient for avatar squares with rounded corners.
clipBehavior: Clip.hardEdge,
2025-09-20 22:03:55 +05:30
child: UserAvatar(
size: 36,
2025-09-20 22:03:55 +05:30
imageUrl: avatarUrl,
fallbackText: initial,
2025-08-21 23:56:47 +05:30
),
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodySmallStyle.copyWith(
color: sidebarTheme.foreground,
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
),
),
IconButton(
tooltip: AppLocalizations.of(context)!.manage,
onPressed: () {
Navigator.of(context).maybePop();
context.pushNamed(RouteNames.profile);
},
visualDensity: VisualDensity.compact,
icon: Icon(
Platform.isIOS
? CupertinoIcons.settings
: Icons.settings_rounded,
color: sidebarTheme.foreground.withValues(alpha: 0.8),
size: IconSize.medium,
),
),
],
2025-08-21 23:56:47 +05:30
),
),
2025-08-21 23:56:47 +05:30
],
],
2025-08-21 23:56:47 +05:30
),
);
}
}
2025-09-21 22:31:44 +05:30
class _ShowArchivedNotifier extends Notifier<bool> {
@override
bool build() => false;
void set(bool value) => state = value;
}
class _ExpandedFoldersNotifier extends Notifier<Map<String, bool>> {
@override
Map<String, bool> build() => {};
void set(Map<String, bool> value) => state = Map<String, bool>.from(value);
}
2025-08-22 01:24:04 +05:30
class _DragConversationData {
final String id;
final String 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 borderRadius = BorderRadius.circular(AppBorderRadius.small);
2025-09-20 18:09:22 +05:30
final borderColor = theme.surfaceContainerHighest.withValues(alpha: 0.40);
return Material(
color: Colors.transparent,
elevation: Elevation.low,
2025-09-20 18:09:22 +05:30
borderRadius: borderRadius,
child: Container(
constraints: const BoxConstraints(minHeight: TouchTarget.listItem),
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
),
decoration: BoxDecoration(
color: theme.surfaceContainer,
2025-09-20 18:09:22 +05:30
borderRadius: borderRadius,
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;
2025-09-20 22:03:55 +05:30
final Widget? leading;
const _ConversationTileContent({
required this.title,
required this.pinned,
required this.selected,
required this.isLoading,
this.onMorePressed,
2025-09-20 22:03:55 +05:30
this.leading,
});
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
final textStyle = AppTypography.standard.copyWith(
color: theme.textPrimary,
2025-09-19 23:35:46 +05:30
fontWeight: selected ? FontWeight.w600 : 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: [
2025-09-20 22:03:55 +05:30
if (leading != null) ...[
SizedBox(
width: TouchTarget.listItem,
height: TouchTarget.listItem,
child: Center(child: leading!),
),
const SizedBox(width: Spacing.sm),
],
Flexible(
fit: textFit,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
),
),
...trailing,
],
);
},
);
}
}
2025-08-21 23:56:47 +05:30
class _ConversationTile extends StatelessWidget {
final String title;
final bool pinned;
final bool selected;
final bool isLoading;
2025-09-20 22:03:55 +05:30
final Widget? leading;
2025-08-21 23:56:47 +05:30
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onMorePressed;
const _ConversationTile({
required this.title,
required this.pinned,
required this.selected,
required this.isLoading,
2025-09-20 22:03:55 +05:30
this.leading,
2025-08-21 23:56:47 +05:30
required this.onTap,
this.onLongPress,
this.onMorePressed,
});
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
final borderRadius = BorderRadius.circular(AppBorderRadius.small);
2025-09-19 23:35:46 +05:30
final Color background = selected
? theme.buttonPrimary.withValues(alpha: 0.1)
2025-09-19 23:35:46 +05:30
: theme.surfaceContainer;
2025-09-20 18:09:22 +05:30
final Color borderColor = selected
? theme.buttonPrimary.withValues(alpha: 0.5)
2025-09-20 18:09:22 +05:30
: theme.surfaceContainerHighest.withValues(alpha: 0.40);
final List<BoxShadow> shadow = const [];
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;
}
2025-09-07 18:51:52 +05:30
return Semantics(
selected: selected,
button: true,
child: Material(
2025-09-19 23:35:46 +05:30
color: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
2025-09-07 18:51:52 +05:30
child: InkWell(
2025-09-19 23:35:46 +05:30
borderRadius: borderRadius,
2025-09-07 18:51:52 +05:30
onTap: isLoading ? null : onTap,
onLongPress: onLongPress,
overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
decoration: BoxDecoration(
2025-09-19 23:35:46 +05:30
color: background,
borderRadius: borderRadius,
border: Border.all(color: borderColor, width: BorderWidth.thin),
boxShadow: shadow,
),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: TouchTarget.listItem,
2025-08-21 23:56:47 +05:30
),
child: Padding(
2025-09-07 18:51:52 +05:30
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
2025-09-07 18:51:52 +05:30
),
child: _ConversationTileContent(
title: title,
pinned: pinned,
selected: selected,
isLoading: isLoading,
onMorePressed: onMorePressed,
2025-09-20 22:03:55 +05:30
leading: leading,
2025-08-21 23:56:47 +05:30
),
2025-09-07 18:51:52 +05:30
),
),
2025-08-21 23:56:47 +05:30
),
),
),
);
}
}
2025-08-22 01:24:04 +05:30
// Bottom quick actions widget removed as design now shows only profile card