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

1233 lines
43 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 '../../../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';
2025-08-21 23:56:47 +05:30
import '../../profile/views/profile_page.dart';
import '../../../shared/utils/ui_utils.dart';
2025-08-22 01:24:04 +05:30
import '../../../core/auth/auth_state_manager.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');
Timer? _debounce;
String _query = '';
bool _isLoadingConversation = false;
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
static final _showArchivedProvider = StateProvider<bool>((ref) => false);
static final _expandedFoldersProvider =
StateProvider<Map<String, bool>>((ref) => {});
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
_searchFocusNode.dispose();
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
2025-08-21 23:56:47 +05:30
final theme = context.conduitTheme;
return SafeArea(
child: Container(
color: theme.surfaceBackground,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
child: _buildSearchField(context),
),
Expanded(child: _buildConversationList(context)),
const Divider(height: 1),
_buildBottomSection(context),
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
final theme = context.conduitTheme;
return Padding(
padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, 0),
2025-08-22 01:24:04 +05:30
child: Stack(
alignment: Alignment.center,
2025-08-21 23:56:47 +05:30
children: [
2025-08-22 01:24:04 +05:30
// Centered title (no leading icon)
Text(
'Chats',
style: AppTypography.headlineSmallStyle.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w600,
2025-08-21 23:56:47 +05:30
),
2025-08-22 01:24:04 +05:30
textAlign: TextAlign.center,
2025-08-21 23:56:47 +05:30
),
2025-08-22 01:24:04 +05:30
// Right-aligned new chat action
Positioned(
right: 0,
child: IconButton(
icon: Icon(
Platform.isIOS ? CupertinoIcons.plus : Icons.add_rounded,
color: theme.iconPrimary,
),
onPressed: () {
chat.startNewChat(ref);
if (mounted) Navigator.of(context).maybePop();
},
tooltip: 'New Chat',
2025-08-21 23:56:47 +05:30
),
),
],
),
);
}
Widget _buildSearchField(BuildContext context) {
final theme = context.conduitTheme;
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.inputBackground.withValues(alpha: 0.6),
theme.inputBackground.withValues(alpha: 0.3),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
border: Border.all(
color: _searchFocusNode.hasFocus
? theme.buttonPrimary.withValues(alpha: 0.8)
: theme.inputBorder.withValues(alpha: 0.3),
width: BorderWidth.thin,
),
),
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
onChanged: (_) => _onSearchChanged(),
style: TextStyle(
color: theme.inputText,
fontSize: AppTypography.bodyMedium,
),
decoration: InputDecoration(
hintText: 'Search conversations...',
hintStyle: TextStyle(
color: theme.inputPlaceholder.withValues(alpha: 0.8),
fontSize: AppTypography.bodyMedium,
),
prefixIcon: Icon(
Platform.isIOS ? CupertinoIcons.search : Icons.search,
color: theme.iconSecondary,
size: IconSize.md,
),
suffixIcon: _query.isNotEmpty
? IconButton(
onPressed: () {
_searchController.clear();
setState(() => _query = '');
_searchFocusNode.unfocus();
},
icon: Icon(
Platform.isIOS
? CupertinoIcons.clear_circled_solid
: Icons.clear,
color: theme.iconSecondary,
size: IconSize.md,
),
)
: null,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
);
}
Widget _buildConversationList(BuildContext context) {
final theme = context.conduitTheme;
if (_query.isEmpty) {
final conversationsAsync = ref.watch(conversationsProvider);
return conversationsAsync.when(
data: (items) {
final list = items;
if (list.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Spacing.lg),
child: Text(
'No conversations yet',
style: AppTypography.bodyMediumStyle.copyWith(
color: theme.textSecondary,
),
),
),
);
}
// Build sections
final pinned = list.where((c) => c.pinned == true).toList();
final regular = list
.where((c) =>
c.pinned != true &&
c.archived != true &&
(c.folderId == null || c.folderId!.isEmpty))
.toList();
final foldered = list
.where((c) =>
c.pinned != true &&
c.archived != true &&
c.folderId != null &&
c.folderId!.isNotEmpty)
.toList();
final archived = list.where((c) => c.archived == true).toList();
return ListView(
padding: const EdgeInsets.fromLTRB(
Spacing.md,
Spacing.sm,
Spacing.md,
Spacing.md,
),
children: [
if (pinned.isNotEmpty) ...[
_buildSectionHeader('Pinned', pinned.length),
const SizedBox(height: Spacing.xs),
...pinned.map((conv) => _buildTileFor(conv)),
const SizedBox(height: Spacing.md),
],
2025-08-22 01:24:04 +05:30
// Folders section (shown even if empty)
_buildFoldersSectionHeader(),
const SizedBox(height: Spacing.xs),
if (_isDragging && _draggingHasFolder) ...[
_buildUnfileDropTarget(),
const SizedBox(height: Spacing.sm),
],
...ref.watch(foldersProvider).when(
2025-08-21 23:56:47 +05:30
data: (folders) {
final grouped = <String, List<dynamic>>{};
for (final c in foldered) {
final id = c.folderId!;
grouped.putIfAbsent(id, () => []).add(c);
}
2025-08-22 01:24:04 +05:30
// Show all folders (including empty)
final sections = folders.map((folder) {
2025-08-21 23:56:47 +05:30
final expandedMap = ref.watch(_expandedFoldersProvider);
final isExpanded = expandedMap[folder.id] ?? false;
2025-08-22 01:24:04 +05:30
final convs = grouped[folder.id] ?? const <dynamic>[];
2025-08-21 23:56:47 +05:30
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildFolderHeader(folder.id, folder.name, convs.length),
2025-08-22 01:24:04 +05:30
if (isExpanded && convs.isNotEmpty) ...[
2025-08-21 23:56:47 +05:30
const SizedBox(height: Spacing.xs),
...convs.map((c) => _buildTileFor(c, inFolder: true)),
const SizedBox(height: Spacing.sm),
],
],
);
}).toList();
2025-08-22 01:24:04 +05:30
return sections.isEmpty ? [const SizedBox.shrink()] : sections;
2025-08-21 23:56:47 +05:30
},
loading: () => [const SizedBox.shrink()],
error: (e, st) => [const SizedBox.shrink()],
),
2025-08-22 01:24:04 +05:30
const SizedBox(height: Spacing.md),
2025-08-21 23:56:47 +05:30
if (regular.isNotEmpty) ...[
_buildSectionHeader('Recent', regular.length),
const SizedBox(height: Spacing.xs),
...regular.map(_buildTileFor),
],
if (archived.isNotEmpty) ...[
const SizedBox(height: Spacing.md),
_buildArchivedSection(archived),
],
],
);
},
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(Spacing.md),
child: Text(
'Failed to load chats',
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();
final regular = list
.where((c) =>
c.pinned != true &&
c.archived != true &&
(c.folderId == null || c.folderId!.isEmpty))
.toList();
final foldered = list
.where((c) =>
c.pinned != true &&
c.archived != true &&
c.folderId != null &&
c.folderId!.isNotEmpty)
.toList();
final archived = list.where((c) => c.archived == true).toList();
return ListView(
padding: const EdgeInsets.fromLTRB(
Spacing.md,
Spacing.sm,
Spacing.md,
Spacing.md,
),
children: [
_buildSectionHeader('Results', list.length),
const SizedBox(height: Spacing.xs),
if (pinned.isNotEmpty) ...[
_buildSectionHeader('Pinned', pinned.length),
const SizedBox(height: Spacing.xs),
...pinned.map((conv) => _buildTileFor(conv)),
const SizedBox(height: Spacing.md),
],
2025-08-22 01:24:04 +05:30
// Folders section (shown even if empty)
_buildFoldersSectionHeader(),
const SizedBox(height: Spacing.xs),
if (_isDragging && _draggingHasFolder) ...[
_buildUnfileDropTarget(),
const SizedBox(height: Spacing.sm),
],
...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);
}
2025-08-21 23:56:47 +05:30
2025-08-22 01:24:04 +05:30
final sections = folders.map((folder) {
2025-08-21 23:56:47 +05:30
final expandedMap = ref.watch(_expandedFoldersProvider);
final isExpanded = expandedMap[folder.id] ?? false;
2025-08-22 01:24:04 +05:30
final convs = grouped[folder.id] ?? const <dynamic>[];
2025-08-21 23:56:47 +05:30
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildFolderHeader(folder.id, folder.name, convs.length),
2025-08-22 01:24:04 +05:30
if (isExpanded && convs.isNotEmpty) ...[
2025-08-21 23:56:47 +05:30
const SizedBox(height: Spacing.xs),
...convs.map((c) => _buildTileFor(c, inFolder: true)),
const SizedBox(height: Spacing.sm),
],
],
);
}).toList();
2025-08-22 01:24:04 +05:30
return sections.isEmpty ? [const SizedBox.shrink()] : sections;
2025-08-21 23:56:47 +05:30
},
loading: () => [const SizedBox.shrink()],
error: (e, st) => [const SizedBox.shrink()],
),
2025-08-22 01:24:04 +05:30
const SizedBox(height: Spacing.md),
2025-08-21 23:56:47 +05:30
if (regular.isNotEmpty) ...[
_buildSectionHeader('Recent', regular.length),
const SizedBox(height: Spacing.xs),
...regular.map(_buildTileFor),
],
if (archived.isNotEmpty) ...[
const SizedBox(height: Spacing.md),
_buildArchivedSection(archived),
],
],
);
},
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(Spacing.md),
child: Text(
'Search failed',
style: AppTypography.bodyMediumStyle.copyWith(
color: theme.textSecondary,
),
),
),
),
);
}
Widget _buildSectionHeader(String title, int count) {
final theme = context.conduitTheme;
return Row(
children: [
Text(
title,
style: AppTypography.bodySmallStyle.copyWith(
fontWeight: FontWeight.w600,
color: theme.textSecondary,
letterSpacing: 0.2,
),
),
const SizedBox(width: Spacing.xs),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.surfaceBackground.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
border: Border.all(color: theme.dividerColor, width: BorderWidth.thin),
),
child: Text(
'$count',
style: AppTypography.bodySmallStyle.copyWith(
color: theme.textSecondary,
),
),
),
],
);
}
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(
'Folders',
style: AppTypography.bodySmallStyle.copyWith(
fontWeight: FontWeight.w600,
color: theme.textSecondary,
letterSpacing: 0.2,
),
),
const Spacer(),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'New Folder',
icon: Icon(
Platform.isIOS ? CupertinoIcons.folder_badge_plus : Icons.create_new_folder_outlined,
color: theme.iconPrimary,
),
onPressed: _promptCreateFolder,
),
],
);
}
Future<void> _promptCreateFolder() async {
final theme = context.conduitTheme;
final controller = TextEditingController();
final name = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: theme.surfaceBackground,
title: Text('New Folder', style: TextStyle(color: theme.textPrimary)),
content: TextField(
controller: controller,
autofocus: true,
style: TextStyle(color: theme.inputText),
decoration: InputDecoration(
hintText: 'Folder name',
hintStyle: TextStyle(color: theme.inputPlaceholder),
enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: theme.inputBorder)),
focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: theme.buttonPrimary)),
),
onSubmitted: (v) => Navigator.pop(ctx, controller.text.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
child: const Text('Create'),
),
],
),
);
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();
ref.invalidate(foldersProvider);
if (!mounted) return;
UiUtils.showMessage(context, 'Folder created');
} catch (e) {
if (!mounted) return;
UiUtils.showMessage(context, 'Failed to create folder', isError: true);
}
}
2025-08-21 23:56:47 +05:30
Widget _buildFolderHeader(String folderId, String name, int count) {
final theme = context.conduitTheme;
final expandedMap = ref.watch(_expandedFoldersProvider);
final isExpanded = expandedMap[folderId] ?? false;
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();
ref.invalidate(conversationsProvider);
ref.invalidate(foldersProvider);
if (mounted) {
UiUtils.showMessage(context, 'Moved "${details.data.title}" to "$name"');
}
} catch (_) {
if (mounted) {
UiUtils.showMessage(context, 'Failed to move chat', isError: true);
}
}
},
builder: (context, candidateData, rejectedData) {
return Material(
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.08)
: theme.surfaceBackground.withValues(alpha: 0.05),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
side: BorderSide(
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.6)
: theme.dividerColor,
width: BorderWidth.regular,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
onTap: () {
final current = {...ref.read(_expandedFoldersProvider)};
current[folderId] = !isExpanded;
ref.read(_expandedFoldersProvider.notifier).state = current;
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
child: Row(
children: [
Icon(
isExpanded
? (Platform.isIOS ? CupertinoIcons.folder_open : Icons.folder_open)
: (Platform.isIOS ? CupertinoIcons.folder : Icons.folder),
color: theme.iconPrimary,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
name,
style: AppTypography.bodyLargeStyle.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
Text(
'$count',
style: AppTypography.bodySmallStyle.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,
)
],
),
),
),
);
},
);
}
Widget _buildUnfileDropTarget() {
final theme = context.conduitTheme;
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();
ref.invalidate(conversationsProvider);
ref.invalidate(foldersProvider);
if (mounted) {
UiUtils.showMessage(context, 'Removed "${details.data.title}" from folder');
}
} catch (_) {
if (mounted) {
UiUtils.showMessage(context, 'Failed to move chat', isError: true);
}
}
},
builder: (context, candidate, rejected) {
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: BoxDecoration(
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.08)
: theme.surfaceBackground.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.6)
: theme.dividerColor,
width: BorderWidth.regular,
),
),
2025-08-21 23:56:47 +05:30
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
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,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
2025-08-22 01:24:04 +05:30
'Drop here to remove from folder',
style: AppTypography.bodyMediumStyle.copyWith(
2025-08-21 23:56:47 +05:30
color: theme.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
],
),
2025-08-22 01:24:04 +05:30
);
},
2025-08-21 23:56:47 +05:30
);
}
Widget _buildTileFor(dynamic conv, {bool inFolder = false}) {
final isActive = ref.watch(activeConversationProvider)?.id == conv.id;
2025-08-22 01:24:04 +05:30
final title = conv.title?.isEmpty == true ? 'Chat' : (conv.title ?? 'Chat');
final tile = _ConversationTile(
title: title,
pinned: conv.pinned == true,
selected: isActive,
onTap: _isLoadingConversation ? null : () => _selectConversation(context, conv.id),
// Remove long-press context menu to avoid conflict with drag gesture
onLongPress: null,
onMorePressed: () {
HapticFeedback.selectionClick();
_showConversationContextMenu(context, conv);
},
);
2025-08-21 23:56:47 +05:30
return 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: Material(
color: Colors.transparent,
elevation: 6,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: Opacity(
opacity: 0.9,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: Theme.of(context).dividerColor,
width: BorderWidth.regular,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Platform.isIOS
? CupertinoIcons.chat_bubble_2
: Icons.chat_bubble_outline,
size: IconSize.md,
),
const SizedBox(width: Spacing.xs),
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
),
),
),
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;
});
2025-08-21 23:56:47 +05:30
},
2025-08-22 01:24:04 +05:30
onDragEnd: (_) => setState(() {
_dragHoverFolderId = null;
_isDragging = false;
_draggingHasFolder = false;
}),
child: tile,
2025-08-21 23:56:47 +05:30
),
);
}
Widget _buildArchivedSection(List<dynamic> archived) {
final theme = context.conduitTheme;
final show = ref.watch(_showArchivedProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Material(
color: theme.surfaceBackground.withValues(alpha: 0.05),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
side: BorderSide(color: theme.dividerColor, width: BorderWidth.regular),
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
onTap: () =>
ref.read(_showArchivedProvider.notifier).state = !show,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
child: Row(
children: [
Icon(
Platform.isIOS
? CupertinoIcons.archivebox
: Icons.archive_rounded,
color: theme.iconPrimary,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
'Archived',
style: AppTypography.bodyLargeStyle.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
Text(
'${archived.length}',
style: AppTypography.bodySmallStyle.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,
),
],
),
),
),
),
if (show) ...[
const SizedBox(height: Spacing.xs),
...archived.map((c) => _buildTileFor(c)),
],
],
);
}
Future<void> _selectConversation(BuildContext context, String id) async {
if (_isLoadingConversation) return;
setState(() => _isLoadingConversation = true);
final navigator = Navigator.of(context);
try {
// Mark global loading to show skeletons in chat
ref.read(chat.isLoadingConversationProvider.notifier).state = true;
final api = ref.read(apiServiceProvider);
if (api != null) {
final full = await api.getConversation(id);
ref.read(activeConversationProvider.notifier).state = full;
} else {
// Fallback: let ChatPage handle if API missing
ref.read(activeConversationProvider.notifier).state =
(await ref.read(conversationsProvider.future))
.firstWhere((c) => c.id == id);
}
// Clear global loading before closing drawer
ref.read(chat.isLoadingConversationProvider.notifier).state = false;
if (mounted) navigator.maybePop();
} catch (_) {
ref.read(chat.isLoadingConversationProvider.notifier).state = false;
if (mounted) navigator.maybePop();
} finally {
if (mounted) setState(() => _isLoadingConversation = false);
}
}
Widget _buildBottomSection(BuildContext context) {
final theme = context.conduitTheme;
final user = ref.watch(authUserProvider);
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(Spacing.sm, 0, Spacing.sm, Spacing.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (user != null) ...[
2025-08-22 01:24:04 +05:30
const SizedBox(height: Spacing.sm),
2025-08-21 23:56:47 +05:30
Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: theme.surfaceBackground.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(color: theme.dividerColor, width: BorderWidth.regular),
),
child: Row(
children: [
Container(
width: IconSize.avatar,
height: IconSize.avatar,
decoration: BoxDecoration(
color: theme.buttonPrimary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
border: Border.all(color: theme.buttonPrimary.withValues(alpha: 0.35), width: BorderWidth.thin),
),
alignment: Alignment.center,
child: Text(
(user.name ?? user.username ?? 'U').toString().substring(0, 1).toUpperCase(),
style: AppTypography.bodyLargeStyle.copyWith(
color: theme.buttonPrimary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(user.name ?? user.username ?? 'User').toString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodyLargeStyle.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w600,
),
),
Text(
'You',
style: AppTypography.bodySmallStyle.copyWith(
color: theme.textSecondary,
),
),
],
),
),
TextButton(
onPressed: () {
Navigator.of(context).maybePop();
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ProfilePage()),
);
},
child: const Text('Manage'),
)
],
),
),
],
],
),
),
);
}
void _showConversationContextMenu(BuildContext context, dynamic conv) {
final theme = context.conduitTheme;
final bool isPinned = conv.pinned == true;
final bool isArchived = conv.archived == true;
HapticFeedback.selectionClick();
showModalBottomSheet(
context: context,
backgroundColor: theme.surfaceBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppBorderRadius.lg)),
),
builder: (sheetContext) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
isPinned
? (Platform.isIOS ? CupertinoIcons.pin_slash : Icons.push_pin_outlined)
: (Platform.isIOS ? CupertinoIcons.pin_fill : Icons.push_pin_rounded),
color: theme.iconPrimary,
),
title: Text(isPinned ? 'Unpin' : 'Pin', style: TextStyle(color: theme.textPrimary)),
onTap: () async {
HapticFeedback.lightImpact();
Navigator.pop(sheetContext);
try {
await chat.pinConversation(ref, conv.id, !isPinned);
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(this.context, 'Failed to update pin', isError: true);
}
},
),
ListTile(
leading: Icon(
isArchived
? (Platform.isIOS ? CupertinoIcons.archivebox_fill : Icons.unarchive_rounded)
: (Platform.isIOS ? CupertinoIcons.archivebox : Icons.archive_rounded),
color: theme.iconPrimary,
),
title: Text(isArchived ? 'Unarchive' : 'Archive', style: TextStyle(color: theme.textPrimary)),
onTap: () async {
HapticFeedback.lightImpact();
Navigator.pop(sheetContext);
try {
await chat.archiveConversation(ref, conv.id, !isArchived);
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(this.context, 'Failed to update archive', isError: true);
}
},
),
ListTile(
leading: Icon(
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_rounded,
color: theme.iconPrimary,
),
title: Text('Rename', style: TextStyle(color: theme.textPrimary)),
onTap: () async {
HapticFeedback.selectionClick();
Navigator.pop(sheetContext);
await _renameConversation(context, conv.id, conv.title ?? '');
},
),
const Divider(height: 1),
ListTile(
leading: Icon(
Platform.isIOS ? CupertinoIcons.delete : Icons.delete_rounded,
color: theme.error,
),
title: Text('Delete', style: TextStyle(color: theme.error)),
onTap: () async {
HapticFeedback.mediumImpact();
Navigator.pop(sheetContext);
await _confirmAndDeleteConversation(context, conv.id);
},
),
],
),
);
},
);
}
Future<void> _renameConversation(
BuildContext context,
String conversationId,
String currentTitle,
) async {
final theme = context.conduitTheme;
final controller = TextEditingController(text: currentTitle);
final newName = await showDialog<String>(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: theme.surfaceBackground,
title: Text('Rename Chat', style: TextStyle(color: theme.textPrimary)),
content: TextField(
controller: controller,
autofocus: true,
style: TextStyle(color: theme.inputText),
decoration: InputDecoration(
hintText: 'Enter chat name',
hintStyle: TextStyle(color: theme.inputPlaceholder),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: theme.inputBorder),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: theme.buttonPrimary),
),
),
textInputAction: TextInputAction.done,
onSubmitted: (value) => Navigator.pop(dialogContext, value.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.pop(dialogContext, controller.text.trim());
},
child: const Text('Save'),
),
],
);
},
);
if (newName == null) return;
if (newName.isEmpty || newName == currentTitle) return;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.updateConversation(conversationId, title: newName);
HapticFeedback.selectionClick();
// Reflect changes
ref.invalidate(conversationsProvider);
final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) {
ref.read(activeConversationProvider.notifier).state =
active!.copyWith(title: newName);
}
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(this.context, 'Failed to rename chat', isError: true);
}
}
Future<void> _confirmAndDeleteConversation(
BuildContext context,
String conversationId,
) async {
final confirmed = await UiUtils.showConfirmationDialog(
context,
title: 'Delete Chat',
message: 'This chat will be permanently deleted.',
confirmText: 'Delete',
isDestructive: true,
);
if (!confirmed) return;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.deleteConversation(conversationId);
HapticFeedback.mediumImpact();
// Clear if deleting active
final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) {
ref.read(activeConversationProvider.notifier).state = null;
ref.read(chat.chatMessagesProvider.notifier).clearMessages();
}
ref.invalidate(conversationsProvider);
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(this.context, 'Failed to delete chat', isError: true);
}
}
}
2025-08-22 01:24:04 +05:30
class _DragConversationData {
final String id;
final String title;
const _DragConversationData({required this.id, required this.title});
}
2025-08-21 23:56:47 +05:30
class _ConversationTile extends StatelessWidget {
final String title;
final bool pinned;
final bool selected;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onMorePressed;
const _ConversationTile({
required this.title,
required this.pinned,
required this.selected,
required this.onTap,
this.onLongPress,
this.onMorePressed,
});
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
return Material(
color: selected
? theme.buttonPrimary.withValues(alpha: 0.08)
: theme.surfaceBackground.withValues(alpha: 0.03),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
side: BorderSide(
color: selected ? theme.buttonPrimary.withValues(alpha: 0.5) : theme.dividerColor,
width: BorderWidth.regular,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
child: Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodyLargeStyle.copyWith(
color: theme.textPrimary,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
),
const SizedBox(width: Spacing.xs),
if (onMorePressed != null)
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
icon: Icon(
Platform.isIOS ? CupertinoIcons.ellipsis : Icons.more_vert_rounded,
color: theme.iconSecondary,
size: IconSize.md,
),
onPressed: onMorePressed,
tooltip: 'More',
),
],
),
),
),
);
}
}
2025-08-22 01:24:04 +05:30
// Bottom quick actions widget removed as design now shows only profile card