Skip to content

Instantly share code, notes, and snippets.

@hawkkiller
Last active March 20, 2024 13:31
Show Gist options
  • Save hawkkiller/d6cedf262180bb8b963e5d8f091f0a5a to your computer and use it in GitHub Desktop.
Save hawkkiller/d6cedf262180bb8b963e5d8f091f0a5a to your computer and use it in GitHub Desktop.
Full code for app that uses Popup
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:popups_showcase/popup.dart';
void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.light,
colorSchemeSeed: const Color(0xFF3F45C4),
),
home: const PlanShowcase(),
);
}
}
class PlanShowcase extends StatefulWidget {
const PlanShowcase({super.key});
@override
State<PlanShowcase> createState() => _PlanShowcaseState();
}
class _PlanShowcaseState extends State<PlanShowcase> {
late final OverlayPortalController _infoPopupController;
late final OverlayPortalController _cardPopupController;
int _selectedPlan = 0;
String? _selectedCard;
@override
void initState() {
super.initState();
_infoPopupController = OverlayPortalController();
_cardPopupController = OverlayPortalController();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: false,
title: Text(
'Upgrade Plan',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
),
),
actions: [_HelpButton(_infoPopupController), const SizedBox(width: 24)],
),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: ListView(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Start your 14-day free trial.',
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 8, left: 16, right: 16),
child: Text(
'Unlock all the premium benefits now:',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 24, left: 16, right: 16),
child: _Feature(
text: 'Unlimited access to all features',
icon: SvgPicture.asset(
'assets/icons/features.svg',
width: 24,
height: 24,
colorFilter: const ColorFilter.mode(
Color(0xff4062BB),
BlendMode.srcIn,
),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
child: _Feature(
text: 'Faster AI response speed',
icon: SvgPicture.asset(
'assets/icons/speed.svg',
width: 24,
height: 24,
colorFilter: const ColorFilter.mode(
Color(0xffF96900),
BlendMode.srcIn,
),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
child: _Feature(
text: 'Connects with 5,000+ apps you like',
icon: SvgPicture.asset(
'assets/icons/layers.svg',
width: 24,
height: 24,
colorFilter: const ColorFilter.mode(
Color(0xff20BF55),
BlendMode.srcIn,
),
),
),
),
const Divider(
color: Color(0xffE0E0E0),
height: 50,
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16),
child: _Plan(
title: 'Monthly',
description: 'First 7 days free, then \$7,99/mo.',
active: _selectedPlan == 0,
color: const Color(0xffDED1FF),
onCheck: (value) {
if (value == true) {
setState(() {
_selectedPlan = 0;
});
}
},
),
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16),
child: _Plan(
title: 'Yearly',
description: 'Save 20% with \$76,99/yr.',
active: _selectedPlan == 1,
color: const Color(0xffFFE1BB),
onCheck: (value) {
if (value == true) {
setState(() {
_selectedPlan = 1;
});
}
},
),
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16),
child: _CardSelector(
controller: _cardPopupController,
selectedCard: _selectedCard,
selectedCardChanged: (value) {
setState(() {
_selectedCard = value;
});
},
),
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 24),
child: SizedBox(
height: 60,
child: FilledButton(
child: Text(
'START FREE TRIAL',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onPrimary),
),
onPressed: () {},
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'By clicking the button, you agree to our Terms of Service and Privacy Policy.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
],
),
),
),
),
);
}
}
/// {@template main}
/// _CardSelector widget
/// {@endtemplate}
class _CardSelector extends StatelessWidget {
/// {@macro main}
const _CardSelector({
required this.controller,
required this.selectedCard,
required this.selectedCardChanged,
});
final OverlayPortalController controller;
final String? selectedCard;
final ValueChanged<String> selectedCardChanged;
Widget _iconForCard(String? card) {
switch (card) {
case 'visa':
return SvgPicture.asset(
'assets/icons/visa.svg',
width: 24,
height: 24,
);
case 'mastercard':
return SvgPicture.asset(
'assets/icons/mastercard.svg',
width: 24,
height: 24,
);
default:
return const Icon(
Icons.credit_card,
color: Color(0xff4062BB),
);
}
}
@override
Widget build(BuildContext context) {
return Popup(
follower: _CardSelectorOverlay((value) {
selectedCardChanged(value);
controller.hide();
}),
controller: controller,
followerAnchor: Alignment.bottomLeft,
targetAnchor: Alignment.bottomLeft,
child: SizedBox(
height: 48,
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
label: Text(
selectedCard != null ? '**** **** **** 1234' : 'Select card',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
),
),
onPressed: controller.show,
icon: _iconForCard(selectedCard),
),
),
),
);
}
}
/// {@template main}
/// _CardSelectorOverlay widget
/// {@endtemplate}
class _CardSelectorOverlay extends StatelessWidget {
/// {@macro main}
const _CardSelectorOverlay(this.selectedCardChanged);
final ValueChanged<String> selectedCardChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 250,
child: Material(
borderRadius: const BorderRadius.all(Radius.circular(28)),
elevation: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
),
leading: SvgPicture.asset(
'assets/icons/visa.svg',
width: 24,
height: 24,
),
title: const Text(
'**** **** **** 1234',
style: TextStyle(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
),
),
onTap: () => selectedCardChanged('visa'),
),
ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
leading: SvgPicture.asset(
'assets/icons/mastercard.svg',
width: 24,
height: 24,
),
title: const Text(
'**** **** **** 1234',
style: TextStyle(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
),
),
onTap: () => selectedCardChanged('mastercard'),
),
],
),
),
);
}
}
class _HelpButton extends StatelessWidget {
const _HelpButton(this.controller);
final OverlayPortalController controller;
@override
Widget build(BuildContext context) {
return Popup(
follower: _HelpOverlay(controller.hide),
followerAnchor: Alignment.topRight,
targetAnchor: Alignment.topRight,
controller: controller,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
),
onPressed: controller.show,
child: Row(
children: [
const Icon(
Icons.help_outline,
color: Color(0xFF3F45C4),
),
const SizedBox(width: 4),
Text(
'Help',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
}
class _HelpOverlay extends StatelessWidget {
const _HelpOverlay(this.hide);
final VoidCallback hide;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 200,
child: Card(
margin: EdgeInsets.zero,
surfaceTintColor: Colors.white,
elevation: 4,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Need help?',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
),
),
IconButton(
onPressed: hide,
icon: const Icon(Icons.cancel),
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Text(
'We are here to help you. Please contact us via email or phone.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
],
),
),
);
}
}
/// {@template main}
/// _Plan widget
/// {@endtemplate}
class _Plan extends StatelessWidget {
/// {@macro main}
const _Plan({
required this.title,
required this.description,
required this.active,
required this.color,
this.onCheck,
});
final ValueChanged<bool?>? onCheck;
final String title;
final String description;
final bool active;
final Color color;
@override
Widget build(BuildContext context) {
return Card(
color: color,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(28)),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Checkbox(
value: active,
onChanged: onCheck,
side: BorderSide(
color: Colors.grey[700]!,
width: 0.5,
),
shape: const CircleBorder(),
fillColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.selected)) {
return const Color(0xFF3F45C4);
}
return Colors.white;
}),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
),
),
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
],
),
),
],
),
),
);
}
}
/// {@template main}
/// _Feature widget
/// {@endtemplate}
class _Feature extends StatelessWidget {
/// {@macro main}
const _Feature({required this.text, required this.icon});
final String text;
final Widget icon;
@override
Widget build(BuildContext context) => Row(
children: [
icon,
const SizedBox(width: 8),
Text(
text,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: 'Helvetica',
fontWeight: FontWeight.w700,
),
),
],
);
}
import 'package:flutter/material.dart';
/// A widget that shows a popup relative to a target widget.
///
/// The popup is declaratively shown/hidden using an [OverlayPortalController].
///
/// It is positioned relative to the target widget using the [followerAnchor] and [targetAnchor] properties.
class Popup extends StatefulWidget {
const Popup({
required this.child,
required this.follower,
required this.controller,
this.offset = Offset.zero,
this.followerAnchor = Alignment.topCenter,
this.targetAnchor = Alignment.bottomCenter,
this.dismissible = true,
super.key,
});
/// The target widget that will be used to position the follower widget.
final Widget child;
/// The widget that will be positioned relative to the target widget.
final Widget follower;
/// The controller that will be used to show/hide the overlay.
final OverlayPortalController controller;
/// The alignment of the follower widget relative to the target widget.
///
/// Defaults to [Alignment.topCenter].
final Alignment followerAnchor;
/// The alignment of the target widget relative to the follower widget.
///
/// Defaults to [Alignment.bottomCenter].
final Alignment targetAnchor;
/// The offset of the follower widget relative to the target widget.
/// This is useful for fine-tuning the position of the follower widget.
///
/// Defaults to [Offset.zero].
final Offset offset;
/// Whether the popup should be dismissed when the user taps outside of it.
final bool dismissible;
@override
State<Popup> createState() => _PopupState();
}
class _PopupState extends State<Popup> {
/// The link between the target widget and the follower widget.
final _layerLink = LayerLink();
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
// Link the target widget to the follower widget.
link: _layerLink,
child: OverlayPortal(
controller: widget.controller,
child: widget.child,
overlayChildBuilder: (BuildContext context) {
// It is needed to wrap the follower widget in a widget that fills the space of the overlay.
// This is needed to make sure that the follower widget is positioned relative to the target widget.
// If not wrapped, the follower widget will fill the screen and be positioned incorrectly.
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.dismissible ? widget.controller.hide : null,
child: UnconstrainedBox(
child: CompositedTransformFollower(
// Link the follower widget to the target widget.
link: _layerLink,
// The follower widget should not be shown when the link is broken.
showWhenUnlinked: false,
followerAnchor: widget.followerAnchor,
targetAnchor: widget.targetAnchor,
offset: widget.offset,
child: widget.follower,
),
),
);
},
),
);
}
}
@SardorbekR
Copy link

