// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:math' as math;
import 'dart:ui' show window;

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

import '../widgets/semantics_tester.dart';

const List<String> menuItems = <String>['one', 'two', 'three', 'four'];

final Type dropdownButtonType = new DropdownButton<String>(
  onChanged: (_) { },
  items: const <DropdownMenuItem<String>>[]
).runtimeType;

Widget buildFrame({
  Key buttonKey,
  String value = 'two',
  ValueChanged<String> onChanged,
  bool isDense = false,
  Widget hint,
  List<String> items = menuItems,
  Alignment alignment = Alignment.center,
  TextDirection textDirection = TextDirection.ltr,
}) {
  return new TestApp(
    textDirection: textDirection,
    child: new Material(
      child: new Align(
        alignment: alignment,
        child: new DropdownButton<String>(
          key: buttonKey,
          value: value,
          hint: hint,
          onChanged: onChanged,
          isDense: isDense,
          items: items.map((String item) {
            return new DropdownMenuItem<String>(
              key: new ValueKey<String>(item),
              value: item,
              child: new Text(item, key: new ValueKey<String>(item + 'Text')),
            );
          }).toList(),
        ),
      ),
    ),
  );
}

class TestApp extends StatefulWidget {
  const TestApp({ this.textDirection, this.child });
  final TextDirection textDirection;
  final Widget child;
  @override
  _TestAppState createState() => new _TestAppState();
}

class _TestAppState extends State<TestApp> {
  @override
  Widget build(BuildContext context) {
    return new Localizations(
      locale: const Locale('en', 'US'),
      delegates: const <LocalizationsDelegate<dynamic>>[
        DefaultWidgetsLocalizations.delegate,
        DefaultMaterialLocalizations.delegate,
      ],
      child: new MediaQuery(
        data: new MediaQueryData.fromWindow(window),
        child: new Directionality(
          textDirection: widget.textDirection,
          child: new Navigator(
            onGenerateRoute: (RouteSettings settings) {
              assert(settings.name == '/');
              return new MaterialPageRoute<void>(
                settings: settings,
                builder: (BuildContext context) => widget.child,
              );
            },
          ),
        ),
      ),
    );
  }
}

// When the dropdown's menu is popped up, a RenderParagraph for the selected
// menu's text item will appear both in the dropdown button and in the menu.
// The RenderParagraphs should be aligned, i.e. they should have the same
// size and location.
void checkSelectedItemTextGeometry(WidgetTester tester, String value) {
  final List<RenderBox> boxes = tester.renderObjectList<RenderBox>(find.byKey(new ValueKey<String>(value + 'Text'))).toList();
  expect(boxes.length, equals(2));
  final RenderBox box0 = boxes[0];
  final RenderBox box1 = boxes[1];
  expect(box0.localToGlobal(Offset.zero), equals(box1.localToGlobal(Offset.zero)));
  expect(box0.size, equals(box1.size));
}

bool sameGeometry(RenderBox box1, RenderBox box2) {
  expect(box1.localToGlobal(Offset.zero), equals(box2.localToGlobal(Offset.zero)));
  expect(box1.size.height, equals(box2.size.height));
  return true;
}

