diff --git a/docs/BUGS.md b/docs/BUGS.md index 7398092..102c77a 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -2,109 +2,3 @@ title: Bugs updated: 2026-03-18 --- - -## Checklist - -- [x] [1. Draft Save Redundancy — Cancel Prompts After Explicit Save](#1-draft-save-redundancy--cancel-prompts-after-explicit-save) -- [ ] [2. Character Counter — No Initial State](#2-character-counter--no-initial-state) -- [ ] [3. Composer Layout — Drafts Should Be Inline, Not Full-Screen](#3-composer-layout--drafts-should-be-inline-not-full-screen) - -## 1. Draft Save Redundancy — Cancel Prompts After Explicit Save - -**Status:** Broken — user can save a draft, then immediately be asked to save again on -cancel. - -**Problem:** The AppBar has both a "Save Draft" button (line 502) and a "Cancel" button -(line 497). If the user taps "Save Draft" → draft is saved and a snackbar confirms it. -If the user then taps "Cancel", `_handleBackNavigation` (line 426) checks `hasContent` -(line 430), which is still `true` because the text/media haven't been cleared. The user -is shown a "Save Draft?" dialog even though the draft was just saved moments ago. - -**Fix:** - -- Track whether the current content has been saved since the last edit. Add a - `isDraftDirty` (or `hasUnsavedChanges`) flag to `ComposeState`. -- Set `isDraftDirty: true` when text or media changes (`_onTextChanged`, - `_onMediaChanged`, etc.). -- Set `isDraftDirty: false` after a successful `DraftSaved` event. -- In `_handleBackNavigation`, check `isDraftDirty` instead of (or in addition to) - `hasContent`. If content exists but `isDraftDirty` is `false`, skip the dialog and - pop immediately. - -**Files:** - -- Edit: `lib/features/compose/bloc/compose_state.dart` — add `isDraftDirty` field - (default `true` for new compositions, `false` after draft load) -- Edit: `lib/features/compose/bloc/compose_bloc.dart` — set `isDraftDirty: true` in - `_onTextChanged` (line 62) and media-change handlers; set `isDraftDirty: false` in - `_onDraftSaved` (line 214) and `_onDraftLoaded` (line 241) -- Edit: `lib/features/compose/presentation/compose_screen.dart` — - `_handleBackNavigation` (line 426): gate the dialog on `state.isDraftDirty` rather - than just `hasContent` - -## 2. Character Counter — No Initial State - -**Status:** Incomplete — counter ring starts empty and invisible until the user types. - -**Problem:** `_CharCounter` (compose_screen.dart, line 895) only shows the remaining -character count text when `count > 0` (line 918). On an empty compose screen the user -sees a bare progress ring at 0% with no text — there is no indication of the 300-character -limit. When loading a draft, the counter jumps from nothing to whatever the draft's count -is, which feels jarring. - -The progress ring itself also starts as just the background circle with no fill, giving -no visual cue about what it represents. - -**Fix:** - -- Always show the remaining count text, even when `count == 0`. Remove the `if (count > 0)` - guard so the counter displays `300` on an empty compose screen. -- This gives users an immediate signal: "you have 300 characters" — matching the behavior - of the official Bluesky app and Twitter/X composer. - -**Files:** - -- Edit: `lib/features/compose/presentation/compose_screen.dart` — `_CharCounter.build` - (line 918): remove the `if (count > 0)` condition so the remaining count is always - visible - -## 3. Composer Layout — Drafts Should Be Inline, Not Full-Screen - -**Status:** UX issue — drafts open as a modal bottom sheet that covers the composer. - -**Problem:** Tapping the drafts button (line 805) calls `_showDraftsDialog` (line 252), -which opens a `showModalBottomSheet` with a `DraggableScrollableSheet` taking 60–90% of -the screen. This obscures the compose area entirely, breaking the user's context. The -overall composer is also described as "colossal" — the full-screen layout with the modal -drafts on top makes it feel heavy. - -The desired behavior is: drafts should appear inline, sharing the screen with the -compose area, and be toggleable open/closed. - -**Fix:** - -- Replace the `showModalBottomSheet` drafts dialog with an inline, collapsible drafts - panel that sits below the compose text field (or above the bottom toolbar). -- Use an `AnimatedContainer` or `ExpansionTile`-style widget that expands/collapses - when the drafts button is toggled. -- When expanded, the drafts panel should take roughly half the available space, with the - compose text field shrinking to accommodate it. The text field remains visible and - editable above. -- When collapsed, the panel is fully hidden and the compose area reclaims the space. -- Add a toggle state (e.g. `_showDrafts` boolean in the screen's `State`) controlled by - the existing drafts `IconButton` (line 804). -- Keep the same drafts list UI (ListTile with content preview, time, delete button, tap - to load) — just move it from a modal into the inline panel. - -**Files:** - -- Edit: `lib/features/compose/presentation/compose_screen.dart`: - - Add `_showDrafts` state variable to `_ComposeScreenState` - - Replace `_showDraftsDialog()` call on the drafts button (line 805) with a - `setState(() => _showDrafts = !_showDrafts)` toggle - - Add an inline drafts panel widget between the text field / media area and the - bottom toolbar (around line 767), wrapped in an `AnimatedSize` or similar for - smooth expand/collapse - - Remove or repurpose `_showDraftsDialog()` (lines 252-374) — extract the list - content into a reusable `_DraftsPanel` widget used by the inline panel - - Fire `DraftsRequested` event when the panel is opened (same as current behavior) diff --git a/lib/features/compose/presentation/compose_screen.dart b/lib/features/compose/presentation/compose_screen.dart index 049a63f..3e51c57 100644 --- a/lib/features/compose/presentation/compose_screen.dart +++ b/lib/features/compose/presentation/compose_screen.dart @@ -40,6 +40,7 @@ class ComposeScreen extends StatefulWidget { class _ComposeScreenState extends State { late final _FacetHighlightController _textController; final ImagePicker _imagePicker = ImagePicker(); + bool _showDrafts = false; @override void initState() { @@ -249,128 +250,12 @@ class _ComposeScreenState extends State { } } - Future _showDraftsDialog() async { - final bloc = context.read(); - bloc.add(const DraftsRequested()); - - await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => BlocProvider.value( - value: bloc, - child: BlocBuilder( - builder: (context, state) { - if (state.isLoadingDrafts) { - return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator())); - } - - if (state.drafts.isEmpty) { - return const SizedBox(height: 150, child: Center(child: Text('No drafts saved'))); - } - - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.3, - maxChildSize: 0.9, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: _theme.dividerColor)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Drafts', style: _theme.textTheme.titleLarge), - Text( - '${state.drafts.length} draft${state.drafts.length != 1 ? 's' : ''}', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: _theme.colorScheme.onSurfaceVariant), - ), - ], - ), - ), - Expanded( - child: ListView.builder( - controller: scrollController, - itemCount: state.drafts.length, - itemBuilder: (context, index) { - final draft = state.drafts[index]; - return ListTile( - title: Text( - draft.content.isEmpty ? '(No text)' : draft.content, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: Row( - children: [ - Text(_formatDraftTime(draft.updatedAt), style: _theme.textTheme.bodySmall), - if (draft.scheduledAt != null) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: _theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'Scheduled', - style: _theme.textTheme.bodySmall?.copyWith( - color: _theme.colorScheme.onPrimaryContainer, - ), - ), - ), - ], - ], - ), - trailing: IconButton( - icon: Icon(Icons.delete_outline, color: _theme.colorScheme.error), - onPressed: () { - final bloc = context.read(); - final theme = _theme; - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete Draft?'), - content: const Text('This action cannot be undone.'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: Text('Delete', style: TextStyle(color: theme.colorScheme.error)), - ), - ], - ), - ).then((confirmed) { - if (confirmed == true && mounted) { - bloc.add(DraftDeleted(draft.id)); - } - }); - }, - ), - onTap: () { - Navigator.pop(context); - context.read().add(DraftLoaded(draft.id)); - }, - ); - }, - ), - ), - ], - ); - }, - ); - }, - ), - ), - ); + void _toggleDrafts() { + final willShow = !_showDrafts; + setState(() => _showDrafts = willShow); + if (willShow) { + context.read().add(const DraftsRequested()); + } } String _formatDraftTime(DateTime dateTime) { @@ -405,6 +290,121 @@ class _ComposeScreenState extends State { }; } + Widget _buildDraftsPanel() { + return BlocBuilder( + builder: (context, state) { + return Container( + constraints: const BoxConstraints(maxHeight: 280), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: _theme.dividerColor)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Drafts', style: _theme.textTheme.titleMedium), + if (state.drafts.isNotEmpty) + Text( + '${state.drafts.length} draft${state.drafts.length != 1 ? 's' : ''}', + style: _theme.textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + if (state.isLoadingDrafts) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (state.drafts.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text('No drafts saved', style: _theme.textTheme.bodyMedium?.copyWith(color: _theme.colorScheme.onSurfaceVariant)), + ), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: state.drafts.length, + itemBuilder: (context, index) { + final draft = state.drafts[index]; + return ListTile( + dense: true, + title: Text( + draft.content.isEmpty ? '(No text)' : draft.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + Text(_formatDraftTime(draft.updatedAt), style: _theme.textTheme.bodySmall), + if (draft.scheduledAt != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Scheduled', + style: _theme.textTheme.bodySmall?.copyWith( + color: _theme.colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ], + ), + trailing: IconButton( + icon: Icon(Icons.delete_outline, color: _theme.colorScheme.error), + onPressed: () { + final bloc = context.read(); + final theme = _theme; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Delete Draft?'), + content: const Text('This action cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text('Delete', style: TextStyle(color: theme.colorScheme.error)), + ), + ], + ), + ).then((confirmed) { + if (confirmed == true && mounted) { + bloc.add(DraftDeleted(draft.id)); + } + }); + }, + ), + onTap: () { + setState(() => _showDrafts = false); + context.read().add(DraftLoaded(draft.id)); + }, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + ThemeData get _theme => Theme.of(context); void _submitPost() { @@ -764,6 +764,11 @@ class _ComposeScreenState extends State { ); }, ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: _showDrafts ? _buildDraftsPanel() : const SizedBox.shrink(), + ), const SizedBox(height: 8), Container( decoration: BoxDecoration( @@ -802,7 +807,7 @@ class _ComposeScreenState extends State { }, ), IconButton( - onPressed: _showDraftsDialog, + onPressed: _toggleDrafts, icon: Icon(Icons.drive_file_rename_outline, color: _theme.colorScheme.primary), tooltip: 'Drafts', ), @@ -915,13 +920,12 @@ class _CharCounter extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - if (count > 0) - Text( - '$remaining', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: color, fontFeatures: const [FontFeature.tabularFigures()]), - ), + Text( + '$remaining', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: color, fontFeatures: const [FontFeature.tabularFigures()]), + ), const SizedBox(width: 8), SizedBox( width: 28, diff --git a/test/features/compose/presentation/compose_screen_test.dart b/test/features/compose/presentation/compose_screen_test.dart new file mode 100644 index 0000000..c8ed0b0 --- /dev/null +++ b/test/features/compose/presentation/compose_screen_test.dart @@ -0,0 +1,188 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; +import 'package:lazurite/features/compose/presentation/compose_screen.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockComposeBloc extends MockBloc implements ComposeBloc {} + +class FakeDraftsCompanion extends Fake implements DraftsCompanion {} + +DraftEntry _makeDraft({int id = 1, String content = 'Draft'}) => DraftEntry( + id: id, + accountDid: 'did:plc:test', + content: content, + mediaPaths: null, + embedJson: null, + replyUri: null, + replyCid: null, + rootUri: null, + rootCid: null, + createdAt: DateTime(2025, 1, 1), + updatedAt: DateTime(2025, 1, 1), + scheduledAt: null, +); + +void main() { + late MockComposeBloc mockBloc; + + setUp(() { + registerFallbackValue(FakeDraftsCompanion()); + registerFallbackValue(const TextChanged('')); + mockBloc = MockComposeBloc(); + }); + + tearDown(() => mockBloc.close()); + + Widget buildSubject() => MaterialApp( + home: BlocProvider.value(value: mockBloc, child: const ComposeScreen()), + ); + + void seedState(ComposeState state) { + whenListen(mockBloc, Stream.value(state), initialState: state); + } + + group('ComposeScreen', () { + group('character counter (Bug #2)', () { + testWidgets('shows 300 on empty compose screen', (tester) async { + seedState(const ComposeState.ready()); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('300'), findsOneWidget); + }); + + testWidgets('shows updated remaining count when graphemeCount changes', (tester) async { + seedState(const ComposeState.ready(graphemeCount: 5)); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('295'), findsOneWidget); + }); + + testWidgets('shows negative remaining count when over limit', (tester) async { + seedState(const ComposeState.ready(graphemeCount: 305, isOverLimit: true)); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('-5'), findsOneWidget); + }); + }); + + group('inline drafts panel (Bug #3)', () { + testWidgets('drafts panel is hidden initially', (tester) async { + seedState(const ComposeState.ready()); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('Drafts'), findsNothing); + }); + + testWidgets('tapping drafts button shows inline panel without BottomSheet', (tester) async { + seedState(const ComposeState.ready()); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.drive_file_rename_outline)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Drafts'), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + }); + + testWidgets('tapping drafts button again hides panel', (tester) async { + seedState(const ComposeState.ready()); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.drive_file_rename_outline)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Drafts'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.drive_file_rename_outline)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Drafts'), findsNothing); + }); + + testWidgets('shows empty state when no drafts loaded', (tester) async { + seedState(const ComposeState.ready().copyWith(drafts: [], isLoadingDrafts: false)); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.drive_file_rename_outline)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('No drafts saved'), findsOneWidget); + }); + + testWidgets('shows draft items when drafts are loaded', (tester) async { + seedState(const ComposeState.ready().copyWith(drafts: [_makeDraft(content: 'My saved draft')], isLoadingDrafts: false)); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.drive_file_rename_outline)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('My saved draft'), findsOneWidget); + }); + + testWidgets('shows loading indicator while drafts are loading', (tester) async { + seedState(const ComposeState.ready().copyWith(isLoadingDrafts: true)); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.drive_file_rename_outline)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('fires DraftsRequested when panel opens', (tester) async { + seedState(const ComposeState.ready()); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.drive_file_rename_outline)); + await tester.pump(); + + verify(() => mockBloc.add(const DraftsRequested())).called(1); + }); + + testWidgets('tapping a draft fires DraftLoaded and closes panel', (tester) async { + seedState(const ComposeState.ready().copyWith(drafts: [_makeDraft(id: 7, content: 'Tap to load me')])); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.drive_file_rename_outline)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.tap(find.text('Tap to load me')); + await tester.pump(); + + verify(() => mockBloc.add(const DraftLoaded(7))).called(1); + expect(find.text('Drafts'), findsNothing); + }); + }); + }); +} diff --git a/test/features/feed/cubit/saved_posts_cubit_test.dart b/test/features/feed/cubit/saved_posts_cubit_test.dart index b6cc4f3..90d30f7 100644 --- a/test/features/feed/cubit/saved_posts_cubit_test.dart +++ b/test/features/feed/cubit/saved_posts_cubit_test.dart @@ -550,7 +550,7 @@ void main() { bookmarks: [ BookmarkView( subject: RepoStrongRef(uri: testUri, cid: 'cid1'), - item: UBookmarkViewItem.unknown(data: {}), + item: const UBookmarkViewItem.unknown(data: {}), ), ], ), @@ -590,7 +590,7 @@ void main() { bookmarks: [ BookmarkView( subject: RepoStrongRef(uri: testUri, cid: 'cid1'), - item: UBookmarkViewItem.unknown(data: {}), + item: const UBookmarkViewItem.unknown(data: {}), ), ], ),