this is not dismissible right? when you click on other area in the app, pop-up is not closed

@hawkkiller
Copy link
Author

@SardorbekR you are right. Though it is easy to make it dismissible as well:

import 'package:flutter/material.dart';

/// A widget that shows a popup relative to a target widget.
///
/// The popup is declaratively shown/hidden using an [OverlayPortalController].
///
/// It is positioned relative to the target widget using the [followerAnchor] and [targetAnchor] properties.
class Popup extends StatefulWidget {
  const Popup({
    required this.child,
    required this.follower,
    required this.controller,
    this.offset = Offset.zero,
    this.followerAnchor = Alignment.topCenter,
    this.targetAnchor = Alignment.bottomCenter,
    this.dismissible = true,
    super.key,
  });

  /// The target widget that will be used to position the follower widget.
  final Widget child;

  /// The widget that will be positioned relative to the target widget.
  final Widget follower;

  /// The controller that will be used to show/hide the overlay.
  final OverlayPortalController controller;

  /// The alignment of the follower widget relative to the target widget.
  ///
  /// Defaults to [Alignment.topCenter].
  final Alignment followerAnchor;

  /// The alignment of the target widget relative to the follower widget.
  ///
  /// Defaults to [Alignment.bottomCenter].
  final Alignment targetAnchor;

