From 4a1784cf070ff204d778ee160e3630b20486c13b Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:03:29 +0530 Subject: [PATCH 1/5] feat(note_editor): Refactor note editor UI with improved layout and styling --- .../notes/views/note_editor_page.dart | 788 +++++++++++------- lib/features/notes/views/notes_list_page.dart | 407 ++++++--- .../profile/views/app_customization_page.dart | 229 +++-- lib/features/profile/views/profile_page.dart | 212 +++-- 4 files changed, 1115 insertions(+), 521 deletions(-) diff --git a/lib/features/notes/views/note_editor_page.dart b/lib/features/notes/views/note_editor_page.dart index ebb1f3e..c3bbaeb 100644 --- a/lib/features/notes/views/note_editor_page.dart +++ b/lib/features/notes/views/note_editor_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -15,6 +16,7 @@ import '../../../core/widgets/error_boundary.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/utils/ui_utils.dart'; import '../../../shared/widgets/improved_loading_states.dart'; +import '../../../shared/widgets/middle_ellipsis_text.dart'; import '../../../shared/widgets/themed_dialogs.dart'; import '../../chat/services/voice_input_service.dart'; import '../providers/notes_providers.dart'; @@ -492,204 +494,392 @@ class _NoteEditorPageState extends ConsumerState { }, child: ErrorBoundary( child: Scaffold( - backgroundColor: sidebarTheme.background, - body: SafeArea( - child: Stack( - children: [ - Column( - children: [ - _buildHeader(context), - if (!_isLoading && _note != null) - _buildMetadataBar(context), - Expanded(child: _buildBody(context)), - ], + backgroundColor: context.conduitTheme.surfaceBackground, + extendBodyBehindAppBar: true, + appBar: _buildAppBar(context), + body: Stack( + children: [ + // Main content - scrolls behind floating elements + Positioned.fill( + child: _buildMainContent(context), + ), + // Floating action buttons + if (!_isLoading && _note != null) + Positioned( + left: Spacing.md, + right: Spacing.md, + bottom: Spacing.md + MediaQuery.of(context).padding.bottom, + child: _buildFloatingActionsRow(context), ), - // Floating action buttons - if (!_isLoading && _note != null) - _buildFloatingActions(context), - ], - ), + ], ), ), ), ); } - Widget _buildHeader(BuildContext context) { - final theme = context.conduitTheme; - final sidebarTheme = context.sidebarTheme; + PreferredSizeWidget _buildAppBar(BuildContext context) { + final theme = Theme.of(context); + final conduitTheme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; - return Container( - padding: const EdgeInsets.fromLTRB( - Spacing.xs, - Spacing.sm, - Spacing.sm, - Spacing.xs, - ), - color: sidebarTheme.background, - child: Row( - children: [ - // Back button - IconButton( - icon: Icon( - UiUtils.platformIcon( - ios: CupertinoIcons.back, - android: Icons.arrow_back_rounded, - ), - color: theme.iconPrimary, - ), - onPressed: () async { - final navigator = Navigator.of(context); - await _onWillPop(); - if (!mounted) return; - navigator.pop(); - }, - tooltip: l10n.back, - ), - const SizedBox(width: Spacing.xs), - - // Title input - Expanded( - child: TextField( - controller: _titleController, - focusNode: _titleFocusNode, - enabled: !_isGeneratingTitle, - style: AppTypography.headlineSmallStyle.copyWith( - color: theme.textPrimary, - fontWeight: FontWeight.w600, - ), - decoration: InputDecoration( - hintText: _isGeneratingTitle - ? l10n.generatingTitle - : l10n.noteTitle, - hintStyle: AppTypography.headlineSmallStyle.copyWith( - color: theme.textSecondary.withValues(alpha: 0.4), - fontWeight: FontWeight.w600, - ), - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - isDense: true, - ), - textCapitalization: TextCapitalization.sentences, - textInputAction: TextInputAction.next, - onSubmitted: (_) => _contentFocusNode.requestFocus(), - ), - ), - - // Generate title button - aligned with other header icons - AnimatedOpacity( - opacity: _titleFocusNode.hasFocus && !_isGeneratingTitle - ? 1.0 - : 0.0, - duration: const Duration(milliseconds: 150), - child: IgnorePointer( - ignoring: !_titleFocusNode.hasFocus || _isGeneratingTitle, - child: IconButton( - icon: Icon( - Platform.isIOS - ? CupertinoIcons.sparkles - : Icons.auto_awesome_rounded, - color: theme.buttonPrimary, - ), - onPressed: _generateTitle, - tooltip: l10n.generateTitle, - ), - ), - ), - - // Save indicator - if (_isSaving) - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), - child: SizedBox( - width: IconSize.sm, - height: IconSize.sm, - child: CircularProgressIndicator( - strokeWidth: BorderWidth.medium, - valueColor: AlwaysStoppedAnimation(theme.loadingIndicator), - ), - ), - ) - else if (_hasChanges) - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: theme.warning, - shape: BoxShape.circle, - ), - ), - ), - - // Menu - PopupMenuButton( - icon: Icon( - Platform.isIOS - ? CupertinoIcons.ellipsis - : Icons.more_vert_rounded, - color: theme.iconPrimary, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - ), - onSelected: (value) { - switch (value) { - case 'copy': - _copyToClipboard(); - case 'delete': - _deleteNote(); - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'copy', - child: Row( - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.doc_on_clipboard - : Icons.copy_rounded, - color: theme.iconPrimary, - size: IconSize.md, - ), - const SizedBox(width: Spacing.sm), - Text(l10n.copy), - ], - ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.delete - : Icons.delete_rounded, - color: theme.error, - size: IconSize.md, - ), - const SizedBox(width: Spacing.sm), - Text(l10n.delete, style: TextStyle(color: theme.error)), - ], - ), - ), + return PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight + 40), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.4, 1.0], + colors: [ + theme.scaffoldBackgroundColor, + theme.scaffoldBackgroundColor.withValues(alpha: 0.85), + theme.scaffoldBackgroundColor.withValues(alpha: 0.0), ], ), - ], + ), + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // App bar row with back button, title, and menu + SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + // Leading (back button) + Padding( + padding: const EdgeInsets.only(left: Spacing.inputPadding), + child: Center( + child: GestureDetector( + onTap: () async { + final navigator = Navigator.of(context); + await _onWillPop(); + if (!mounted) return; + navigator.pop(); + }, + child: _buildAppBarPill( + context, + Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.back, + android: Icons.arrow_back, + ), + color: conduitTheme.textPrimary, + size: IconSize.appBar, + ), + isCircular: true, + ), + ), + ), + ), + // Title centered + Expanded( + child: Center( + child: _buildAppBarPill( + context, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: _isGeneratingTitle + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: IconSize.sm, + height: IconSize.sm, + child: CircularProgressIndicator( + strokeWidth: BorderWidth.medium, + valueColor: AlwaysStoppedAnimation( + conduitTheme.loadingIndicator, + ), + ), + ), + const SizedBox(width: Spacing.sm), + Text( + l10n.generatingTitle, + style: AppTypography.bodyMediumStyle.copyWith( + color: conduitTheme.textSecondary, + ), + ), + ], + ) + : ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.5, + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Hidden TextField always in tree for focus + Opacity( + opacity: _titleFocusNode.hasFocus ? 1.0 : 0.0, + child: IntrinsicWidth( + child: TextField( + controller: _titleController, + focusNode: _titleFocusNode, + enabled: !_isGeneratingTitle, + style: AppTypography.headlineSmallStyle + .copyWith( + color: conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + decoration: InputDecoration( + hintText: l10n.untitled, + hintStyle: AppTypography.headlineSmallStyle + .copyWith( + color: conduitTheme.textSecondary + .withValues(alpha: 0.6), + fontWeight: FontWeight.w600, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + textAlign: TextAlign.center, + textCapitalization: + TextCapitalization.sentences, + textInputAction: TextInputAction.done, + onSubmitted: (_) => + _contentFocusNode.requestFocus(), + ), + ), + ), + // Visible text when not focused + if (!_titleFocusNode.hasFocus) + GestureDetector( + onTap: () => _titleFocusNode.requestFocus(), + child: MiddleEllipsisText( + _titleController.text.isEmpty + ? l10n.untitled + : _titleController.text, + style: AppTypography.headlineSmallStyle + .copyWith( + color: _titleController.text.isEmpty + ? conduitTheme.textSecondary + .withValues(alpha: 0.6) + : conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + if (_hasChanges && !_isSaving) + Padding( + padding: const EdgeInsets.only(left: Spacing.sm), + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: conduitTheme.warning, + shape: BoxShape.circle, + ), + ), + ), + if (_isSaving) + Padding( + padding: const EdgeInsets.only(left: Spacing.sm), + child: SizedBox( + width: IconSize.sm, + height: IconSize.sm, + child: CircularProgressIndicator( + strokeWidth: BorderWidth.medium, + valueColor: AlwaysStoppedAnimation( + conduitTheme.loadingIndicator, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + // Actions (more menu) + Padding( + padding: const EdgeInsets.only(right: Spacing.inputPadding), + child: Center( + child: PopupMenuButton( + tooltip: '', + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + onSelected: (value) { + switch (value) { + case 'generate_title': + _generateTitle(); + case 'copy': + _copyToClipboard(); + case 'delete': + _deleteNote(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'generate_title', + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.sparkles + : Icons.auto_awesome_rounded, + color: conduitTheme.buttonPrimary, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Text(l10n.generateTitle), + ], + ), + ), + PopupMenuItem( + value: 'copy', + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.doc_on_clipboard + : Icons.copy_rounded, + color: conduitTheme.iconPrimary, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Text(l10n.copy), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.delete + : Icons.delete_rounded, + color: conduitTheme.error, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Text( + l10n.delete, + style: TextStyle(color: conduitTheme.error), + ), + ], + ), + ), + ], + child: _buildAppBarPill( + context, + Icon( + Platform.isIOS + ? CupertinoIcons.ellipsis + : Icons.more_vert_rounded, + color: conduitTheme.textPrimary, + size: IconSize.appBar, + ), + isCircular: true, + ), + ), + ), + ), + ], + ), + ), + // Metadata stats row + if (!_isLoading && _note != null) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: _buildFloatingMetadataBar(context), + ), + ], + ), + ), ), ); } - Widget _buildMetadataBar(BuildContext context) { - final theme = context.conduitTheme; + Widget _buildAppBarPill( + BuildContext context, + Widget child, { + bool isCircular = false, + }) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; + + final borderColor = context.conduitTheme.cardBorder.withValues( + alpha: isDark ? 0.65 : 0.55, + ); + + final borderRadius = isCircular + ? BorderRadius.circular(100) + : BorderRadius.circular(AppBorderRadius.pill); + + if (isCircular) { + return SizedBox( + width: 44, + height: 44, + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: Center(child: child), + ), + ), + ), + ); + } + + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: child, + ), + ), + ); + } + + Widget _buildFloatingMetadataBar(BuildContext context) { + final theme = Theme.of(context); + final conduitTheme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; + final isDark = theme.brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!; + + final borderColor = conduitTheme.cardBorder.withValues( + alpha: isDark ? 0.65 : 0.55, + ); final dateFormat = DateFormat.MMMd(); final timeFormat = DateFormat.jm(); @@ -697,42 +887,51 @@ class _NoteEditorPageState extends ConsumerState { ? '${dateFormat.format(_note!.createdDateTime)} ${timeFormat.format(_note!.createdDateTime)}' : ''; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.xs, - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - // Created date - _buildMetadataChip( - context, - icon: Platform.isIOS - ? CupertinoIcons.calendar - : Icons.calendar_today_rounded, - label: createdDate, - ), - _buildMetadataSeparator(theme), - // Word count - _buildMetadataChip( - context, - icon: Platform.isIOS - ? CupertinoIcons.doc_text - : Icons.article_rounded, - label: l10n.wordCount(_wordCount), - ), - _buildMetadataSeparator(theme), - // Character count - _buildMetadataChip( - context, - icon: Platform.isIOS - ? CupertinoIcons.textformat_abc - : Icons.text_fields_rounded, - label: l10n.charCount(_charCount), - ), - ], + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Created date + _buildMetadataChip( + context, + icon: Platform.isIOS + ? CupertinoIcons.calendar + : Icons.calendar_today_rounded, + label: createdDate, + ), + _buildMetadataSeparator(conduitTheme), + // Word count + _buildMetadataChip( + context, + icon: Platform.isIOS + ? CupertinoIcons.doc_text + : Icons.article_rounded, + label: l10n.wordCount(_wordCount), + ), + _buildMetadataSeparator(conduitTheme), + // Character count + _buildMetadataChip( + context, + icon: Platform.isIOS + ? CupertinoIcons.textformat_abc + : Icons.text_fields_rounded, + label: l10n.charCount(_charCount), + ), + ], + ), ), ), ); @@ -783,6 +982,10 @@ class _NoteEditorPageState extends ConsumerState { ); } + Widget _buildMainContent(BuildContext context) { + return _buildBody(context); + } + Widget _buildBody(BuildContext context) { if (_isLoading) { return Center( @@ -796,21 +999,25 @@ class _NoteEditorPageState extends ConsumerState { return _buildNotFoundState(context); } + // Title is now edited in the app bar pill, so just show the content editor return _buildEditor(context); } Widget _buildEditor(BuildContext context) { final theme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; + final topPadding = MediaQuery.of(context).padding.top; + // App bar height: kToolbarHeight + metadata bar (~40) + final appBarHeight = kToolbarHeight + 40; return GestureDetector( onTap: () => _contentFocusNode.requestFocus(), behavior: HitTestBehavior.opaque, child: SingleChildScrollView( controller: _scrollController, - padding: const EdgeInsets.fromLTRB( + padding: EdgeInsets.fromLTRB( Spacing.inputPadding, - Spacing.md, + topPadding + appBarHeight + Spacing.sm, // Space for floating app bar Spacing.inputPadding, 120, // Extra padding for floating buttons ), @@ -843,46 +1050,39 @@ class _NoteEditorPageState extends ConsumerState { ); } - Widget _buildFloatingActions(BuildContext context) { + Widget _buildFloatingActionsRow(BuildContext context) { final theme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; - return Positioned( - left: Spacing.md, - right: Spacing.md, - bottom: Spacing.md, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Dictation button - _buildFloatingButton( - context, - icon: _isRecording - ? (Platform.isIOS - ? CupertinoIcons.stop_fill - : Icons.stop_rounded) - : (Platform.isIOS - ? CupertinoIcons.mic_fill - : Icons.mic_rounded), - color: _isRecording ? theme.error : null, - isLoading: false, - tooltip: _isRecording ? l10n.stopRecording : l10n.startDictation, - onPressed: _toggleDictation, - ), + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Dictation button + _buildFloatingButton( + context, + icon: _isRecording + ? (Platform.isIOS + ? CupertinoIcons.stop_fill + : Icons.stop_rounded) + : (Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic_rounded), + color: _isRecording ? theme.error : null, + isLoading: false, + tooltip: _isRecording ? l10n.stopRecording : l10n.startDictation, + onPressed: _toggleDictation, + ), - // AI button - _buildFloatingButton( - context, - icon: Platform.isIOS - ? CupertinoIcons.sparkles - : Icons.auto_awesome_rounded, - isLoading: _isEnhancing, - tooltip: l10n.enhanceWithAI, - onPressed: _isEnhancing ? null : _enhanceContent, - showMenu: true, - ), - ], - ), + // AI button + _buildFloatingButton( + context, + icon: Platform.isIOS + ? CupertinoIcons.sparkles + : Icons.auto_awesome_rounded, + isLoading: _isEnhancing, + tooltip: l10n.enhanceWithAI, + onPressed: _isEnhancing ? null : _enhanceContent, + showMenu: true, + ), + ], ); } @@ -895,34 +1095,52 @@ class _NoteEditorPageState extends ConsumerState { Color? color, bool showMenu = false, }) { - final theme = context.conduitTheme; - final sidebarTheme = context.sidebarTheme; + final theme = Theme.of(context); + final conduitTheme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; + final isDark = theme.brightness == Brightness.dark; - final buttonChild = Container( - width: 52, - height: 52, - decoration: BoxDecoration( - color: theme.surfaceContainer, - shape: BoxShape.circle, - border: Border.all( - color: sidebarTheme.border.withValues(alpha: 0.2), - width: BorderWidth.thin, - ), - boxShadow: ConduitShadows.medium(context), - ), - child: isLoading - ? Center( - child: SizedBox( - width: IconSize.md, - height: IconSize.md, - child: CircularProgressIndicator( - strokeWidth: BorderWidth.medium, - valueColor: AlwaysStoppedAnimation(theme.loadingIndicator), + final backgroundColor = isDark + ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! + .withValues(alpha: 0.85) + : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)! + .withValues(alpha: 0.85); + + final borderColor = conduitTheme.cardBorder.withValues(alpha: 0.55); + + final buttonChild = ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.floatingButton), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + width: TouchTarget.button, + height: TouchTarget.button, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(AppBorderRadius.floatingButton), + border: Border.all(color: borderColor, width: BorderWidth.thin), + boxShadow: ConduitShadows.button(context), + ), + child: isLoading + ? Center( + child: SizedBox( + width: IconSize.md, + height: IconSize.md, + child: CircularProgressIndicator( + strokeWidth: BorderWidth.medium, + valueColor: + AlwaysStoppedAnimation(conduitTheme.loadingIndicator), + ), + ), + ) + : Icon( + icon, + color: color ?? + conduitTheme.iconPrimary.withValues(alpha: 0.9), + size: IconSize.lg, ), - ), - ) - : Icon(icon, color: color ?? theme.iconPrimary, size: IconSize.lg), + ), + ), ); if (showMenu) { @@ -949,7 +1167,7 @@ class _NoteEditorPageState extends ConsumerState { Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_fix_high_rounded, - color: theme.buttonPrimary, + color: conduitTheme.buttonPrimary, size: IconSize.md, ), const SizedBox(width: Spacing.sm), @@ -965,7 +1183,7 @@ class _NoteEditorPageState extends ConsumerState { Platform.isIOS ? CupertinoIcons.textformat : Icons.title_rounded, - color: theme.buttonPrimary, + color: conduitTheme.buttonPrimary, size: IconSize.md, ), const SizedBox(width: Spacing.sm), @@ -984,7 +1202,9 @@ class _NoteEditorPageState extends ConsumerState { color: Colors.transparent, child: InkWell( onTap: onPressed, - customBorder: const CircleBorder(), + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.floatingButton), + ), child: buttonChild, ), ), diff --git a/lib/features/notes/views/notes_list_page.dart b/lib/features/notes/views/notes_list_page.dart index c8cb5f8..97d734e 100644 --- a/lib/features/notes/views/notes_list_page.dart +++ b/lib/features/notes/views/notes_list_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -122,151 +123,273 @@ class _NotesListPageState extends ConsumerState { return Scaffold(backgroundColor: sidebarTheme.background); } + final canPop = ModalRoute.of(context)?.canPop ?? false; + final l10n = AppLocalizations.of(context)!; + return ErrorBoundary( child: Scaffold( - backgroundColor: sidebarTheme.background, - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(context), - _buildSearchField(context), - Expanded(child: _buildBody(context)), - ], + backgroundColor: context.conduitTheme.surfaceBackground, + extendBodyBehindAppBar: true, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight + 64), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.4, 1.0], + colors: [ + Theme.of(context).scaffoldBackgroundColor, + Theme.of(context).scaffoldBackgroundColor.withValues( + alpha: 0.85, + ), + Theme.of(context).scaffoldBackgroundColor.withValues( + alpha: 0.0, + ), + ], + ), + ), + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // App bar row with back button and title + SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + // Leading (back button) + if (canPop) + Padding( + padding: const EdgeInsets.only( + left: Spacing.inputPadding, + ), + child: Center( + child: GestureDetector( + onTap: () => Navigator.of(context).maybePop(), + child: _buildAppBarPill( + context, + Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.back, + android: Icons.arrow_back, + ), + color: context.conduitTheme.textPrimary, + size: IconSize.appBar, + ), + isCircular: true, + ), + ), + ), + ) + else + const SizedBox(width: Spacing.inputPadding), + // Title centered + Expanded( + child: Center( + child: _buildAppBarPill( + context, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.doc_text_fill + : Icons.notes_rounded, + color: context.conduitTheme.textPrimary + .withValues(alpha: 0.7), + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Text( + l10n.notes, + style: + AppTypography.headlineSmallStyle + .copyWith( + color: context + .conduitTheme + .textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + // Trailing spacer to balance + if (canPop) + const SizedBox( + width: 44 + Spacing.inputPadding, + ) + else + const SizedBox(width: Spacing.inputPadding), + ], + ), + ), + // Search bar directly below title + Padding( + padding: const EdgeInsets.fromLTRB( + Spacing.inputPadding, + Spacing.xs, + Spacing.inputPadding, + Spacing.sm, + ), + child: _buildFloatingSearchField(context), + ), + ], + ), + ), ), ), + body: _buildBody(context), floatingActionButton: _buildFAB(context), ), ); } - Widget _buildHeader(BuildContext context) { - final sidebarTheme = context.sidebarTheme; - final l10n = AppLocalizations.of(context)!; - final canPop = ModalRoute.of(context)?.canPop ?? false; + Widget _buildAppBarPill( + BuildContext context, + Widget child, { + bool isCircular = false, + }) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; - return Container( - padding: EdgeInsets.fromLTRB( - canPop ? Spacing.xs : Spacing.inputPadding, - Spacing.md, - Spacing.inputPadding, - Spacing.sm, - ), - child: Row( - children: [ - if (canPop) ...[ - IconButton( - icon: Icon( - UiUtils.platformIcon( - ios: CupertinoIcons.back, - android: Icons.arrow_back, - ), - color: sidebarTheme.foreground.withValues(alpha: 0.8), - ), - onPressed: () => Navigator.of(context).maybePop(), - tooltip: l10n.back, - ), - const SizedBox(width: Spacing.xs), - ], - Icon( - Platform.isIOS ? CupertinoIcons.doc_text_fill : Icons.notes_rounded, - color: sidebarTheme.foreground.withValues(alpha: 0.7), - size: IconSize.lg, - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text( - l10n.notes, - style: AppTypography.headlineSmallStyle.copyWith( - color: sidebarTheme.foreground, - fontWeight: FontWeight.w700, + final backgroundColor = isDark + ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; + + final borderColor = context.conduitTheme.cardBorder.withValues( + alpha: isDark ? 0.65 : 0.55, + ); + + final borderRadius = isCircular + ? BorderRadius.circular(100) + : BorderRadius.circular(AppBorderRadius.pill); + + if (isCircular) { + return SizedBox( + width: 44, + height: 44, + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), ), + child: Center(child: child), ), ), - ], + ), + ); + } + + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: child, + ), ), ); } - Widget _buildSearchField(BuildContext context) { - final sidebarTheme = context.sidebarTheme; + Widget _buildFloatingSearchField(BuildContext context) { + final theme = Theme.of(context); + final conduitTheme = context.conduitTheme; + final isDark = theme.brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; - return Padding( - padding: const EdgeInsets.fromLTRB( - Spacing.inputPadding, - Spacing.xs, - Spacing.inputPadding, - Spacing.sm, - ), - child: Material( - color: Colors.transparent, - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - onChanged: (_) => _onSearchChanged(), - style: AppTypography.standard.copyWith( - color: sidebarTheme.foreground, + final backgroundColor = isDark + ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!; + + final borderColor = conduitTheme.cardBorder.withValues( + alpha: isDark ? 0.65 : 0.55, + ); + + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + border: Border.all(color: borderColor, width: BorderWidth.thin), ), - decoration: InputDecoration( - isDense: true, - hintText: l10n.searchNotes, - hintStyle: AppTypography.standard.copyWith( - color: sidebarTheme.foreground.withValues(alpha: 0.5), - ), - prefixIcon: Icon( - Platform.isIOS ? CupertinoIcons.search : Icons.search_rounded, - color: sidebarTheme.foreground.withValues(alpha: 0.6), - 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_rounded, - color: sidebarTheme.foreground.withValues(alpha: 0.6), - size: IconSize.input, - ), - ) - : null, - suffixIconConstraints: const BoxConstraints( - minWidth: TouchTarget.minimum, - minHeight: TouchTarget.minimum, - ), - filled: true, - fillColor: sidebarTheme.accent.withValues(alpha: 0.85), - 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.2), - width: BorderWidth.thin, + child: Material( + color: Colors.transparent, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (_) => _onSearchChanged(), + style: AppTypography.standard.copyWith( + color: conduitTheme.textPrimary, ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - borderSide: BorderSide( - color: sidebarTheme.ring.withValues(alpha: 0.5), - width: BorderWidth.regular, + decoration: InputDecoration( + isDense: true, + hintText: l10n.searchNotes, + hintStyle: AppTypography.standard.copyWith( + color: conduitTheme.textSecondary.withValues(alpha: 0.6), + ), + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search, + color: conduitTheme.iconSecondary, + 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: conduitTheme.iconSecondary, + size: IconSize.input, + ), + ) + : null, + suffixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, + ), + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), ), ), - contentPadding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), ), ), ), @@ -322,7 +445,7 @@ class _NotesListPageState extends ConsumerState { ); slivers.add( SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) => @@ -347,12 +470,23 @@ class _NotesListPageState extends ConsumerState { } Widget _buildRefreshableScrollView(List slivers) { + // Add top padding for floating app bar and search bar + final topPadding = MediaQuery.of(context).padding.top; + // App bar height: kToolbarHeight + search bar (48) + padding (xs + sm) + final appBarHeight = kToolbarHeight + 48 + Spacing.xs + Spacing.sm; + final paddedSlivers = [ + SliverToBoxAdapter( + child: SizedBox(height: topPadding + appBarHeight), + ), + ...slivers, + ]; + return ConduitRefreshIndicator( onRefresh: _refreshNotes, child: CustomScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), - slivers: slivers, + slivers: paddedSlivers, ), ); } @@ -691,10 +825,17 @@ class _NotesListPageState extends ConsumerState { final sidebarTheme = context.sidebarTheme; final l10n = AppLocalizations.of(context)!; final isSearchActive = _query.isNotEmpty; + final topPadding = MediaQuery.of(context).padding.top; + final appBarHeight = kToolbarHeight + 48 + Spacing.xs + Spacing.sm; return Center( child: Padding( - padding: const EdgeInsets.all(Spacing.xxl), + padding: EdgeInsets.fromLTRB( + Spacing.xxl, + topPadding + appBarHeight, + Spacing.xxl, + Spacing.xxl, + ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -765,17 +906,29 @@ class _NotesListPageState extends ConsumerState { Widget _buildLoading(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Center(child: ImprovedLoadingState(message: l10n.loadingNotes)); + final topPadding = MediaQuery.of(context).padding.top; + final appBarHeight = kToolbarHeight + 48 + Spacing.xs + Spacing.sm; + return Padding( + padding: EdgeInsets.only(top: topPadding + appBarHeight), + child: Center(child: ImprovedLoadingState(message: l10n.loadingNotes)), + ); } Widget _buildError(BuildContext context, Object error) { final theme = context.conduitTheme; final sidebarTheme = context.sidebarTheme; final l10n = AppLocalizations.of(context)!; + final topPadding = MediaQuery.of(context).padding.top; + final appBarHeight = kToolbarHeight + 48 + Spacing.xs + Spacing.sm; return Center( child: Padding( - padding: const EdgeInsets.all(Spacing.xxl), + padding: EdgeInsets.fromLTRB( + Spacing.xxl, + topPadding + appBarHeight, + Spacing.xxl, + Spacing.xxl, + ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/lib/features/profile/views/app_customization_page.dart b/lib/features/profile/views/app_customization_page.dart index 10b52f4..fed4434 100644 --- a/lib/features/profile/views/app_customization_page.dart +++ b/lib/features/profile/views/app_customization_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -44,79 +45,185 @@ class AppCustomizationPage extends ConsumerWidget { final currentLanguageCode = locale?.toLanguageTag() ?? 'system'; final languageLabel = _resolveLanguageLabel(context, currentLanguageCode); final activeTheme = ref.watch(appThemePaletteProvider); + final canPop = ModalRoute.of(context)?.canPop ?? false; + final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight + 24; + + final theme = Theme.of(context); + final conduitTheme = context.conduitTheme; return Scaffold( - backgroundColor: context.sidebarTheme.background, - appBar: _buildAppBar(context), - body: SafeArea( - child: ListView( - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - padding: const EdgeInsets.symmetric( - horizontal: Spacing.pagePadding, - vertical: Spacing.pagePadding, - ), - children: [ - _buildThemesDropdownSection( - context, - ref, - themeMode, - themeDescription, - activeTheme, - settings, + backgroundColor: conduitTheme.surfaceBackground, + extendBodyBehindAppBar: true, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight + 8), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.4, 1.0], + colors: [ + theme.scaffoldBackgroundColor, + theme.scaffoldBackgroundColor.withValues(alpha: 0.85), + theme.scaffoldBackgroundColor.withValues(alpha: 0.0), + ], ), - const SizedBox(height: Spacing.md), - _buildLanguageSection( - context, - ref, - currentLanguageCode, - languageLabel, + ), + child: SafeArea( + bottom: false, + child: SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + // Leading (back button) + if (canPop) + Padding( + padding: const EdgeInsets.only(left: Spacing.inputPadding), + child: Center( + child: GestureDetector( + onTap: () => Navigator.of(context).maybePop(), + child: _buildAppBarPill( + context, + Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.back, + android: Icons.arrow_back, + ), + color: conduitTheme.textPrimary, + size: IconSize.appBar, + ), + isCircular: true, + ), + ), + ), + ) + else + const SizedBox(width: Spacing.inputPadding), + // Title centered + Expanded( + child: Center( + child: _buildAppBarPill( + context, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: Text( + l10n.appCustomization, + style: AppTypography.headlineSmallStyle.copyWith( + color: conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + // Trailing spacer to balance + if (canPop) + const SizedBox(width: 44 + Spacing.inputPadding) + else + const SizedBox(width: Spacing.inputPadding), + ], + ), ), - const SizedBox(height: Spacing.xl), - _buildSttSection(context, ref, settings), - const SizedBox(height: Spacing.xl), - _buildTtsDropdownSection(context, ref, settings), - const SizedBox(height: Spacing.xl), - _buildChatSection(context, ref, settings), - const SizedBox(height: Spacing.xl), - _buildSocketHealthSection(context, ref), - ], + ), ), ), + body: ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB( + Spacing.pagePadding, + topPadding, + Spacing.pagePadding, + Spacing.pagePadding + MediaQuery.of(context).padding.bottom, + ), + children: [ + _buildThemesDropdownSection( + context, + ref, + themeMode, + themeDescription, + activeTheme, + settings, + ), + const SizedBox(height: Spacing.md), + _buildLanguageSection( + context, + ref, + currentLanguageCode, + languageLabel, + ), + const SizedBox(height: Spacing.xl), + _buildSttSection(context, ref, settings), + const SizedBox(height: Spacing.xl), + _buildTtsDropdownSection(context, ref, settings), + const SizedBox(height: Spacing.xl), + _buildChatSection(context, ref, settings), + const SizedBox(height: Spacing.xl), + _buildSocketHealthSection(context, ref), + ], + ), ); } - PreferredSizeWidget _buildAppBar(BuildContext context) { - final canPop = ModalRoute.of(context)?.canPop ?? false; - return AppBar( - backgroundColor: context.sidebarTheme.background, - surfaceTintColor: Colors.transparent, - elevation: Elevation.none, - toolbarHeight: kToolbarHeight, - automaticallyImplyLeading: false, - leading: canPop - ? IconButton( - icon: Icon( - UiUtils.platformIcon( - ios: CupertinoIcons.back, - android: Icons.arrow_back, - ), - color: context.conduitTheme.iconPrimary, + Widget _buildAppBarPill( + BuildContext context, + Widget child, { + bool isCircular = false, + }) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; + + final borderColor = context.conduitTheme.cardBorder.withValues( + alpha: isDark ? 0.65 : 0.55, + ); + + final borderRadius = isCircular + ? BorderRadius.circular(100) + : BorderRadius.circular(AppBorderRadius.pill); + + if (isCircular) { + return SizedBox( + width: 44, + height: 44, + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), ), - onPressed: () => Navigator.of(context).maybePop(), - tooltip: AppLocalizations.of(context)!.back, - ) - : null, - titleSpacing: 0, - title: Text( - AppLocalizations.of(context)!.appCustomization, - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, + child: Center(child: child), + ), + ), + ), + ); + } + + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: child, ), ), - centerTitle: true, ); } diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index 8937bc4..9acd2c4 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/widgets/improved_loading_states.dart'; +import 'dart:ui' show ImageFilter; import '../../../shared/utils/ui_utils.dart'; import '../../../shared/widgets/themed_dialogs.dart'; @@ -66,52 +67,162 @@ class ProfilePage extends ConsumerWidget { } Scaffold _buildScaffold(BuildContext context, {required Widget body}) { + final canPop = ModalRoute.of(context)?.canPop ?? false; + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + final conduitTheme = context.conduitTheme; + return Scaffold( - backgroundColor: context.sidebarTheme.background, - appBar: _buildAppBar(context), + backgroundColor: conduitTheme.surfaceBackground, + extendBodyBehindAppBar: true, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight + 8), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.4, 1.0], + colors: [ + theme.scaffoldBackgroundColor, + theme.scaffoldBackgroundColor.withValues(alpha: 0.85), + theme.scaffoldBackgroundColor.withValues(alpha: 0.0), + ], + ), + ), + child: SafeArea( + bottom: false, + child: SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + // Leading (back button) + if (canPop) + Padding( + padding: const EdgeInsets.only(left: Spacing.inputPadding), + child: Center( + child: GestureDetector( + onTap: () => Navigator.of(context).maybePop(), + child: _buildAppBarPill( + context, + Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.back, + android: Icons.arrow_back, + ), + color: conduitTheme.textPrimary, + size: IconSize.appBar, + ), + isCircular: true, + ), + ), + ), + ) + else + const SizedBox(width: Spacing.inputPadding), + // Title centered + Expanded( + child: Center( + child: _buildAppBarPill( + context, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: Text( + l10n.you, + style: AppTypography.headlineSmallStyle.copyWith( + color: conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + // Trailing spacer to balance + if (canPop) + const SizedBox(width: 44 + Spacing.inputPadding) + else + const SizedBox(width: Spacing.inputPadding), + ], + ), + ), + ), + ), + ), body: body, ); } - PreferredSizeWidget _buildAppBar(BuildContext context) { - final canPop = ModalRoute.of(context)?.canPop ?? false; - return AppBar( - backgroundColor: context.sidebarTheme.background, - surfaceTintColor: Colors.transparent, - elevation: Elevation.none, - toolbarHeight: kToolbarHeight, - automaticallyImplyLeading: false, - leading: canPop - ? IconButton( - icon: Icon( - UiUtils.platformIcon( - ios: CupertinoIcons.back, - android: Icons.arrow_back, - ), - color: context.conduitTheme.iconPrimary, + Widget _buildAppBarPill( + BuildContext context, + Widget child, { + bool isCircular = false, + }) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; + + final borderColor = context.conduitTheme.cardBorder.withValues( + alpha: isDark ? 0.65 : 0.55, + ); + + final borderRadius = isCircular + ? BorderRadius.circular(100) + : BorderRadius.circular(AppBorderRadius.pill); + + if (isCircular) { + return SizedBox( + width: 44, + height: 44, + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), ), - onPressed: () => Navigator.of(context).maybePop(), - tooltip: AppLocalizations.of(context)!.back, - ) - : null, - titleSpacing: 0, - title: Text( - AppLocalizations.of(context)!.you, - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, + child: Center(child: child), + ), + ), + ), + ); + } + + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: child, ), ), - centerTitle: true, ); } Widget _buildCenteredState(BuildContext context, Widget child) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(Spacing.pagePadding), - child: Center(child: child), + final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight + 24; + return Padding( + padding: EdgeInsets.fromLTRB( + Spacing.pagePadding, + topPadding, + Spacing.pagePadding, + Spacing.pagePadding + MediaQuery.of(context).padding.bottom, ), + child: Center(child: child), ); } @@ -121,23 +232,26 @@ class ProfilePage extends ConsumerWidget { dynamic userData, ApiService? api, ) { - return SafeArea( - child: ListView( - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - padding: const EdgeInsets.symmetric( - horizontal: Spacing.pagePadding, - vertical: Spacing.pagePadding, - ), - children: [ - _buildProfileHeader(context, userData, api), - const SizedBox(height: Spacing.xl), - _buildAccountSection(context, ref), - const SizedBox(height: Spacing.xl), - _buildSupportSection(context), - ], + // Calculate top padding to account for app bar + safe area + final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight + 24; + + return ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), ), + padding: EdgeInsets.fromLTRB( + Spacing.pagePadding, + topPadding, + Spacing.pagePadding, + Spacing.pagePadding + MediaQuery.of(context).padding.bottom, + ), + children: [ + _buildProfileHeader(context, userData, api), + const SizedBox(height: Spacing.xl), + _buildAccountSection(context, ref), + const SizedBox(height: Spacing.xl), + _buildSupportSection(context), + ], ); } From 5396fb8eec0a98959dc9124a15982438f62d9db6 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 15 Dec 2025 19:15:27 +0530 Subject: [PATCH 2/5] feat(ui): Refactor chats drawer with floating search and user sections --- .../navigation/widgets/chats_drawer.dart | 433 +++++++++++------- 1 file changed, 264 insertions(+), 169 deletions(-) diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 6bf343c..2dc16b9 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -113,12 +114,26 @@ class _ChatsDrawerState extends ConsumerState { // Legacy helper removed: drawer now uses slivers with lazy delegates. Widget _buildRefreshableScrollableSlivers({required List slivers}) { + // Add padding at top and bottom for floating elements + final bottomPadding = MediaQuery.of(context).viewPadding.bottom; + final paddedSlivers = [ + // Top padding for floating search bar area (sm + search height + md) + const SliverToBoxAdapter( + child: SizedBox(height: Spacing.sm + 48 + Spacing.md), + ), + ...slivers, + // Bottom padding for floating user tile area (xl + tile height + md + safe area) + SliverToBoxAdapter( + child: SizedBox(height: Spacing.xl + 52 + Spacing.md + bottomPadding), + ), + ]; + final scroll = CustomScrollView( key: const PageStorageKey('chats_drawer_scroll'), controller: _listController, physics: const AlwaysScrollableScrollPhysics(), cacheExtent: 800, - slivers: slivers, + slivers: paddedSlivers, ); final refreshableScroll = ConduitRefreshIndicator( @@ -163,97 +178,173 @@ class _ChatsDrawerState extends ConsumerState { color: sidebarTheme.background, border: Border(right: BorderSide(color: sidebarTheme.border)), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Stack( children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - Spacing.inputPadding, - Spacing.sm, - Spacing.md, - Spacing.sm, + // Main scrollable content - extends behind floating elements + Positioned.fill( + child: _buildConversationList(context), + ), + // Floating top area with gradient background (matches app bar pattern) + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.4, 1.0], + colors: [ + sidebarTheme.background, + sidebarTheme.background.withValues(alpha: 0.85), + sidebarTheme.background.withValues(alpha: 0.0), + ], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Small top padding + const SizedBox(height: Spacing.sm), + // Floating search bar + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.inputPadding, + ), + child: _buildFloatingSearchField(context), + ), + // Gradient fade area below + const SizedBox(height: Spacing.md), + ], + ), ), - child: Row(children: [Expanded(child: _buildSearchField(context))]), ), - Expanded(child: _buildConversationList(context)), - Divider( - height: 1, - color: sidebarTheme.border.withValues(alpha: 0.28), + // Floating bottom area with gradient background (matches chat input pattern) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.4, 1.0], + colors: [ + sidebarTheme.background.withValues(alpha: 0.0), + sidebarTheme.background.withValues(alpha: 0.85), + sidebarTheme.background, + ], + ), + ), + child: Builder( + builder: (context) { + final bottomPadding = MediaQuery.of(context).viewPadding.bottom; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Gradient fade area above + const SizedBox(height: Spacing.xl), + // Floating user tile + Padding( + padding: EdgeInsets.fromLTRB( + Spacing.screenPadding, + 0, + Spacing.screenPadding, + bottomPadding + Spacing.md, + ), + child: _buildFloatingBottomSection(context), + ), + ], + ); + }, + ), + ), ), - _buildBottomSection(context), ], ), ); } - 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), + Widget _buildFloatingSearchField(BuildContext context) { + final theme = Theme.of(context); + final conduitTheme = context.conduitTheme; + final isDark = theme.brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!; + + final borderColor = conduitTheme.cardBorder.withValues( + alpha: isDark ? 0.65 : 0.55, + ); + + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + border: Border.all(color: borderColor, width: BorderWidth.thin), ), - 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, + child: Material( + color: Colors.transparent, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (_) => _onSearchChanged(), + style: AppTypography.standard.copyWith( + color: conduitTheme.textPrimary, + ), + decoration: InputDecoration( + isDense: true, + hintText: AppLocalizations.of(context)!.searchConversations, + hintStyle: AppTypography.standard.copyWith( + color: conduitTheme.textSecondary.withValues(alpha: 0.6), + ), + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search, + color: conduitTheme.iconSecondary, + 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: conduitTheme.iconSecondary, + size: IconSize.input, + ), + ) + : null, + suffixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, + ), + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + ), ), ), - 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, - ), ), ), ); @@ -1608,9 +1699,10 @@ class _ChatsDrawerState extends ConsumerState { } } - Widget _buildBottomSection(BuildContext context) { - final theme = context.conduitTheme; - final sidebarTheme = context.sidebarTheme; + Widget _buildFloatingBottomSection(BuildContext context) { + final theme = Theme.of(context); + final conduitTheme = context.conduitTheme; + final isDark = theme.brightness == Brightness.dark; final authUser = ref.watch(currentUserProvider2); final asyncUser = ref.watch(currentUserProvider); final user = asyncUser.maybeWhen( @@ -1630,96 +1722,99 @@ class _ChatsDrawerState extends ConsumerState { final initial = initialFor(displayName); 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, + final backgroundColor = isDark + ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!; + + final borderColor = conduitTheme.cardBorder.withValues( + alpha: isDark ? 0.65 : 0.55, + ); + + if (user == null) return const SizedBox.shrink(); + + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + AppBorderRadius.avatar, + ), + border: Border.all( + color: conduitTheme.buttonPrimary.withValues(alpha: 0.25), + width: BorderWidth.thin, + ), + ), + clipBehavior: Clip.hardEdge, + child: UserAvatar( + size: 36, + imageUrl: avatarUrl, + fallbackText: initial, ), ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - AppBorderRadius.avatar, - ), - border: Border.all( - color: theme.buttonPrimary.withValues(alpha: 0.25), - width: BorderWidth.thin, - ), - ), - // Hard-edge clipping is cheaper than anti-aliased clipping - // and sufficient for avatar squares with rounded corners. - clipBehavior: Clip.hardEdge, - child: UserAvatar( - size: 36, - imageUrl: avatarUrl, - fallbackText: initial, - ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodySmallStyle.copyWith( + color: conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, ), - 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, - ), - ), - ), - // Notes icon (hidden when feature is disabled) - if (notesEnabled) - IconButton( - tooltip: AppLocalizations.of(context)!.notes, - onPressed: () { - Navigator.of(context).maybePop(); - context.pushNamed(RouteNames.notes); - }, - visualDensity: VisualDensity.compact, - icon: Icon( - Platform.isIOS - ? CupertinoIcons.doc_text - : Icons.note_alt_outlined, - color: sidebarTheme.foreground.withValues(alpha: 0.8), - size: IconSize.medium, - ), - ), - 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, - ), - ), - ], + ), ), - ), - ], - ], + // Notes icon (hidden when feature is disabled) + if (notesEnabled) + IconButton( + tooltip: AppLocalizations.of(context)!.notes, + onPressed: () { + Navigator.of(context).maybePop(); + context.pushNamed(RouteNames.notes); + }, + visualDensity: VisualDensity.compact, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.doc_text + : Icons.note_alt_outlined, + color: conduitTheme.iconPrimary, + size: IconSize.medium, + ), + ), + 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: conduitTheme.iconPrimary, + size: IconSize.medium, + ), + ), + ], + ), + ), ), ); } From 7619040e27e4a83bfac3894ac0fe393b8230d5e2 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:17:56 +0530 Subject: [PATCH 3/5] fix(navigation): Adjust bottom padding for safe area in chats drawer --- lib/features/chat/views/chat_page.dart | 54 +-- .../navigation/widgets/chats_drawer.dart | 258 ++++++-------- .../notes/views/note_editor_page.dart | 53 +-- lib/features/notes/views/notes_list_page.dart | 315 ++++-------------- .../profile/views/app_customization_page.dart | 142 +------- lib/features/profile/views/profile_page.dart | 141 +------- lib/shared/widgets/conduit_components.dart | 303 +++++++++++++++++ 7 files changed, 495 insertions(+), 771 deletions(-) diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index a2dbcdb..f6de623 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -956,57 +956,9 @@ class _ChatPageState extends ConsumerState { required Widget child, bool isCircular = false, }) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - - // Use same high-contrast colors as the floating chat input - final backgroundColor = isDark - ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! - : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; - - final borderColor = context.conduitTheme.cardBorder.withValues( - alpha: isDark ? 0.65 : 0.55, - ); - - final borderRadius = isCircular - ? BorderRadius.circular(100) - : BorderRadius.circular(AppBorderRadius.pill); - - // For circular buttons, ensure the entire widget is constrained to a square - if (isCircular) { - return SizedBox( - width: 44, - height: 44, - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: Center(child: child), - ), - ), - ), - ); - } - - return ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: child, - ), - ), + return FloatingAppBarPill( + isCircular: isCircular, + child: child, ); } diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 2dc16b9..a62e452 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io' show Platform; -import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -23,6 +22,7 @@ import '../../../core/utils/user_avatar_utils.dart'; import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/widgets/user_avatar.dart'; import '../../../shared/widgets/model_avatar.dart'; +import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/widgets/responsive_drawer_layout.dart'; import '../../../core/models/model.dart'; import '../../../core/models/conversation.dart'; @@ -268,81 +268,60 @@ class _ChatsDrawerState extends ConsumerState { } Widget _buildFloatingSearchField(BuildContext context) { - final theme = Theme.of(context); final conduitTheme = context.conduitTheme; - final isDark = theme.brightness == Brightness.dark; - final backgroundColor = isDark - ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! - : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!; - - final borderColor = conduitTheme.cardBorder.withValues( - alpha: isDark ? 0.65 : 0.55, - ); - - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.pill), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(AppBorderRadius.pill), - border: Border.all(color: borderColor, width: BorderWidth.thin), + return FloatingAppBarPill( + child: Material( + color: Colors.transparent, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (_) => _onSearchChanged(), + style: AppTypography.standard.copyWith( + color: conduitTheme.textPrimary, ), - child: Material( - color: Colors.transparent, - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - onChanged: (_) => _onSearchChanged(), - style: AppTypography.standard.copyWith( - color: conduitTheme.textPrimary, - ), - decoration: InputDecoration( - isDense: true, - hintText: AppLocalizations.of(context)!.searchConversations, - hintStyle: AppTypography.standard.copyWith( - color: conduitTheme.textSecondary.withValues(alpha: 0.6), - ), - prefixIcon: Icon( - Platform.isIOS ? CupertinoIcons.search : Icons.search, - color: conduitTheme.iconSecondary, - 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: conduitTheme.iconSecondary, - size: IconSize.input, - ), - ) - : null, - suffixIconConstraints: const BoxConstraints( - minWidth: TouchTarget.minimum, - minHeight: TouchTarget.minimum, - ), - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - ), + decoration: InputDecoration( + isDense: true, + hintText: AppLocalizations.of(context)!.searchConversations, + hintStyle: AppTypography.standard.copyWith( + color: conduitTheme.textSecondary.withValues(alpha: 0.6), + ), + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search, + color: conduitTheme.iconSecondary, + 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: conduitTheme.iconSecondary, + size: IconSize.input, + ), + ) + : null, + suffixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, + ), + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, ), ), ), @@ -1700,9 +1679,7 @@ class _ChatsDrawerState extends ConsumerState { } Widget _buildFloatingBottomSection(BuildContext context) { - final theme = Theme.of(context); final conduitTheme = context.conduitTheme; - final isDark = theme.brightness == Brightness.dark; final authUser = ref.watch(currentUserProvider2); final asyncUser = ref.watch(currentUserProvider); final user = asyncUser.maybeWhen( @@ -1722,98 +1699,81 @@ class _ChatsDrawerState extends ConsumerState { final initial = initialFor(displayName); final avatarUrl = resolveUserAvatarUrlForUser(api, user); - final backgroundColor = isDark - ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! - : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!; - - final borderColor = conduitTheme.cardBorder.withValues( - alpha: isDark ? 0.65 : 0.55, - ); - if (user == null) return const SizedBox.shrink(); - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.pill), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(AppBorderRadius.pill), - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - AppBorderRadius.avatar, - ), - border: Border.all( - color: conduitTheme.buttonPrimary.withValues(alpha: 0.25), - width: BorderWidth.thin, - ), + return FloatingAppBarPill( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + AppBorderRadius.avatar, ), - clipBehavior: Clip.hardEdge, - child: UserAvatar( - size: 36, - imageUrl: avatarUrl, - fallbackText: initial, + border: Border.all( + color: conduitTheme.buttonPrimary.withValues(alpha: 0.25), + width: BorderWidth.thin, ), ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text( - displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.bodySmallStyle.copyWith( - color: conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, - ), + clipBehavior: Clip.hardEdge, + child: UserAvatar( + size: 36, + imageUrl: avatarUrl, + fallbackText: initial, + ), + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodySmallStyle.copyWith( + color: conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, ), ), - // Notes icon (hidden when feature is disabled) - if (notesEnabled) - IconButton( - tooltip: AppLocalizations.of(context)!.notes, - onPressed: () { - Navigator.of(context).maybePop(); - context.pushNamed(RouteNames.notes); - }, - visualDensity: VisualDensity.compact, - icon: Icon( - Platform.isIOS - ? CupertinoIcons.doc_text - : Icons.note_alt_outlined, - color: conduitTheme.iconPrimary, - size: IconSize.medium, - ), - ), + ), + // Notes icon (hidden when feature is disabled) + if (notesEnabled) IconButton( - tooltip: AppLocalizations.of(context)!.manage, + tooltip: AppLocalizations.of(context)!.notes, onPressed: () { Navigator.of(context).maybePop(); - context.pushNamed(RouteNames.profile); + context.pushNamed(RouteNames.notes); }, visualDensity: VisualDensity.compact, icon: Icon( Platform.isIOS - ? CupertinoIcons.settings - : Icons.settings_rounded, + ? CupertinoIcons.doc_text + : Icons.note_alt_outlined, color: conduitTheme.iconPrimary, size: IconSize.medium, ), ), - ], - ), + 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: conduitTheme.iconPrimary, + size: IconSize.medium, + ), + ), + ], ), ), ); diff --git a/lib/features/notes/views/note_editor_page.dart b/lib/features/notes/views/note_editor_page.dart index c3bbaeb..ce847c0 100644 --- a/lib/features/notes/views/note_editor_page.dart +++ b/lib/features/notes/views/note_editor_page.dart @@ -15,6 +15,7 @@ import '../../../core/providers/app_providers.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/utils/ui_utils.dart'; +import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/widgets/improved_loading_states.dart'; import '../../../shared/widgets/middle_ellipsis_text.dart'; import '../../../shared/widgets/themed_dialogs.dart'; @@ -815,55 +816,9 @@ class _NoteEditorPageState extends ConsumerState { Widget child, { bool isCircular = false, }) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - - final backgroundColor = isDark - ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! - : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; - - final borderColor = context.conduitTheme.cardBorder.withValues( - alpha: isDark ? 0.65 : 0.55, - ); - - final borderRadius = isCircular - ? BorderRadius.circular(100) - : BorderRadius.circular(AppBorderRadius.pill); - - if (isCircular) { - return SizedBox( - width: 44, - height: 44, - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: Center(child: child), - ), - ), - ), - ); - } - - return ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: child, - ), - ), + return FloatingAppBarPill( + isCircular: isCircular, + child: child, ); } diff --git a/lib/features/notes/views/notes_list_page.dart b/lib/features/notes/views/notes_list_page.dart index 97d734e..c6ac950 100644 --- a/lib/features/notes/views/notes_list_page.dart +++ b/lib/features/notes/views/notes_list_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io' show Platform; -import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -15,7 +14,7 @@ import '../../../core/providers/app_providers.dart'; import '../../../core/services/navigation_service.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/theme/theme_extensions.dart'; -import '../../../shared/utils/ui_utils.dart'; +import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/widgets/improved_loading_states.dart'; import '../../../shared/widgets/loading_states.dart'; import '../../../shared/widgets/themed_dialogs.dart'; @@ -130,123 +129,23 @@ class _NotesListPageState extends ConsumerState { child: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight + 64), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0.0, 0.4, 1.0], - colors: [ - Theme.of(context).scaffoldBackgroundColor, - Theme.of(context).scaffoldBackgroundColor.withValues( - alpha: 0.85, - ), - Theme.of(context).scaffoldBackgroundColor.withValues( - alpha: 0.0, - ), - ], - ), - ), - child: SafeArea( - bottom: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // App bar row with back button and title - SizedBox( - height: kToolbarHeight, - child: Row( - children: [ - // Leading (back button) - if (canPop) - Padding( - padding: const EdgeInsets.only( - left: Spacing.inputPadding, - ), - child: Center( - child: GestureDetector( - onTap: () => Navigator.of(context).maybePop(), - child: _buildAppBarPill( - context, - Icon( - UiUtils.platformIcon( - ios: CupertinoIcons.back, - android: Icons.arrow_back, - ), - color: context.conduitTheme.textPrimary, - size: IconSize.appBar, - ), - isCircular: true, - ), - ), - ), - ) - else - const SizedBox(width: Spacing.inputPadding), - // Title centered - Expanded( - child: Center( - child: _buildAppBarPill( - context, - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.doc_text_fill - : Icons.notes_rounded, - color: context.conduitTheme.textPrimary - .withValues(alpha: 0.7), - size: IconSize.md, - ), - const SizedBox(width: Spacing.sm), - Text( - l10n.notes, - style: - AppTypography.headlineSmallStyle - .copyWith( - color: context - .conduitTheme - .textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ), - ), - // Trailing spacer to balance - if (canPop) - const SizedBox( - width: 44 + Spacing.inputPadding, - ) - else - const SizedBox(width: Spacing.inputPadding), - ], - ), - ), - // Search bar directly below title - Padding( - padding: const EdgeInsets.fromLTRB( - Spacing.inputPadding, - Spacing.xs, - Spacing.inputPadding, - Spacing.sm, - ), - child: _buildFloatingSearchField(context), - ), - ], - ), + appBar: FloatingAppBar( + leading: canPop ? const FloatingAppBarBackButton() : null, + title: FloatingAppBarTitle( + text: l10n.notes, + icon: Platform.isIOS + ? CupertinoIcons.doc_text_fill + : Icons.notes_rounded, + ), + bottomHeight: 64, + bottom: Padding( + padding: const EdgeInsets.fromLTRB( + Spacing.inputPadding, + Spacing.xs, + Spacing.inputPadding, + Spacing.sm, ), + child: _buildFloatingSearchField(context), ), ), body: _buildBody(context), @@ -255,140 +154,62 @@ class _NotesListPageState extends ConsumerState { ); } - Widget _buildAppBarPill( - BuildContext context, - Widget child, { - bool isCircular = false, - }) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - - final backgroundColor = isDark - ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! - : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; - - final borderColor = context.conduitTheme.cardBorder.withValues( - alpha: isDark ? 0.65 : 0.55, - ); - - final borderRadius = isCircular - ? BorderRadius.circular(100) - : BorderRadius.circular(AppBorderRadius.pill); - - if (isCircular) { - return SizedBox( - width: 44, - height: 44, - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: Center(child: child), - ), - ), - ), - ); - } - - return ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: child, - ), - ), - ); - } - Widget _buildFloatingSearchField(BuildContext context) { - final theme = Theme.of(context); final conduitTheme = context.conduitTheme; - final isDark = theme.brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; - final backgroundColor = isDark - ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! - : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!; - - final borderColor = conduitTheme.cardBorder.withValues( - alpha: isDark ? 0.65 : 0.55, - ); - - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.pill), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(AppBorderRadius.pill), - border: Border.all(color: borderColor, width: BorderWidth.thin), + return FloatingAppBarPill( + child: Material( + color: Colors.transparent, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (_) => _onSearchChanged(), + style: AppTypography.standard.copyWith( + color: conduitTheme.textPrimary, ), - child: Material( - color: Colors.transparent, - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - onChanged: (_) => _onSearchChanged(), - style: AppTypography.standard.copyWith( - color: conduitTheme.textPrimary, - ), - decoration: InputDecoration( - isDense: true, - hintText: l10n.searchNotes, - hintStyle: AppTypography.standard.copyWith( - color: conduitTheme.textSecondary.withValues(alpha: 0.6), - ), - prefixIcon: Icon( - Platform.isIOS ? CupertinoIcons.search : Icons.search, - color: conduitTheme.iconSecondary, - 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: conduitTheme.iconSecondary, - size: IconSize.input, - ), - ) - : null, - suffixIconConstraints: const BoxConstraints( - minWidth: TouchTarget.minimum, - minHeight: TouchTarget.minimum, - ), - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - ), + decoration: InputDecoration( + isDense: true, + hintText: l10n.searchNotes, + hintStyle: AppTypography.standard.copyWith( + color: conduitTheme.textSecondary.withValues(alpha: 0.6), + ), + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search, + color: conduitTheme.iconSecondary, + 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: conduitTheme.iconSecondary, + size: IconSize.input, + ), + ) + : null, + suffixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, + ), + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, ), ), ), diff --git a/lib/features/profile/views/app_customization_page.dart b/lib/features/profile/views/app_customization_page.dart index fed4434..311cd83 100644 --- a/lib/features/profile/views/app_customization_page.dart +++ b/lib/features/profile/views/app_customization_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io' show Platform; -import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -48,88 +47,12 @@ class AppCustomizationPage extends ConsumerWidget { final canPop = ModalRoute.of(context)?.canPop ?? false; final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight + 24; - final theme = Theme.of(context); - final conduitTheme = context.conduitTheme; - return Scaffold( - backgroundColor: conduitTheme.surfaceBackground, + backgroundColor: context.conduitTheme.surfaceBackground, extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight + 8), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0.0, 0.4, 1.0], - colors: [ - theme.scaffoldBackgroundColor, - theme.scaffoldBackgroundColor.withValues(alpha: 0.85), - theme.scaffoldBackgroundColor.withValues(alpha: 0.0), - ], - ), - ), - child: SafeArea( - bottom: false, - child: SizedBox( - height: kToolbarHeight, - child: Row( - children: [ - // Leading (back button) - if (canPop) - Padding( - padding: const EdgeInsets.only(left: Spacing.inputPadding), - child: Center( - child: GestureDetector( - onTap: () => Navigator.of(context).maybePop(), - child: _buildAppBarPill( - context, - Icon( - UiUtils.platformIcon( - ios: CupertinoIcons.back, - android: Icons.arrow_back, - ), - color: conduitTheme.textPrimary, - size: IconSize.appBar, - ), - isCircular: true, - ), - ), - ), - ) - else - const SizedBox(width: Spacing.inputPadding), - // Title centered - Expanded( - child: Center( - child: _buildAppBarPill( - context, - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.xs, - ), - child: Text( - l10n.appCustomization, - style: AppTypography.headlineSmallStyle.copyWith( - color: conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ), - // Trailing spacer to balance - if (canPop) - const SizedBox(width: 44 + Spacing.inputPadding) - else - const SizedBox(width: Spacing.inputPadding), - ], - ), - ), - ), - ), + appBar: FloatingAppBar( + leading: canPop ? const FloatingAppBarBackButton() : null, + title: FloatingAppBarTitle(text: l10n.appCustomization), ), body: ListView( physics: const BouncingScrollPhysics( @@ -170,63 +93,6 @@ class AppCustomizationPage extends ConsumerWidget { ); } - Widget _buildAppBarPill( - BuildContext context, - Widget child, { - bool isCircular = false, - }) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - - final backgroundColor = isDark - ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! - : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; - - final borderColor = context.conduitTheme.cardBorder.withValues( - alpha: isDark ? 0.65 : 0.55, - ); - - final borderRadius = isCircular - ? BorderRadius.circular(100) - : BorderRadius.circular(AppBorderRadius.pill); - - if (isCircular) { - return SizedBox( - width: 44, - height: 44, - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: Center(child: child), - ), - ), - ), - ); - } - - return ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: child, - ), - ), - ); - } - Widget _buildThemesDropdownSection( BuildContext context, WidgetRef ref, diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index 9acd2c4..f1213a3 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -9,7 +9,6 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/widgets/improved_loading_states.dart'; -import 'dart:ui' show ImageFilter; import '../../../shared/utils/ui_utils.dart'; import '../../../shared/widgets/themed_dialogs.dart'; @@ -68,151 +67,19 @@ class ProfilePage extends ConsumerWidget { Scaffold _buildScaffold(BuildContext context, {required Widget body}) { final canPop = ModalRoute.of(context)?.canPop ?? false; - final theme = Theme.of(context); final l10n = AppLocalizations.of(context)!; - final conduitTheme = context.conduitTheme; return Scaffold( - backgroundColor: conduitTheme.surfaceBackground, + backgroundColor: context.conduitTheme.surfaceBackground, extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight + 8), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0.0, 0.4, 1.0], - colors: [ - theme.scaffoldBackgroundColor, - theme.scaffoldBackgroundColor.withValues(alpha: 0.85), - theme.scaffoldBackgroundColor.withValues(alpha: 0.0), - ], - ), - ), - child: SafeArea( - bottom: false, - child: SizedBox( - height: kToolbarHeight, - child: Row( - children: [ - // Leading (back button) - if (canPop) - Padding( - padding: const EdgeInsets.only(left: Spacing.inputPadding), - child: Center( - child: GestureDetector( - onTap: () => Navigator.of(context).maybePop(), - child: _buildAppBarPill( - context, - Icon( - UiUtils.platformIcon( - ios: CupertinoIcons.back, - android: Icons.arrow_back, - ), - color: conduitTheme.textPrimary, - size: IconSize.appBar, - ), - isCircular: true, - ), - ), - ), - ) - else - const SizedBox(width: Spacing.inputPadding), - // Title centered - Expanded( - child: Center( - child: _buildAppBarPill( - context, - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.xs, - ), - child: Text( - l10n.you, - style: AppTypography.headlineSmallStyle.copyWith( - color: conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ), - // Trailing spacer to balance - if (canPop) - const SizedBox(width: 44 + Spacing.inputPadding) - else - const SizedBox(width: Spacing.inputPadding), - ], - ), - ), - ), - ), + appBar: FloatingAppBar( + leading: canPop ? const FloatingAppBarBackButton() : null, + title: FloatingAppBarTitle(text: l10n.you), ), body: body, ); } - Widget _buildAppBarPill( - BuildContext context, - Widget child, { - bool isCircular = false, - }) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - - final backgroundColor = isDark - ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! - : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; - - final borderColor = context.conduitTheme.cardBorder.withValues( - alpha: isDark ? 0.65 : 0.55, - ); - - final borderRadius = isCircular - ? BorderRadius.circular(100) - : BorderRadius.circular(AppBorderRadius.pill); - - if (isCircular) { - return SizedBox( - width: 44, - height: 44, - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: Center(child: child), - ), - ), - ), - ); - } - - return ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: backgroundColor.withValues(alpha: 0.85), - borderRadius: borderRadius, - border: Border.all(color: borderColor, width: BorderWidth.thin), - ), - child: child, - ), - ), - ); - } - Widget _buildCenteredState(BuildContext context, Widget child) { final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight + 24; return Padding( diff --git a/lib/shared/widgets/conduit_components.dart b/lib/shared/widgets/conduit_components.dart index f62e7d9..f6f8242 100644 --- a/lib/shared/widgets/conduit_components.dart +++ b/lib/shared/widgets/conduit_components.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show ImageFilter; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme/theme_extensions.dart'; @@ -10,6 +12,307 @@ import '../../core/services/settings_service.dart'; /// Unified component library following Conduit design patterns /// This provides consistent, reusable UI components throughout the app +// ============================================================================= +// FLOATING APP BAR COMPONENTS +// ============================================================================= + +/// A pill-shaped container with blur effect for floating app bar elements. +/// Used for back buttons, titles, and action buttons in the floating app bar. +class FloatingAppBarPill extends StatelessWidget { + final Widget child; + final bool isCircular; + + const FloatingAppBarPill({ + super.key, + required this.child, + this.isCircular = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final conduitTheme = context.conduitTheme; + final isDark = theme.brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!; + + final borderColor = conduitTheme.cardBorder.withValues( + alpha: isDark ? 0.65 : 0.55, + ); + + final borderRadius = isCircular + ? BorderRadius.circular(100) + : BorderRadius.circular(AppBorderRadius.pill); + + if (isCircular) { + return SizedBox( + width: 44, + height: 44, + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: Center(child: child), + ), + ), + ), + ); + } + + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.85), + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: child, + ), + ), + ); + } +} + +/// A floating app bar with gradient background and pill-shaped elements. +/// Provides a consistent app bar style across the app with blur effects. +/// +/// Supports: +/// - Simple title with optional leading/actions +/// - Custom title widget for complex layouts +/// - Bottom widget for search bars or other content +/// - Flexible actions positioning +class FloatingAppBar extends StatelessWidget implements PreferredSizeWidget { + /// Leading widget (typically a back button or menu button) + final Widget? leading; + + /// Title widget - can be a simple [FloatingAppBarTitle] or custom widget + final Widget title; + + /// Action widgets displayed on the right side + final List? actions; + + /// Bottom widget displayed below the main row (e.g., search bar) + final Widget? bottom; + + /// Height of the bottom widget (used for preferredSize calculation) + final double bottomHeight; + + /// Whether to show a trailing spacer when there's a leading widget but no actions + /// Set to false if you want the title to use all available space + final bool balanceLeading; + + const FloatingAppBar({ + super.key, + this.leading, + required this.title, + this.actions, + this.bottom, + this.bottomHeight = 0, + this.balanceLeading = true, + }); + + @override + Size get preferredSize => Size.fromHeight(kToolbarHeight + bottomHeight); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.4, 1.0], + colors: [ + theme.scaffoldBackgroundColor, + theme.scaffoldBackgroundColor.withValues(alpha: 0.85), + theme.scaffoldBackgroundColor.withValues(alpha: 0.0), + ], + ), + ), + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + // Leading + if (leading != null) + Padding( + padding: const EdgeInsets.only(left: Spacing.inputPadding), + child: Center(child: leading), + ) + else + const SizedBox(width: Spacing.inputPadding), + // Title centered + Expanded( + child: Center(child: title), + ), + // Actions or trailing spacer + if (actions != null && actions!.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + children: actions!, + ) + else if (leading != null && balanceLeading) + const SizedBox(width: 44 + Spacing.inputPadding) + else + const SizedBox(width: Spacing.inputPadding), + ], + ), + ), + if (bottom != null) bottom!, + ], + ), + ), + ); + } +} + +/// Helper to build a standard floating app bar title pill with text. +class FloatingAppBarTitle extends StatelessWidget { + final String text; + final IconData? icon; + + const FloatingAppBarTitle({ + super.key, + required this.text, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final conduitTheme = context.conduitTheme; + + return FloatingAppBarPill( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon( + icon, + color: conduitTheme.textPrimary.withValues(alpha: 0.7), + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + ], + Text( + text, + style: AppTypography.headlineSmallStyle.copyWith( + color: conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +/// Helper to build a standard floating app bar back button. +class FloatingAppBarBackButton extends StatelessWidget { + final VoidCallback? onTap; + final IconData? icon; + + const FloatingAppBarBackButton({ + super.key, + this.onTap, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final conduitTheme = context.conduitTheme; + final isIOS = Theme.of(context).platform == TargetPlatform.iOS; + + return GestureDetector( + onTap: onTap ?? () => Navigator.of(context).maybePop(), + child: FloatingAppBarPill( + isCircular: true, + child: Icon( + icon ?? (isIOS ? Icons.arrow_back_ios_new : Icons.arrow_back), + color: conduitTheme.textPrimary, + size: IconSize.appBar, + ), + ), + ); + } +} + +/// Helper to build a floating app bar icon button (circular pill with icon). +class FloatingAppBarIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onTap; + final Color? iconColor; + + const FloatingAppBarIconButton({ + super.key, + required this.icon, + this.onTap, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + final conduitTheme = context.conduitTheme; + + return GestureDetector( + onTap: onTap, + child: FloatingAppBarPill( + isCircular: true, + child: Icon( + icon, + color: iconColor ?? conduitTheme.textPrimary, + size: IconSize.appBar, + ), + ), + ); + } +} + +/// Helper to build a floating app bar action with padding. +class FloatingAppBarAction extends StatelessWidget { + final Widget child; + + const FloatingAppBarAction({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: Spacing.inputPadding), + child: Center(child: child), + ); + } +} + +// ============================================================================= +// EXISTING COMPONENTS +// ============================================================================= + class ConduitButton extends ConsumerWidget { final String text; final VoidCallback? onPressed; From 8fa69fc1656a5112a4938e7ec747e33390ab5764 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:23:24 +0530 Subject: [PATCH 4/5] refactor(ui): Replace default AppBar with FloatingAppBar components --- lib/features/auth/views/sso_auth_page.dart | 26 ++++++++------------ lib/features/chat/views/voice_call_page.dart | 13 ++++++---- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/lib/features/auth/views/sso_auth_page.dart b/lib/features/auth/views/sso_auth_page.dart index 4e08725..8c5560c 100644 --- a/lib/features/auth/views/sso_auth_page.dart +++ b/lib/features/auth/views/sso_auth_page.dart @@ -478,25 +478,19 @@ class _SsoAuthPageState extends ConsumerState { return ErrorBoundary( child: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, - appBar: AppBar( - backgroundColor: context.conduitTheme.surfaceBackground, - elevation: 0, - leading: ConduitIconButton( - icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back, - onPressed: () => context.pop(), - tooltip: l10n?.back ?? 'Back', + extendBodyBehindAppBar: true, + appBar: FloatingAppBar( + leading: FloatingAppBarBackButton( + onTap: () => context.pop(), ), - title: Text( - l10n?.sso ?? 'SSO', - style: context.conduitTheme.headingMedium, - ), - centerTitle: true, + title: FloatingAppBarTitle(text: l10n?.sso ?? 'SSO'), actions: [ if (_controller != null) - ConduitIconButton( - icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, - onPressed: _refresh, - tooltip: l10n?.retry ?? 'Retry', + FloatingAppBarAction( + child: FloatingAppBarIconButton( + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + onTap: _refresh, + ), ), ], ), diff --git a/lib/features/chat/views/voice_call_page.dart b/lib/features/chat/views/voice_call_page.dart index 741b36c..743f28a 100644 --- a/lib/features/chat/views/voice_call_page.dart +++ b/lib/features/chat/views/voice_call_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -8,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/utils/markdown_to_text.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../shared/widgets/conduit_components.dart'; import '../providers/chat_providers.dart'; import '../services/voice_call_service.dart'; @@ -155,16 +157,17 @@ class _VoiceCallPageState extends ConsumerState return Scaffold( backgroundColor: backgroundColor, - appBar: AppBar( - title: Text(l10n.voiceCallTitle), - leading: IconButton( - icon: const Icon(CupertinoIcons.xmark), - onPressed: () async { + extendBodyBehindAppBar: true, + appBar: FloatingAppBar( + leading: FloatingAppBarIconButton( + icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close, + onTap: () async { await _service?.stopCall(); if (!context.mounted) return; Navigator.of(context).pop(); }, ), + title: FloatingAppBarTitle(text: l10n.voiceCallTitle), ), body: SafeArea( child: Column( From 9ec7fdadde2fdc1d1482b57c48a205d320bf872c Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:31:40 +0530 Subject: [PATCH 5/5] feat(chat): Add loading state for conversation title and model selector --- lib/features/chat/views/chat_page.dart | 220 ++++++++++++++++--------- 1 file changed, 144 insertions(+), 76 deletions(-) diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index f6de623..917c1d4 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -1502,11 +1502,14 @@ class _ChatPageState extends ConsumerState { trimmedConversationTitle.isNotEmpty) ? trimmedConversationTitle : null; + // Watch loading state for app bar skeleton + final isLoadingConversation = ref.watch(isLoadingConversationProvider); final formattedModelName = selectedModel != null ? _formatModelDisplayName(selectedModel.name) : null; final modelLabel = formattedModelName ?? l10n.chooseModel; - final hasConversationTitle = displayConversationTitle != null; + final hasConversationTitle = + displayConversationTitle != null || isLoadingConversation; final TextStyle modelTextStyle = hasConversationTitle ? AppTypography.small.copyWith( color: context.conduitTheme.textSecondary, @@ -1746,8 +1749,27 @@ class _ChatPageState extends ConsumerState { : LayoutBuilder( builder: (context, constraints) { // Build title pill (tappable for context menu) + // Show skeleton when loading, actual title otherwise Widget? titlePill; - if (displayConversationTitle != null) { + if (isLoadingConversation) { + // Show skeleton pill while loading conversation + titlePill = _buildAppBarPill( + context: context, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: ConduitLoading.skeleton( + width: 120, + height: 18, + borderRadius: BorderRadius.circular( + AppBorderRadius.sm, + ), + ), + ), + ); + } else if (displayConversationTitle != null) { titlePill = GestureDetector( onTap: () { final conversation = ref.read( @@ -1795,95 +1817,141 @@ class _ChatPageState extends ConsumerState { } // Build model selector pill - final modelPill = GestureDetector( - onTap: () async { - final modelsAsync = ref.read(modelsProvider); - - if (modelsAsync.isLoading) { - try { - final models = await ref.read( - modelsProvider.future, - ); - if (!mounted) return; - // ignore: use_build_context_synchronously - _showModelDropdown(context, ref, models); - } catch (e) { - DebugLogger.error( - 'model-load-failed', - scope: 'chat/model-selector', - error: e, - ); - } - } else if (modelsAsync.hasValue) { - _showModelDropdown( - context, - ref, - modelsAsync.value!, - ); - } else if (modelsAsync.hasError) { - try { - ref.invalidate(modelsProvider); - final models = await ref.read( - modelsProvider.future, - ); - if (!mounted) return; - // ignore: use_build_context_synchronously - _showModelDropdown(context, ref, models); - } catch (e) { - DebugLogger.error( - 'model-refresh-failed', - scope: 'chat/model-selector', - error: e, - ); - } - } - }, - child: _buildAppBarPill( + // Show skeleton when loading, actual model selector otherwise + final Widget modelPill; + if (isLoadingConversation) { + // Show skeleton pill while loading conversation + modelPill = _buildAppBarPill( context: context, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.sm, vertical: Spacing.xs, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: - constraints.maxWidth - - Spacing.xxl, - ), - child: MiddleEllipsisText( - modelLabel, - style: modelTextStyle, - textAlign: TextAlign.center, - semanticsLabel: modelLabel, - ), - ), - const SizedBox(width: Spacing.xs), - Icon( - Platform.isIOS - ? CupertinoIcons.chevron_down - : Icons.keyboard_arrow_down, - color: - context.conduitTheme.iconSecondary, - size: IconSize.small, - ), - ], + child: ConduitLoading.skeleton( + width: 80, + height: 14, + borderRadius: BorderRadius.circular( + AppBorderRadius.sm, + ), ), ), - ), - ); + ); + } else { + modelPill = GestureDetector( + onTap: () async { + final modelsAsync = ref.read(modelsProvider); + + if (modelsAsync.isLoading) { + try { + final models = await ref.read( + modelsProvider.future, + ); + if (!mounted) return; + // ignore: use_build_context_synchronously + _showModelDropdown(context, ref, models); + } catch (e) { + DebugLogger.error( + 'model-load-failed', + scope: 'chat/model-selector', + error: e, + ); + } + } else if (modelsAsync.hasValue) { + _showModelDropdown( + context, + ref, + modelsAsync.value!, + ); + } else if (modelsAsync.hasError) { + try { + ref.invalidate(modelsProvider); + final models = await ref.read( + modelsProvider.future, + ); + if (!mounted) return; + // ignore: use_build_context_synchronously + _showModelDropdown(context, ref, models); + } catch (e) { + DebugLogger.error( + 'model-refresh-failed', + scope: 'chat/model-selector', + error: e, + ); + } + } + }, + child: _buildAppBarPill( + context: context, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + constraints.maxWidth - + Spacing.xxl, + ), + child: MiddleEllipsisText( + modelLabel, + style: modelTextStyle, + textAlign: TextAlign.center, + semanticsLabel: modelLabel, + ), + ), + const SizedBox(width: Spacing.xs), + Icon( + Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.keyboard_arrow_down, + color: + context.conduitTheme.iconSecondary, + size: IconSize.small, + ), + ], + ), + ), + ), + ); + } return Column( mainAxisSize: MainAxisSize.min, children: [ if (titlePill != null) ...[ - titlePill, + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: KeyedSubtree( + key: ValueKey( + isLoadingConversation + ? 'loading' + : 'title-$displayConversationTitle', + ), + child: titlePill, + ), + ), const SizedBox(height: Spacing.xs), ], - modelPill, + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: KeyedSubtree( + key: ValueKey( + isLoadingConversation + ? 'model-loading' + : 'model-$modelLabel', + ), + child: modelPill, + ), + ), if (isReviewerMode) Padding( padding: const EdgeInsets.only(