Last active
March 20, 2024 13:31
-
-
Save hawkkiller/d6cedf262180bb8b963e5d8f091f0a5a to your computer and use it in GitHub Desktop.
Full code for app that uses Popup
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
), | |
), | |
], | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 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,
),
),
);
},
),
);
}
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
this is not dismissible right? when you click on other area in the app, pop-up is not closed