From e4dfb0ad0902e68bf53547fc1679f01665a75325 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Fri, 19 Sep 2025 21:12:15 +0530 Subject: [PATCH] refactor: visual tweaks --- lib/features/chat/views/chat_page.dart | 319 +++++++------ .../chat/widgets/modern_chat_input.dart | 176 ++++--- .../navigation/widgets/chats_drawer.dart | 16 +- lib/features/profile/views/profile_page.dart | 431 ++++++++++-------- lib/shared/widgets/modal_safe_area.dart | 37 ++ 5 files changed, 588 insertions(+), 391 deletions(-) create mode 100644 lib/shared/widgets/modal_safe_area.dart diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 83b238f..8b384d0 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -38,6 +38,7 @@ import '../../../shared/widgets/sheet_handle.dart'; import '../../../shared/widgets/measure_size.dart'; import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/widgets/middle_ellipsis_text.dart'; +import '../../../shared/widgets/modal_safe_area.dart'; import '../../../core/services/settings_service.dart'; // Removed unused PlatformUtils import import '../../../core/services/platform_service.dart' as ps; @@ -1654,19 +1655,24 @@ class _ModelSelectorSheetState extends ConsumerState<_ModelSelectorSheet> { } void _filterModels(String query) { - // Debounce for fast search + setState(() => _searchQuery = query); + _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 160), () { + if (!mounted) return; + + final normalized = query.trim().toLowerCase(); + Iterable list = widget.models; + + if (normalized.isNotEmpty) { + list = list.where((model) { + final name = model.name.toLowerCase(); + final id = model.id.toLowerCase(); + return name.contains(normalized) || id.contains(normalized); + }); + } + setState(() { - _searchQuery = query.toLowerCase(); - Iterable list = widget.models; - if (_searchQuery.isNotEmpty) { - list = list.where((model) { - return model.name.toLowerCase().contains(_searchQuery) || - model.id.toLowerCase().contains(_searchQuery); - }); - } - // No capability filters _filteredModels = list.toList(); }); }); @@ -1674,149 +1680,190 @@ class _ModelSelectorSheetState extends ConsumerState<_ModelSelectorSheet> { @override Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.75, - maxChildSize: 0.92, - minChildSize: 0.45, - builder: (context, scrollController) { - return Container( - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(AppBorderRadius.bottomSheet), - ), - border: Border.all( - color: context.conduitTheme.dividerColor, - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.modal, + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).maybePop(), + child: const SizedBox.shrink(), ), - child: SafeArea( - top: false, - bottom: true, - child: Padding( - padding: const EdgeInsets.all(Spacing.bottomSheetPadding), - child: Column( - children: [ - // Handle bar (standardized) - const SheetHandle(), + ), + DraggableScrollableSheet( + expand: false, + initialChildSize: 0.75, + maxChildSize: 0.92, + minChildSize: 0.45, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.bottomSheet), + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.modal, + ), + child: ModalSheetSafeArea( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.modalPadding, + vertical: Spacing.modalPadding, + ), + child: Column( + children: [ + // Handle bar (standardized) + const SheetHandle(), - // Search field - Padding( - padding: const EdgeInsets.only(bottom: Spacing.md), - child: TextField( - controller: _searchController, - style: TextStyle(color: context.conduitTheme.textPrimary), - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.searchModels, - hintStyle: TextStyle( - color: context.conduitTheme.inputPlaceholder, + // Search field + Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: TextField( + controller: _searchController, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textPrimary, ), - prefixIcon: Icon( - Platform.isIOS ? CupertinoIcons.search : Icons.search, - color: context.conduitTheme.iconSecondary, - ), - filled: true, - fillColor: context.conduitTheme.inputBackground, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.md, + onChanged: _filterModels, + decoration: InputDecoration( + isDense: true, + hintText: AppLocalizations.of(context)!.searchModels, + hintStyle: AppTypography.standard.copyWith( + color: context.conduitTheme.inputPlaceholder, ), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.md, + prefixIcon: Icon( + Platform.isIOS + ? CupertinoIcons.search + : Icons.search, + color: context.conduitTheme.iconSecondary, + size: IconSize.input, ), - borderSide: BorderSide( - color: context.conduitTheme.inputBorder, - width: 1, + prefixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.md, + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + onPressed: () { + _searchController.clear(); + _filterModels(''); + }, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.clear_circled_solid + : Icons.clear, + color: context.conduitTheme.iconSecondary, + size: IconSize.input, + ), + ) + : null, + suffixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, ), - borderSide: BorderSide( - color: context.conduitTheme.buttonPrimary, - width: 1, + filled: true, + fillColor: context.conduitTheme.inputBackground, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + borderSide: BorderSide( + color: context.conduitTheme.buttonPrimary, + width: 1, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.md, ), ), - onChanged: _filterModels, ), - ), - // Removed capability filters - const SizedBox(height: Spacing.sm), + // Removed capability filters + const SizedBox(height: Spacing.sm), - // Models list - Expanded( - child: Scrollbar( - controller: scrollController, - child: _filteredModels.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.search_circle - : Icons.search_off, - size: 48, - color: context.conduitTheme.iconSecondary, - ), - const SizedBox(height: Spacing.md), - Text( - 'No results', - style: TextStyle( - color: context.conduitTheme.textSecondary, - fontSize: AppTypography.bodyLarge, + // Models list + Expanded( + child: Scrollbar( + controller: scrollController, + child: _filteredModels.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.search_circle + : Icons.search_off, + size: 48, + color: context.conduitTheme.iconSecondary, ), - ), - ], - ), - ) - : ListView.builder( - controller: scrollController, - padding: EdgeInsets.zero, - itemCount: _filteredModels.length, - itemBuilder: (context, index) { - final model = _filteredModels[index]; - final isSelected = - widget.ref - .watch(selectedModelProvider) - ?.id == - model.id; + const SizedBox(height: Spacing.md), + Text( + 'No results', + style: TextStyle( + color: + context.conduitTheme.textSecondary, + fontSize: AppTypography.bodyLarge, + ), + ), + ], + ), + ) + : ListView.builder( + controller: scrollController, + padding: EdgeInsets.zero, + itemCount: _filteredModels.length, + itemBuilder: (context, index) { + final model = _filteredModels[index]; + final isSelected = + widget.ref + .watch(selectedModelProvider) + ?.id == + model.id; - return _buildModelListTile( - model: model, - isSelected: isSelected, - onTap: () { - HapticFeedback.selectionClick(); - widget.ref - .read( - selectedModelProvider.notifier, - ) - .state = - model; - Navigator.pop(context); - }, - ); - }, - ), + return _buildModelListTile( + model: model, + isSelected: isSelected, + onTap: () { + HapticFeedback.selectionClick(); + widget.ref + .read( + selectedModelProvider.notifier, + ) + .state = + model; + Navigator.pop(context); + }, + ); + }, + ), + ), ), - ), - ], + ], + ), ), - ), - ), - ); - }, + ); + }, + ), + ], ); } diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 1a113d6..3272573 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:io' show Platform; import 'dart:async'; import 'dart:ui'; +import 'dart:math' as math; import '../providers/chat_providers.dart'; import '../../tools/providers/tools_providers.dart'; import '../../../core/models/tool.dart'; @@ -19,6 +20,7 @@ import '../../chat/services/voice_input_service.dart'; import '../../../shared/utils/platform_utils.dart'; import 'package:conduit/l10n/app_localizations.dart'; +import '../../../shared/widgets/modal_safe_area.dart'; class _SendMessageIntent extends Intent { const _SendMessageIntent(); @@ -1044,6 +1046,7 @@ class _ModernChatInputState extends ConsumerState showModalBottomSheet( context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (modalContext) => Consumer( builder: (innerContext, modalRef, _) { final l10n = AppLocalizations.of(innerContext)!; @@ -1188,34 +1191,87 @@ class _ModernChatInputState extends ConsumerState ..add(_buildSectionLabel(l10n.tools)) ..add(toolsSection); - return Container( - decoration: BoxDecoration( - color: theme.surfaceBackground, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(AppBorderRadius.bottomSheet), - ), - border: Border.all( - color: theme.dividerColor, - width: BorderWidth.thin, - ), - boxShadow: ConduitShadows.modal, - ), - child: SafeArea( - top: false, - bottom: true, - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - Spacing.modalPadding, - Spacing.sm, - Spacing.modalPadding, - Spacing.modalPadding, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: bodyChildren, - ), - ), - ), + // Measure content height and cap the sheet's max size to avoid extra blank space + final GlobalKey sheetContentKey = GlobalKey(); + double? measuredContentHeight; + + return StatefulBuilder( + builder: (context, setModalState) { + // Schedule a post-frame measurement of the content height + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = sheetContentKey.currentContext; + if (ctx != null) { + final renderObject = ctx.findRenderObject(); + if (renderObject is RenderBox) { + final double h = renderObject.size.height; + if (h > 0 && h != measuredContentHeight) { + measuredContentHeight = h; + setModalState(() {}); + } + } + } + }); + + final media = MediaQuery.of(modalContext); + final double availableHeight = + media.size.height - media.padding.top; + + double computedMax = 0.9; + if (measuredContentHeight != null && availableHeight > 0) { + computedMax = (measuredContentHeight! / availableHeight).clamp( + 0.1, + 0.9, + ); + } + final double computedMin = math.min(0.25, computedMax); + final double computedInitial = math.min(0.4, computedMax); + + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(modalContext).maybePop(), + child: const SizedBox.shrink(), + ), + ), + DraggableScrollableSheet( + expand: false, + initialChildSize: computedInitial, + minChildSize: computedMin, + maxChildSize: computedMax, + snap: true, + snapSizes: [computedMax], + builder: (sheetContext, scrollController) { + return Container( + decoration: BoxDecoration( + color: theme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.bottomSheet), + ), + border: Border.all( + color: theme.dividerColor, + width: BorderWidth.thin, + ), + boxShadow: ConduitShadows.modal, + ), + child: ModalSheetSafeArea( + child: SingleChildScrollView( + controller: scrollController, + padding: EdgeInsets.zero, + child: Column( + key: sheetContentKey, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: bodyChildren, + ), + ), + ), + ); + }, + ), + ], + ); + }, ); }, ), @@ -1308,7 +1364,7 @@ class _ModernChatInputState extends ConsumerState boxShadow: value ? ConduitShadows.low : const [], ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ _buildToolGlyph(icon: icon, selected: value, theme: theme), const SizedBox(width: Spacing.sm), @@ -1316,23 +1372,14 @@ class _ModernChatInputState extends ConsumerState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - title, - style: AppTypography.bodyLargeStyle.copyWith( - color: theme.textPrimary, - fontWeight: value - ? FontWeight.w600 - : FontWeight.w500, - ), - ), - ), - const SizedBox(width: Spacing.xs), - _buildTogglePill(isOn: value, theme: theme), - ], + Text( + title, + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textPrimary, + fontWeight: value ? FontWeight.w600 : FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), if (description.isNotEmpty) ...[ const SizedBox(height: Spacing.xs), @@ -1340,7 +1387,7 @@ class _ModernChatInputState extends ConsumerState description, maxLines: 2, overflow: TextOverflow.ellipsis, - style: AppTypography.bodySmallStyle.copyWith( + style: AppTypography.captionStyle.copyWith( color: theme.textSecondary.withValues( alpha: Alpha.strong, ), @@ -1350,6 +1397,8 @@ class _ModernChatInputState extends ConsumerState ], ), ), + const SizedBox(width: Spacing.sm), + _buildTogglePill(isOn: value, theme: theme), ], ), ), @@ -1402,7 +1451,7 @@ class _ModernChatInputState extends ConsumerState boxShadow: selected ? ConduitShadows.low : const [], ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ _buildToolGlyph( icon: _toolIconFor(tool), @@ -1414,23 +1463,16 @@ class _ModernChatInputState extends ConsumerState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - tool.name, - style: AppTypography.bodyLargeStyle.copyWith( - color: theme.textPrimary, - fontWeight: selected - ? FontWeight.w600 - : FontWeight.w500, - ), - ), - ), - const SizedBox(width: Spacing.xs), - _buildTogglePill(isOn: selected, theme: theme), - ], + Text( + tool.name, + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textPrimary, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), if (description.isNotEmpty) ...[ const SizedBox(height: Spacing.xs), @@ -1438,7 +1480,7 @@ class _ModernChatInputState extends ConsumerState description, maxLines: 2, overflow: TextOverflow.ellipsis, - style: AppTypography.bodySmallStyle.copyWith( + style: AppTypography.captionStyle.copyWith( color: theme.textSecondary.withValues( alpha: Alpha.strong, ), @@ -1448,6 +1490,8 @@ class _ModernChatInputState extends ConsumerState ], ), ), + const SizedBox(width: Spacing.sm), + _buildTogglePill(isOn: selected, theme: theme), ], ), ), diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index fa6c63c..646b0b4 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/providers/app_providers.dart'; import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/modal_safe_area.dart'; import '../../chat/providers/chat_providers.dart' as chat; // import '../../files/views/files_page.dart'; import '../../profile/views/profile_page.dart'; @@ -798,6 +799,9 @@ class _ChatsDrawerState extends ConsumerState { String folderName, ) { final theme = context.conduitTheme; + // Ensure consistent modal padding/insets across the app + // ignore: unnecessary_import + showModalBottomSheet( context: context, backgroundColor: theme.surfaceBackground, @@ -807,7 +811,11 @@ class _ChatsDrawerState extends ConsumerState { ), ), builder: (sheetContext) { - return SafeArea( + return ModalSheetSafeArea( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.modalPadding, + vertical: Spacing.modalPadding, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1329,7 +1337,11 @@ class _ChatsDrawerState extends ConsumerState { ), ), builder: (sheetContext) { - return SafeArea( + return ModalSheetSafeArea( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.modalPadding, + vertical: Spacing.modalPadding, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index e9063ad..355571b 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -22,6 +22,7 @@ import 'dart:async'; import 'dart:io'; import '../../chat/views/chat_page_helpers.dart'; import 'app_customization_page.dart'; +import '../../../shared/widgets/modal_safe_area.dart'; /// Profile page (You tab) showing user info and main actions /// Enhanced with production-grade design tokens for better cohesion @@ -236,7 +237,9 @@ class ProfilePage extends ConsumerWidget { android: Icons.tune, ), title: AppLocalizations.of(context)!.appCustomization, - subtitle: AppLocalizations.of(context)!.appCustomizationSubtitle, + subtitle: AppLocalizations.of( + context, + )!.appCustomizationSubtitle, onTap: () { Navigator.of(context).push( MaterialPageRoute( @@ -334,7 +337,10 @@ class ProfilePage extends ConsumerWidget { (m) => m.id == settings.defaultModel, orElse: () => models.isNotEmpty ? models.first - : Model(id: 'none', name: AppLocalizations.of(context)!.noModelsAvailable), + : Model( + id: 'none', + name: AppLocalizations.of(context)!.noModelsAvailable, + ), ); return ListTile( @@ -498,7 +504,9 @@ class ProfilePage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - AppLocalizations.of(ctx)!.versionLabel(info.version, info.buildNumber), + AppLocalizations.of( + ctx, + )!.versionLabel(info.version, info.buildNumber), style: ctx.conduitTheme.bodyMedium?.copyWith( color: ctx.conduitTheme.textSecondary, ), @@ -544,7 +552,10 @@ class ProfilePage extends ConsumerWidget { ); } catch (e) { if (!context.mounted) return; - UiUtils.showMessage(context, AppLocalizations.of(context)!.unableToLoadAppInfo); + UiUtils.showMessage( + context, + AppLocalizations.of(context)!.unableToLoadAppInfo, + ); } } @@ -647,10 +658,7 @@ class _DefaultModelBottomSheetState // If no default model is set (null), default to auto-select _selectedModelId = widget.currentDefaultModelId ?? 'auto-select'; // Add auto-select as first item - _filteredModels = [ - const Model(id: 'auto-select', name: 'Auto-select'), - ...widget.models, - ]; + _filteredModels = _allModels(); } @override @@ -660,215 +668,264 @@ class _DefaultModelBottomSheetState super.dispose(); } + List _allModels() { + return [ + const Model(id: 'auto-select', name: 'Auto-select'), + ...widget.models, + ]; + } + void _filterModels(String query) { + setState(() => _searchQuery = query); + _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 160), () { - setState(() { - _searchQuery = query.toLowerCase(); - List allModels = [ - const Model(id: 'auto-select', name: 'Auto-select'), - ...widget.models, - ]; + if (!mounted) return; - if (_searchQuery.isNotEmpty) { - _filteredModels = allModels.where((model) { - return model.name.toLowerCase().contains(_searchQuery) || - model.id.toLowerCase().contains(_searchQuery); - }).toList(); - } else { - _filteredModels = allModels; - } + final normalized = query.trim().toLowerCase(); + final allModels = _allModels(); + final filtered = normalized.isEmpty + ? allModels + : allModels.where((model) { + final name = model.name.toLowerCase(); + final id = model.id.toLowerCase(); + return name.contains(normalized) || id.contains(normalized); + }).toList(); + + setState(() { + _filteredModels = filtered; }); }); } @override Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.75, - maxChildSize: 0.92, - minChildSize: 0.45, - builder: (context, scrollController) { - return Container( - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(AppBorderRadius.bottomSheet), - ), - border: Border.all( - color: context.conduitTheme.dividerColor, - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.modal, + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).maybePop(), + child: const SizedBox.shrink(), ), - child: SafeArea( - top: false, - bottom: true, - child: Padding( - padding: const EdgeInsets.all(Spacing.bottomSheetPadding), - child: Column( - children: [ - // Handle bar (standardized) - const SheetHandle(), + ), + DraggableScrollableSheet( + expand: false, + initialChildSize: 0.75, + maxChildSize: 0.92, + minChildSize: 0.45, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.bottomSheet), + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.modal, + ), + child: ModalSheetSafeArea( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.modalPadding, + vertical: Spacing.modalPadding, + ), + child: Column( + children: [ + // Handle bar (standardized) + const SheetHandle(), - // Header removed (no icon/title or save button) - const SizedBox(height: Spacing.md), + // Header removed (no icon/title or save button) + const SizedBox(height: Spacing.md), - // Search field - Padding( - padding: const EdgeInsets.only(bottom: Spacing.md), - child: TextField( - controller: _searchController, - style: TextStyle(color: context.conduitTheme.textPrimary), - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.searchModels, - hintStyle: TextStyle( - color: context.conduitTheme.inputPlaceholder, + // Search field + Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: TextField( + controller: _searchController, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textPrimary, ), - prefixIcon: Icon( - Platform.isIOS ? CupertinoIcons.search : Icons.search, - color: context.conduitTheme.iconSecondary, - ), - filled: true, - fillColor: context.conduitTheme.inputBackground, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.md, + onChanged: _filterModels, + decoration: InputDecoration( + isDense: true, + hintText: AppLocalizations.of(context)!.searchModels, + hintStyle: AppTypography.standard.copyWith( + color: context.conduitTheme.inputPlaceholder, ), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.md, + prefixIcon: Icon( + Platform.isIOS + ? CupertinoIcons.search + : Icons.search, + color: context.conduitTheme.iconSecondary, + size: IconSize.input, ), - borderSide: BorderSide( - color: context.conduitTheme.inputBorder, - width: 1, + prefixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.md, + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + onPressed: () { + _searchController.clear(); + _filterModels(''); + }, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.clear_circled_solid + : Icons.clear, + color: context.conduitTheme.iconSecondary, + size: IconSize.input, + ), + ) + : null, + suffixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, ), - borderSide: BorderSide( - color: context.conduitTheme.buttonPrimary, - width: 1, + filled: true, + fillColor: context.conduitTheme.inputBackground, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + borderSide: BorderSide( + color: context.conduitTheme.buttonPrimary, + width: 1, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.md, ), ), - onChanged: _filterModels, ), - ), - // Section header (cohesive with Chats Drawer) - Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: Row( - children: [ - Text( - AppLocalizations.of(context)!.availableModels, - style: AppTypography.bodySmallStyle.copyWith( - fontWeight: FontWeight.w600, - color: context.conduitTheme.textSecondary, - letterSpacing: 0.2, - ), - ), - const SizedBox(width: Spacing.xs), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground - .withValues(alpha: 0.6), - borderRadius: BorderRadius.circular( - AppBorderRadius.xs, - ), - border: Border.all( - color: context.conduitTheme.dividerColor, - width: BorderWidth.thin, - ), - ), - child: Text( - '${_filteredModels.length}', + // Section header (cohesive with Chats Drawer) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Row( + children: [ + Text( + AppLocalizations.of(context)!.availableModels, style: AppTypography.bodySmallStyle.copyWith( + fontWeight: FontWeight.w600, color: context.conduitTheme.textSecondary, + letterSpacing: 0.2, ), ), - ), - ], - ), - ), - - const SizedBox(height: Spacing.sm), - - // Models list - Expanded( - child: Scrollbar( - controller: scrollController, - child: _filteredModels.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.search_circle - : Icons.search_off, - size: 48, - color: context.conduitTheme.iconSecondary, - ), - const SizedBox(height: Spacing.md), - Text( - AppLocalizations.of(context)!.noResults, - style: TextStyle( - color: context.conduitTheme.textSecondary, - fontSize: AppTypography.bodyLarge, - ), - ), - ], - ), - ) - : ListView.builder( - controller: scrollController, - padding: EdgeInsets.zero, - itemCount: _filteredModels.length, - itemBuilder: (context, index) { - final model = _filteredModels[index]; - final isAutoSelect = model.id == 'auto-select'; - final isSelected = isAutoSelect - ? _selectedModelId == null || - _selectedModelId == 'auto-select' - : _selectedModelId == model.id; - - return _buildModelListTile( - model: model, - isSelected: isSelected, - isAutoSelect: isAutoSelect, - onTap: () { - HapticFeedback.lightImpact(); - final selectedId = isAutoSelect - ? 'auto-select' - : model.id; - // Return selection immediately; caller handles persisting - Navigator.pop(context, selectedId); - }, - ); - }, + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground + .withValues(alpha: 0.6), + borderRadius: BorderRadius.circular( + AppBorderRadius.xs, + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: Text( + '${_filteredModels.length}', + style: AppTypography.bodySmallStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ), + ], + ), ), - ), - ], + + const SizedBox(height: Spacing.sm), + + // Models list + Expanded( + child: Scrollbar( + controller: scrollController, + child: _filteredModels.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.search_circle + : Icons.search_off, + size: 48, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(height: Spacing.md), + Text( + AppLocalizations.of(context)!.noResults, + style: TextStyle( + color: + context.conduitTheme.textSecondary, + fontSize: AppTypography.bodyLarge, + ), + ), + ], + ), + ) + : ListView.builder( + controller: scrollController, + padding: EdgeInsets.zero, + itemCount: _filteredModels.length, + itemBuilder: (context, index) { + final model = _filteredModels[index]; + final isAutoSelect = + model.id == 'auto-select'; + final isSelected = isAutoSelect + ? _selectedModelId == null || + _selectedModelId == 'auto-select' + : _selectedModelId == model.id; + + return _buildModelListTile( + model: model, + isSelected: isSelected, + isAutoSelect: isAutoSelect, + onTap: () { + HapticFeedback.lightImpact(); + final selectedId = isAutoSelect + ? 'auto-select' + : model.id; + Navigator.pop(context, selectedId); + }, + ); + }, + ), + ), + ), + ], + ), ), - ), - ), - ); - }, + ); + }, + ), + ], ); } diff --git a/lib/shared/widgets/modal_safe_area.dart b/lib/shared/widgets/modal_safe_area.dart new file mode 100644 index 0000000..4223d1b --- /dev/null +++ b/lib/shared/widgets/modal_safe_area.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; + +import '../theme/theme_extensions.dart'; + +/// Consistent safe area wrapper for modal sheets presented across the app. +/// +/// All modal-bottom sheets should rely on this widget to guarantee that +/// system insets (e.g. gesture areas or dynamic island) are respected while +/// maintaining the same padding rhythm used by the attachments sheet. +class ModalSheetSafeArea extends StatelessWidget { + const ModalSheetSafeArea({super.key, required this.child, this.padding}); + + /// Content rendered inside the safe area. + final Widget child; + + /// Optional custom padding that wraps the [child]. When omitted the default + /// modal spacing used by attachments/chat input is applied. + final EdgeInsets? padding; + + @override + Widget build(BuildContext context) { + final resolvedPadding = + padding ?? + const EdgeInsets.fromLTRB( + Spacing.modalPadding, + Spacing.sm, + Spacing.modalPadding, + Spacing.modalPadding, + ); + + return SafeArea( + top: false, + bottom: true, + child: Padding(padding: resolvedPadding, child: child), + ); + } +}