  /// The offset of the follower widget relative to the target widget.
  /// This is useful for fine-tuning the position of the follower widget.
  ///
  /// Defaults to [Offset.zero].
  final Offset offset;

  /// Whether the popup should be dismissed when the user taps outside of it.
  final bool dismissible;

  @override
  State<Popup> createState() => _PopupState();
}

class _PopupState extends State<Popup> {
  /// The link between the target widget and the follower widget.
  final _layerLink = LayerLink();

  @override
  Widget build(BuildContext context) {
    return CompositedTransformTarget(
      // Link the target widget to the follower widget.
      link: _layerLink,
      child: OverlayPortal(
        controller: widget.controller,
        child: widget.child,
        overlayChildBuilder: (BuildContext context) {
          // It is needed to wrap the follower widget in a widget that fills the space of the overlay.
          // This is needed to make sure that the follower widget is positioned relative to the target widget.
          // If not wrapped, the follower widget will fill the screen and be positioned incorrectly.
          return GestureDetector(
            behavior: HitTestBehavior.opaque,
            onTap: widget.dismissible ? widget.controller.hide : null,
            child: UnconstrainedBox(
              child: CompositedTransformFollower(
                // Link the follower widget to the target widget.
                link: _layerLink,
                // The follower widget should not be shown when the link is broken.
                showWhenUnlinked: false,
                followerAnchor: widget.followerAnchor,
                targetAnchor: widget.targetAnchor,
                offset: widget.offset,
                child: widget.follower,
              ),
            ),
          );
        },
      ),
    );
  }
}

@SardorbekR
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment