feat(ai-scribe): reorganize abstractions and update dialog modal UI

This commit is contained in:
dab246
2025-12-18 02:51:43 +07:00
committed by Dat H. Pham
parent 7ddccf2e1c
commit 582b1ebe97
70 changed files with 2449 additions and 1352 deletions
@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
import 'package:scribe/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart';
void main() {
const screen = Size(800, 600);
const menuSize = Size(300, 200);
const anchorSize = Size(24, 24);
group('AnchoredModalLayoutCalculator placement strategy', () {
test('places menu to the RIGHT when there is enough horizontal space', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(100, 200),
anchorSize: anchorSize,
menuSize: menuSize,
),
);
expect(result.placement, ModalPlacement.right);
expect(result.position.dx, greaterThan(100));
});
test(
'places menu at the BOTTOM when right space is insufficient but bottom has enough space',
() {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(750, 200),
anchorSize: anchorSize,
menuSize: menuSize,
),
);
expect(result.placement, ModalPlacement.bottom);
expect(result.position.dy, greaterThan(200));
});
test(
'places menu at the TOP when right and bottom space are insufficient but top has space',
() {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(750, 500),
anchorSize: anchorSize,
menuSize: menuSize,
),
);
expect(result.placement, ModalPlacement.top);
expect(result.position.dy, lessThan(500));
});
test(
'places menu to the LEFT when neither top nor bottom has enough vertical space',
() {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: Size(800, 260),
anchorPosition: Offset(750, 120),
anchorSize: anchorSize,
menuSize: menuSize,
),
);
expect(result.placement, ModalPlacement.left);
expect(result.position.dx, lessThan(750));
});
test('falls back to LEFT placement when no direction has sufficient space',
() {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: Size(320, 480),
anchorPosition: Offset(150, 200),
anchorSize: anchorSize,
menuSize: Size(300, 400),
),
);
expect(result.placement, ModalPlacement.left);
});
});
group('AnchoredModalLayoutCalculator screen clamping', () {
test('clamps LEFT position to screen padding', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(5, 200),
anchorSize: anchorSize,
menuSize: menuSize,
),
padding: 16,
);
expect(result.position.dx, greaterThanOrEqualTo(16));
});
test('clamps TOP position to screen padding', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(200, 5),
anchorSize: anchorSize,
menuSize: menuSize,
),
padding: 16,
);
expect(result.position.dy, greaterThanOrEqualTo(16));
});
test('does not overflow RIGHT screen edge', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(780, 200),
anchorSize: anchorSize,
menuSize: menuSize,
),
padding: 16,
);
expect(
result.position.dx + menuSize.width,
lessThanOrEqualTo(screen.width - 16),
);
});
test('does not overflow BOTTOM screen edge', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(200, 580),
anchorSize: anchorSize,
menuSize: menuSize,
),
padding: 16,
);
expect(
result.position.dy + menuSize.height,
lessThanOrEqualTo(screen.height - 16),
);
});
});
}
@@ -1,202 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe.dart';
void main() {
group('calculateMenuDialogHeight::', () {
test('should calculate correct height with content and default 4 categories', () {
// Arrange
const hasContent = true;
const categoryCount = 4;
// Act
final height = calculateMenuDialogHeight(
hasContent: hasContent,
categoryCount: categoryCount,
);
// Assert
// 4 categories × 40px + 8px spacing + 48px bar = 216px
const expectedHeight = (4 * AIScribeSizes.menuItemHeight) +
AIScribeSizes.fieldSpacing +
AIScribeSizes.barHeight;
expect(height, expectedHeight);
expect(height, 216.0);
});
test('should calculate correct height with content and custom categories', () {
// Arrange
const hasContent = true;
const categoryCount = 2;
// Act
final height = calculateMenuDialogHeight(
hasContent: hasContent,
categoryCount: categoryCount,
);
// Assert
// 2 categories × 40px + 8px spacing + 48px bar = 136px
const expectedHeight = (2 * AIScribeSizes.menuItemHeight) +
AIScribeSizes.fieldSpacing +
AIScribeSizes.barHeight;
expect(height, expectedHeight);
expect(height, 136.0);
});
test('should calculate correct height without content (bar only)', () {
// Arrange
const hasContent = false;
const categoryCount = 4; // Should be ignored when hasContent is false
// Act
final height = calculateMenuDialogHeight(
hasContent: hasContent,
categoryCount: categoryCount,
);
// Assert
// Only the bar height
expect(height, AIScribeSizes.barHeight);
expect(height, 48.0);
});
});
group('calculateModalPosition::', () {
testWidgets('should adjust left position when modal would go off-screen to the right', (tester) async {
// Arrange
const screenSize = Size(800, 600);
const buttonPosition = Offset(700, 300); // Close to right edge
const modalWidth = 440.0;
BuildContext? capturedContext;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: screenSize),
child: Builder(
builder: (context) {
capturedContext = context;
return Container();
},
),
),
),
);
// Act
final position = calculateModalPosition(
context: capturedContext!,
buttonPosition: buttonPosition,
modalWidth: modalWidth,
);
// Assert
final expectedLeft = screenSize.width - modalWidth - AIScribeSizes.screenEdgePadding;
expect(position.left, expectedLeft);
});
testWidgets('should adjust left position to screenEdgePadding when modal would go off-screen to the left', (tester) async {
// Arrange
const screenSize = Size(800, 600);
const buttonPosition = Offset(5, 300); // Very close to left edge
const modalWidth = 440.0;
BuildContext? capturedContext;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: screenSize),
child: Builder(
builder: (context) {
capturedContext = context;
return Container();
},
),
),
),
);
// Act
final position = calculateModalPosition(
context: capturedContext!,
buttonPosition: buttonPosition,
modalWidth: modalWidth,
);
// Assert
expect(position.left, AIScribeSizes.screenEdgePadding);
});
testWidgets('should adjust bottom position when modal height is provided and would go off-screen to the top', (tester) async {
// Arrange
const screenSize = Size(800, 600);
const buttonPosition = Offset(100, 50); // Close to top
const modalWidth = 440.0;
const modalHeight = 400.0;
BuildContext? capturedContext;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: screenSize),
child: Builder(
builder: (context) {
capturedContext = context;
return Container();
},
),
),
),
);
// Act
final position = calculateModalPosition(
context: capturedContext!,
buttonPosition: buttonPosition,
modalWidth: modalWidth,
modalHeight: modalHeight,
);
// Assert
final expectedBottom = screenSize.height - modalHeight - AIScribeSizes.screenEdgePadding;
expect(position.bottom, expectedBottom);
});
testWidgets('should adjust bottom position to screenEdgePadding when calculated bottom is too small', (tester) async {
// Arrange
const screenSize = Size(800, 600);
const buttonPosition = Offset(100, 595); // Very close to bottom edge
const modalWidth = 440.0;
const modalHeight = 400.0;
BuildContext? capturedContext;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: screenSize),
child: Builder(
builder: (context) {
capturedContext = context;
return Container();
},
),
),
),
);
// Act
final position = calculateModalPosition(
context: capturedContext!,
buttonPosition: buttonPosition,
modalWidth: modalWidth,
modalHeight: modalHeight,
);
// Assert
// bottom = 600 - 595 + 8 = 13, which is < 16, so it should be adjusted to 16
expect(position.bottom, AIScribeSizes.screenEdgePadding);
});
});
}