| // Copyright 2019 The Flutter 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:async'; |
| |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:url_launcher/url_launcher.dart'; |
| |
| import '../model/commit.pb.dart'; |
| import 'commit_author_avatar.dart'; |
| |
| // TODO(ianh): Factor out the logic in task_overlay.dart and use it here as well, |
| // so that all our popups have the same look and feel and we don't duplicate code. |
| |
| /// Displays Git commit information. |
| /// |
| /// On click, it will open an [OverlayEntry] with [CommitOverlayContents] |
| /// to show the information provided. Otherwise, it just shows the avatar |
| /// for the author of this commit. Clicking outside of the [OverlayEntry] |
| /// will close it. |
| class CommitBox extends StatefulWidget { |
| const CommitBox({ |
| super.key, |
| required this.commit, |
| }); |
| |
| /// The commit being shown |
| final Commit commit; |
| |
| @override |
| CommitBoxState createState() => CommitBoxState(); |
| } |
| |
| class CommitBoxState extends State<CommitBox> { |
| OverlayEntry? _commitOverlay; |
| |
| @override |
| Widget build(BuildContext context) { |
| return InkWell( |
| onTap: _handleTap, |
| child: Padding( |
| padding: const EdgeInsets.all(4.0), |
| child: CommitAuthorAvatar(commit: widget.commit), |
| ), |
| ); |
| } |
| |
| void _handleTap() { |
| _commitOverlay = OverlayEntry( |
| builder: (_) => CommitOverlayContents( |
| parentContext: context, |
| commit: widget.commit, |
| closeCallback: _closeOverlay, |
| ), |
| ); |
| |
| Overlay.of(context).insert(_commitOverlay!); |
| } |
| |
| void _closeOverlay() => _commitOverlay?.remove(); |
| } |
| |
| /// Displays the information from a Git commit. |
| /// |
| /// This is intended to be inserted in an [OverlayEntry] as it requires |
| /// [closeCallback] that will remove the widget from the tree. |
| class CommitOverlayContents extends StatelessWidget { |
| const CommitOverlayContents({ |
| super.key, |
| required this.parentContext, |
| required this.commit, |
| required this.closeCallback, |
| }); |
| |
| /// The parent context that has the size of the whole screen |
| final BuildContext parentContext; |
| |
| /// The commit data to display in the overlay |
| final Commit commit; |
| |
| /// This callback removes the parent overlay from the widget tree. |
| /// |
| /// On a click that is outside the area of the overlay (the rest of the screen), |
| /// this callback is called closing the overlay. |
| final void Function() closeCallback; |
| |
| @override |
| Widget build(BuildContext context) { |
| final ThemeData theme = Theme.of(context); |
| final RenderBox renderBox = parentContext.findRenderObject() as RenderBox; |
| final Offset offsetLeft = renderBox.localToGlobal(Offset.zero); |
| return Stack( |
| children: <Widget>[ |
| // This is the area a user can click (the rest of the screen) to close the overlay. |
| GestureDetector( |
| onTap: closeCallback, |
| child: Container( |
| width: MediaQuery.of(parentContext).size.width, |
| height: MediaQuery.of(parentContext).size.height, |
| // Color must be defined otherwise the container can't be clicked on |
| color: Colors.transparent, |
| ), |
| ), |
| Positioned( |
| width: 300, |
| // Move this overlay to be where the parent is |
| top: offsetLeft.dy + (renderBox.size.height / 2), |
| left: offsetLeft.dx + (renderBox.size.width / 2), |
| child: Card( |
| child: SafeArea( |
| minimum: const EdgeInsets.all(16), |
| child: Row( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: <Widget>[ |
| CommitAuthorAvatar(commit: commit), |
| Expanded( |
| child: Padding( |
| padding: const EdgeInsetsDirectional.only(start: 16), |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.stretch, |
| children: <Widget>[ |
| Align( |
| alignment: Alignment.centerLeft, |
| child: AnimatedDefaultTextStyle( |
| style: theme.textTheme.titleMedium!, |
| duration: kThemeChangeDuration, |
| child: Row( |
| children: <Widget>[ |
| Hyperlink( |
| text: commit.sha.substring(0, 7), |
| onPressed: _openGithub, |
| ), |
| IconButton( |
| icon: const Icon(Icons.copy), |
| onPressed: () => unawaited( |
| Clipboard.setData( |
| ClipboardData(text: commit.sha), |
| ), |
| ), |
| ), |
| ], |
| ), |
| ), |
| ), |
| const SizedBox(height: 8), |
| Padding( |
| padding: const EdgeInsets.only(bottom: 4), |
| child: AnimatedDefaultTextStyle( |
| style: theme.textTheme.bodyMedium!.copyWith( |
| color: theme.textTheme.bodySmall!.color, |
| ), |
| duration: kThemeChangeDuration, |
| child: SelectableText(commit.message.split('\n').first), |
| ), |
| ), |
| SelectableText(commit.author), |
| ], |
| ), |
| ), |
| ), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ], |
| ); |
| } |
| |
| Future<void> _openGithub() async { |
| final String githubUrl = 'https://github.com/${commit.repository}/commit/${commit.sha}'; |
| await launchUrl(Uri.parse(githubUrl)); |
| } |
| } |
| |
| class Hyperlink extends StatefulWidget { |
| const Hyperlink({ |
| super.key, |
| required this.text, |
| this.onPressed, |
| }); |
| |
| final String text; |
| final VoidCallback? onPressed; |
| |
| @override |
| HyperlinkState createState() => HyperlinkState(); |
| } |
| |
| class HyperlinkState extends State<Hyperlink> { |
| bool hover = false; |
| |
| @override |
| Widget build(BuildContext context) { |
| final TextStyle defaultStyle = DefaultTextStyle.of(context).style; |
| return MouseRegion( |
| onEnter: (PointerEnterEvent _) => setState(() => hover = true), |
| onExit: (PointerExitEvent _) => setState(() => hover = false), |
| cursor: SystemMouseCursors.click, |
| child: GestureDetector( |
| onTap: widget.onPressed, |
| child: Text( |
| widget.text, |
| style: defaultStyle.copyWith( |
| color: const Color(0xff1377c0), |
| decoration: hover ? TextDecoration.underline : TextDecoration.none, |
| ), |
| ), |
| ), |
| ); |
| } |
| } |