void main() {
  testWidgets('Dropdown button control test', (WidgetTester tester) async {
    String value = 'one';
    void didChangeValue(String newValue) {
      value = newValue;
    }

    Widget build() => buildFrame(value: value, onChanged: didChangeValue);

    await tester.pumpWidget(build());

    await tester.tap(find.text('one'));
    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    expect(value, equals('one'));

    await tester.tap(find.text('three').last);

    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    expect(value, equals('three'));

    await tester.tap(find.text('three'));
    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    expect(value, equals('three'));

    await tester.pumpWidget(build());

    await tester.tap(find.text('two').last);

    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    expect(value, equals('two'));
  });

  testWidgets('Dropdown button with no app', (WidgetTester tester) async {
    String value = 'one';
    void didChangeValue(String newValue) {
      value = newValue;
    }

    Widget build() {
      return new Directionality(
        textDirection: TextDirection.ltr,
        child: new Navigator(
          initialRoute: '/',
          onGenerateRoute: (RouteSettings settings) {
            return new MaterialPageRoute<void>(
              settings: settings,
              builder: (BuildContext context) {
                return new Material(
                  child: buildFrame(value: 'one', onChanged: didChangeValue),
                );
              },
            );
          },
        ),
      );
    }

    await tester.pumpWidget(build());

    await tester.tap(find.text('one'));
    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    expect(value, equals('one'));

    await tester.tap(find.text('three').last);

    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    expect(value, equals('three'));

    await tester.tap(find.text('three'));
    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    expect(value, equals('three'));

    await tester.pumpWidget(build());

    await tester.tap(find.text('two').last);

    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    expect(value, equals('two'));
  });

  testWidgets('Dropdown in ListView', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/12053
    // Positions a DropdownButton at the left and right edges of the screen,
    // forcing it to be sized down to the viewport width
    const String value = 'foo';
    final UniqueKey itemKey = new UniqueKey();
    await tester.pumpWidget(
      new MaterialApp(
        home: new Material(
          child: new ListView(
            children: <Widget>[
              new DropdownButton<String>(
                value: value,
                items: <DropdownMenuItem<String>>[
                  new DropdownMenuItem<String>(
                    key: itemKey,
                    value: value,
                    child: const Text(value),
                  ),
                ],
                onChanged: (_) {},
              ),
            ],
          ),
        ),
      ),
    );
    await tester.tap(find.text(value));
    await tester.pump();
    final List<RenderBox> itemBoxes = tester.renderObjectList<RenderBox>(find.byKey(itemKey)).toList();
    expect(itemBoxes[0].localToGlobal(Offset.zero).dx, equals(0.0));
    expect(itemBoxes[1].localToGlobal(Offset.zero).dx, equals(16.0));
    expect(itemBoxes[1].size.width, equals(800.0 - 16.0 * 2));
  });

  testWidgets('Dropdown screen edges', (WidgetTester tester) async {
    int value = 4;
    final List<DropdownMenuItem<int>> items = <DropdownMenuItem<int>>[];
    for (int i = 0; i < 20; ++i)
      items.add(new DropdownMenuItem<int>(value: i, child: new Text('$i')));

    void handleChanged(int newValue) {
      value = newValue;
    }

    final DropdownButton<int> button = new DropdownButton<int>(
      value: value,
      onChanged: handleChanged,
      items: items,
    );

    await tester.pumpWidget(
      new MaterialApp(
        home: new Material(
          child: new Align(
            alignment: Alignment.topCenter,
            child: button,
          ),
        ),
      ),
    );

    await tester.tap(find.text('4'));
    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    // We should have two copies of item 5, one in the menu and one in the
    // button itself.
    expect(tester.elementList(find.text('5')), hasLength(2));

    // We should only have one copy of item 19, which is in the button itself.
    // The copy in the menu shouldn't be in the tree because it's off-screen.
    expect(tester.elementList(find.text('19')), hasLength(1));

    expect(value, 4);
    await tester.tap(find.byWidget(button));
    expect(value, 4);
    // this waits for the route's completer to complete, which calls handleChanged
    await tester.idle();
    expect(value, 4);

    // TODO(abarth): Remove these calls to pump once navigator cleans up its
    // pop transitions.
    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation
  });

  for (TextDirection textDirection in TextDirection.values) {
    testWidgets('Dropdown button aligns selected menu item ($textDirection)', (WidgetTester tester) async {
      final Key buttonKey = new UniqueKey();
      const String value = 'two';

      Widget build() => buildFrame(buttonKey: buttonKey, value: value, textDirection: textDirection);

      await tester.pumpWidget(build());
      final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey));
      assert(buttonBox.attached);
      final Offset buttonOriginBeforeTap = buttonBox.localToGlobal(Offset.zero);

      await tester.tap(find.text('two'));
      await tester.pump();
      await tester.pump(const Duration(seconds: 1)); // finish the menu animation

      // Tapping the dropdown button should not cause it to move.
      expect(buttonBox.localToGlobal(Offset.zero), equals(buttonOriginBeforeTap));

      // The selected dropdown item is both in menu we just popped up, and in
      // the IndexedStack contained by the dropdown button. Both of them should
      // have the same origin and height as the dropdown button.
      final List<RenderObject> itemBoxes = tester.renderObjectList(find.byKey(const ValueKey<String>('two'))).toList();
      expect(itemBoxes.length, equals(2));
      for (RenderBox itemBox in itemBoxes) {
        assert(itemBox.attached);
        assert(textDirection != null);
        switch (textDirection) {
          case TextDirection.rtl:
            expect(buttonBox.localToGlobal(buttonBox.size.bottomRight(Offset.zero)),
                   equals(itemBox.localToGlobal(itemBox.size.bottomRight(Offset.zero))));
            break;
          case TextDirection.ltr:
            expect(buttonBox.localToGlobal(Offset.zero), equals(itemBox.localToGlobal(Offset.zero)));
            break;
        }
        expect(buttonBox.size.height, equals(itemBox.size.height));
      }

      // The two RenderParagraph objects, for the 'two' items' Text children,
      // should have the same size and location.
      checkSelectedItemTextGeometry(tester, 'two');

      await tester.pumpWidget(new Container()); // reset test
    });
  }

  testWidgets('Dropdown button with isDense:true aligns selected menu item', (WidgetTester tester) async {
    final Key buttonKey = new UniqueKey();
    const String value = 'two';

    Widget build() => buildFrame(buttonKey: buttonKey, value: value, isDense: true);

    await tester.pumpWidget(build());
    final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey));
    assert(buttonBox.attached);

    await tester.tap(find.text('two'));
    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    // The selected dropdown item is both in menu we just popped up, and in
    // the IndexedStack contained by the dropdown button. Both of them should
    // have the same vertical center as the button.
    final List<RenderBox> itemBoxes = tester.renderObjectList<RenderBox>(find.byKey(const ValueKey<String>('two'))).toList();
    expect(itemBoxes.length, equals(2));

    // When isDense is true, the button's height is reduced. The menu items'
    // heights are not.
    final double menuItemHeight = itemBoxes.map((RenderBox box) => box.size.height).reduce(math.max);
    expect(menuItemHeight, greaterThan(buttonBox.size.height));

    for (RenderBox itemBox in itemBoxes) {
      assert(itemBox.attached);
      final Offset buttonBoxCenter = buttonBox.size.center(buttonBox.localToGlobal(Offset.zero));
      final Offset itemBoxCenter = itemBox.size.center(itemBox.localToGlobal(Offset.zero));
      expect(buttonBoxCenter.dy, equals(itemBoxCenter.dy));
    }

    // The two RenderParagraph objects, for the 'two' items' Text children,
    // should have the same size and location.
    checkSelectedItemTextGeometry(tester, 'two');
  });

  testWidgets('Size of DropdownButton with null value', (WidgetTester tester) async {
    final Key buttonKey = new UniqueKey();
    String value;

    Widget build() => buildFrame(buttonKey: buttonKey, value: value);

    await tester.pumpWidget(build());
    final RenderBox buttonBoxNullValue = tester.renderObject(find.byKey(buttonKey));
    assert(buttonBoxNullValue.attached);


    value = 'three';
    await tester.pumpWidget(build());
    final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey));
    assert(buttonBox.attached);

    // A Dropdown button with a null value should be the same size as a
    // one with a non-null value.
    expect(buttonBox.localToGlobal(Offset.zero), equals(buttonBoxNullValue.localToGlobal(Offset.zero)));
    expect(buttonBox.size, equals(buttonBoxNullValue.size));
  });

  testWidgets('Layout of a DropdownButton with null value', (WidgetTester tester) async {
    final Key buttonKey = new UniqueKey();
    String value;

    void onChanged(String newValue) {
      value = newValue;
    }

    Widget build() => buildFrame(buttonKey: buttonKey, value: value, onChanged: onChanged);

    await tester.pumpWidget(build());
    final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey));
    assert(buttonBox.attached);

    // Show the menu.
    await tester.tap(find.byKey(buttonKey));
    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    // Tap on item 'one', which must appear over the button.
    await tester.tap(find.byKey(buttonKey));
    await tester.pump();
    await tester.pump(const Duration(seconds: 1)); // finish the menu animation

    await tester.pumpWidget(build());
    expect(value, equals('one'));
  });

  testWidgets('Size of DropdownButton with null value and a hint', (WidgetTester tester) async {
    final Key buttonKey = new UniqueKey();
    String value;

    // The hint will define the dropdown's width
    Widget build() => buildFrame(buttonKey: buttonKey, value: value, hint: const Text('onetwothree'));

    await tester.pumpWidget(build());
    expect(find.text('onetwothree'), findsOneWidget);
    final RenderBox buttonBoxHintValue = tester.renderObject(find.byKey(buttonKey));
    assert(buttonBoxHintValue.attached);


    value = 'three';
    await tester.pumpWidget(build());
    final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey));
    assert(buttonBox.attached);

    // A Dropdown button with a null value and a hint should be the same size as a
    // one with a non-null value.
    expect(buttonBox.localToGlobal(Offset.zero), equals(buttonBoxHintValue.localToGlobal(Offset.zero)));
    expect(buttonBox.size, equals(buttonBoxHintValue.size));
  });

  testWidgets('Dropdown menus must fit within the screen', (WidgetTester tester) async {

    // The dropdown menu isn't readily accessible. To find it we're assuming that it
    // contains a ListView and that it's an instance of _DropdownMenu.
    Rect getMenuRect() {
      Rect menuRect;
      tester.element(find.byType(ListView)).visitAncestorElements((Element element) {
        if (element.toString().startsWith('_DropdownMenu')) {
          final RenderBox box = element.findRenderObject();
          assert(box != null);
          menuRect = box.localToGlobal(Offset.zero) & box.size;
          return false;
        }
        return true;
      });
      assert(menuRect != null);
      return menuRect;
    }

    // In all of the tests that follow we're assuming that the dropdown menu
    // is horizontally aligned with the center of the dropdown button and padded
    // on the top, left, and right.
    const EdgeInsets buttonPadding = EdgeInsets.only(top: 8.0, left: 16.0, right: 24.0);

    Rect getExpandedButtonRect() {
      final RenderBox box = tester.renderObject<RenderBox>(find.byType(dropdownButtonType));
      final Rect buttonRect = box.localToGlobal(Offset.zero) & box.size;
      return buttonPadding.inflateRect(buttonRect);
    }

    Rect buttonRect;
    Rect menuRect;

    Future<Null> popUpAndDown(Widget frame) async {
      await tester.pumpWidget(frame);
      await tester.tap(find.byType(dropdownButtonType));
      await tester.pumpAndSettle();
      menuRect = getMenuRect();
      buttonRect = getExpandedButtonRect();
      await tester.tap(find.byType(dropdownButtonType));
    }

    // Dropdown button is along the top of the app. The top of the menu is
    // aligned with the top of the expanded button and shifted horizontally
    // so that it fits within the frame.

    await popUpAndDown(
      buildFrame(alignment: Alignment.topLeft, value: menuItems.last)
    );
    expect(menuRect.topLeft, Offset.zero);
    expect(menuRect.topRight, new Offset(menuRect.width, 0.0));

    await popUpAndDown(
      buildFrame(alignment: Alignment.topCenter, value: menuItems.last)
    );
    expect(menuRect.topLeft, new Offset(buttonRect.left, 0.0));
    expect(menuRect.topRight, new Offset(buttonRect.right, 0.0));

    await popUpAndDown(
      buildFrame(alignment: Alignment.topRight, value: menuItems.last)
    );
    expect(menuRect.topLeft, new Offset(800.0 - menuRect.width, 0.0));
    expect(menuRect.topRight, const Offset(800.0, 0.0));

    // Dropdown button is along the middle of the app. The top of the menu is
    // aligned with the top of the expanded button (because the 1st item
    // is selected) and shifted horizontally so that it fits within the frame.

    await popUpAndDown(
      buildFrame(alignment: Alignment.centerLeft, value: menuItems.first)
    );
    expect(menuRect.topLeft, new Offset(0.0, buttonRect.top));
    expect(menuRect.topRight, new Offset(menuRect.width, buttonRect.top));

    await popUpAndDown(
      buildFrame(alignment: Alignment.center, value: menuItems.first)
    );
    expect(menuRect.topLeft, buttonRect.topLeft);
    expect(menuRect.topRight, buttonRect.topRight);

    await popUpAndDown(
      buildFrame(alignment: Alignment.centerRight, value: menuItems.first)
    );
    expect(menuRect.topLeft, new Offset(800.0 - menuRect.width, buttonRect.top));
    expect(menuRect.topRight, new Offset(800.0, buttonRect.top));

    // Dropdown button is along the bottom of the app. The bottom of the menu is
    // aligned with the bottom of the expanded button and shifted horizontally
    // so that it fits within the frame.

    await popUpAndDown(
      buildFrame(alignment: Alignment.bottomLeft, value: menuItems.first)
    );
    expect(menuRect.bottomLeft, const Offset(0.0, 600.0));
    expect(menuRect.bottomRight, new Offset(menuRect.width, 600.0));

    await popUpAndDown(
      buildFrame(alignment: Alignment.bottomCenter, value: menuItems.first)
    );
    expect(menuRect.bottomLeft, new Offset(buttonRect.left, 600.0));
    expect(menuRect.bottomRight, new Offset(buttonRect.right, 600.0));

    await popUpAndDown(
      buildFrame(alignment: Alignment.bottomRight, value: menuItems.first)
    );
    expect(menuRect.bottomLeft, new Offset(800.0 - menuRect.width, 600.0));
    expect(menuRect.bottomRight, const Offset(800.0, 600.0));
  });

  testWidgets('Dropdown menus are dismissed on screen orientation changes', (WidgetTester tester) async {
    await tester.pumpWidget(buildFrame());
    await tester.tap(find.byType(dropdownButtonType));
    await tester.pumpAndSettle();
    expect(find.byType(ListView), findsOneWidget);

    window.onMetricsChanged();
    await tester.pump();
    expect(find.byType(ListView, skipOffstage: false), findsNothing);
  });


  testWidgets('Semantics Tree contains only selected element', (WidgetTester tester) async {
    final SemanticsTester semantics = new SemanticsTester(tester);
    await tester.pumpWidget(buildFrame(items: menuItems));

    expect(semantics, isNot(includesNodeWith(label: menuItems[0])));
    expect(semantics, includesNodeWith(label: menuItems[1]));
    expect(semantics, isNot(includesNodeWith(label: menuItems[2])));
    expect(semantics, isNot(includesNodeWith(label: menuItems[3])));

    semantics.dispose();
  });

  testWidgets('Dropdown button includes semantics', (WidgetTester tester) async {
    final SemanticsHandle handle = tester.ensureSemantics();
    const Key key = Key('test');
    await tester.pumpWidget(buildFrame(
      buttonKey: key,
      value: null,
      items: menuItems,
      onChanged: (String _) {},
      hint: const Text('test'),
    ));

    // By default the hint contributes the label.
    expect(tester.getSemanticsData(find.byKey(key)), matchesSemanticsData(
      isButton: true,
      label: 'test',
      hasTapAction: true,
    ));

    await tester.pumpWidget(buildFrame(
      buttonKey: key,
      value: 'three',
      items: menuItems,
      onChanged: null,
      hint: const Text('test'),
    ));

    // Displays label of select item and is no longer tappable.
    expect(tester.getSemanticsData(find.byKey(key)), matchesSemanticsData(
      isButton: true,
      label: 'three',
      hasTapAction: true,
    ));
    handle.dispose();
  });

  testWidgets('Dropdown menu includes semantics', (WidgetTester tester) async {
    final SemanticsTester semantics = new SemanticsTester(tester);
    const Key key = Key('test');
    await tester.pumpWidget(buildFrame(
      buttonKey: key,
      value: null,
      items: menuItems,
    ));
    await tester.tap(find.byKey(key));
    await tester.pumpAndSettle();

    expect(semantics, hasSemantics(new TestSemantics.root(
      children: <TestSemantics>[
        new TestSemantics.rootChild(
          children: <TestSemantics>[
            new TestSemantics(
              flags: <SemanticsFlag>[
                SemanticsFlag.scopesRoute,
                SemanticsFlag.namesRoute,
              ],
              label: 'Popup menu',
              children: <TestSemantics>[
                new TestSemantics(
                  children: <TestSemantics>[
                    new TestSemantics(
                      children: <TestSemantics>[
                        new TestSemantics(
                          label: 'one',
                          textDirection: TextDirection.ltr,
                          tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
                          actions: <SemanticsAction>[SemanticsAction.tap],
                        ),
                        new TestSemantics(
                          label: 'two',
                          textDirection: TextDirection.ltr,
                          tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
                          actions: <SemanticsAction>[SemanticsAction.tap],
                        ),
                        new TestSemantics(
                          label: 'three',
                          textDirection: TextDirection.ltr,
                          tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
                          actions: <SemanticsAction>[SemanticsAction.tap],
                        ),
                        new TestSemantics(
                          label: 'four',
                          textDirection: TextDirection.ltr,
                          tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
                          actions: <SemanticsAction>[SemanticsAction.tap],
                        ),
                      ],
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
      ],
    ), ignoreId: true, ignoreRect: true, ignoreTransform: true));
    semantics.dispose();
  });
}
