Last active
November 9, 2021 16:50
-
-
Save roipeker/bbda71c985bb0c2b64a7c5d39d8b5bea to your computer and use it in GitHub Desktop.
BMI Calculator clone with GetX (+ Theme modes)
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 'dart:async'; | |
import 'dart:math' as math; | |
import 'dart:ui'; | |
import 'package:flutter/cupertino.dart'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:flutter_icons/flutter_icons.dart'; | |
import 'package:get/get.dart'; | |
import 'package:google_fonts/google_fonts.dart'; | |
void main() { | |
SystemChrome.setSystemUIOverlayStyle(Styles.baseSystemUIStyle); | |
runApp(BMIApp()); | |
} | |
enum Gender { female, male } | |
abstract class Styles { | |
static const primaryColor = Color(0xFFEB1555); | |
static const backgroundColor = Color(0xFF0A0D22); | |
static var lighTheme = ThemeData.light().copyWith( | |
primaryColor: primaryColor, | |
colorScheme: ColorScheme.light(primary: primaryColor), | |
scaffoldBackgroundColor: Colors.grey.shade300, | |
); | |
static var darkTheme = ThemeData.dark().copyWith( | |
primaryColor: primaryColor, | |
colorScheme: ColorScheme.dark(primary: primaryColor), | |
scaffoldBackgroundColor: Styles.backgroundColor, | |
); | |
static SystemUiOverlayStyle baseSystemUIStyle = | |
SystemUiOverlayStyle.dark.copyWith( | |
statusBarColor: Colors.transparent, | |
systemNavigationBarColor: Styles.primaryColor, | |
); | |
static Color get iconColor { | |
return Get.isDarkMode ? Colors.white : Colors.redAccent; | |
} | |
static Color get textColor { | |
return Get.isDarkMode ? Colors.white : Color(0xff656565); | |
} | |
static final bigValueTextStyle = GoogleFonts.nunitoSans( | |
fontWeight: FontWeight.w800, | |
color: Colors.white, | |
fontSize: 46, | |
fontFeatures: [ | |
FontFeature.proportionalFigures(), | |
FontFeature.tabularFigures() | |
], | |
); | |
static const yourResultTextStyle = | |
TextStyle(fontSize: 44, fontWeight: FontWeight.w600, color: Colors.white); | |
static const resultStatusTextStyle = | |
TextStyle(fontWeight: FontWeight.bold, fontSize: 22); | |
static const cmTextStyle = | |
TextStyle(fontWeight: FontWeight.w200, fontSize: 12); | |
static const resultPointsTextStyle = | |
TextStyle(fontWeight: FontWeight.w800, color: Colors.white, fontSize: 90); | |
static const resultTextStyle = TextStyle( | |
fontWeight: FontWeight.normal, color: Colors.white, fontSize: 22); | |
static const cardLabelTextStyle = | |
TextStyle(fontWeight: FontWeight.w300, color: Colors.white54); | |
} | |
class BMIApp extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return GetMaterialApp( | |
debugShowCheckedModeBanner: false, | |
title: 'BMI calculator (GetX)', | |
theme: Styles.lighTheme, | |
darkTheme: Styles.darkTheme, | |
themeMode: ThemeMode.dark, | |
initialBinding: BindingsBuilder(() { | |
Get.put(BMIHomeController()); | |
}), | |
home: BMIHome(), | |
); | |
} | |
} | |
enum BMIStatus { overweight, normal, underweight } | |
class BmiCalculator { | |
BMIStatus status = BMIStatus.normal; | |
double points = 0; | |
String text = 'You have a higher than normal'; | |
double _bmi = 0; | |
void calculateBMI({double weight, double height}) { | |
_bmi = weight / math.pow(height / 100, 2); | |
points = _bmi; | |
// update values. | |
if (_bmi >= 25) { | |
status = BMIStatus.overweight; | |
text = 'You have a higher than normal body weight. Try to exercise more'; | |
} else if (_bmi > 18.5) { | |
status = BMIStatus.normal; | |
text = 'You have a normal body weight. Good job!'; | |
} else { | |
status = BMIStatus.underweight; | |
text = | |
'You have a lower than normal body weight. You can eat a bit more.'; | |
} | |
} | |
} | |
class BMIHomeController extends GetxController { | |
// result status colors | |
static const _bmiStatusColors = { | |
BMIStatus.overweight: Colors.redAccent, | |
BMIStatus.normal: Colors.lightGreenAccent, | |
BMIStatus.underweight: Colors.yellow, | |
}; | |
final gender = Gender.female.obs; | |
final height = 180.0.obs; | |
final minHeight = 120.0; | |
final maxHeight = 240.0; | |
final weight = 85.obs; | |
final age = 19.obs; | |
final _calculator = BmiCalculator(); | |
Color get resultStatusColor => _bmiStatusColors[_calculator.status]; | |
String get resultStatusTitle => | |
describeEnum(_calculator.status).toUpperCase(); | |
get resultText => _calculator.text; | |
get resultPointsString => _calculator.points.toStringAsFixed(1); | |
@override | |
void onInit() { | |
_constrainValue(weight, min: 10); | |
_constrainValue(age, min: 18); | |
} | |
void _constrainValue(RxInt value, {int min = 0}) { | |
ever(value, (_) { | |
value.value = min; | |
}, condition: () => value.value < min); | |
} | |
double getGenderOpacity(Gender value) => gender.value == value ? 1 : .25; | |
String get heightString => height.value.toStringAsFixed(1); | |
void handleCalculateTap() { | |
_calculator.calculateBMI( | |
height: height.value, | |
weight: weight.value.toDouble(), | |
); | |
Get.to(BMIResult()); | |
} | |
void handleReCalculateTap() => Get.back(); | |
void toggleTheme() { | |
Get.changeThemeMode(Get.isDarkMode ? ThemeMode.light : ThemeMode.dark); | |
} | |
Timer _buttonTicker; | |
/// custom timer ticker | |
void handleTickButton(RxInt value, bool isPressed, int dir) { | |
if (isPressed) { | |
int countdownWait = 8; | |
int waitTicker = 0; | |
_buttonTicker = Timer.periodic(48.milliseconds, (_) { | |
if (countdownWait <= 0 || ++waitTicker % countdownWait == 0) { | |
countdownWait--; | |
value.value += dir; | |
} | |
}); | |
} else { | |
_buttonTicker?.cancel(); | |
value.value += dir; | |
} | |
} | |
} | |
class BMIAppbar extends StatelessWidget implements PreferredSizeWidget { | |
final bool centerTitle; | |
const BMIAppbar({Key key, this.centerTitle = false}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
context.theme; | |
final BMIHomeController controller = Get.find(); | |
final canPop = Navigator.canPop(context); | |
return AppBar( | |
brightness: Get.isDarkMode ? Brightness.dark : Brightness.light, | |
backgroundColor: Colors.transparent, | |
leading: Visibility( | |
visible: canPop, | |
child: BackButton( | |
color: Styles.iconColor, | |
onPressed: Get.back, | |
), | |
), | |
elevation: 0, | |
title: Text( | |
'BMI CALCULATOR', | |
style: TextStyle(color: Styles.textColor), | |
), | |
centerTitle: centerTitle, | |
actions: [ | |
CupertinoButton( | |
child: Icon( | |
Get.isDarkMode ? Icons.brightness_2 : Icons.brightness_2_outlined, | |
size: 16), | |
onPressed: controller.toggleTheme, | |
), | |
], | |
); | |
} | |
@override | |
Size get preferredSize => Size.fromHeight(kToolbarHeight); | |
} | |
class BMIResult extends GetView<BMIHomeController> { | |
@override | |
Widget build(BuildContext context) { | |
context.theme; | |
return Scaffold( | |
appBar: BMIAppbar( | |
centerTitle: false, | |
), | |
body: Column( | |
crossAxisAlignment: CrossAxisAlignment.stretch, | |
children: [ | |
Text( | |
'Your Result', | |
style: Styles.yourResultTextStyle.copyWith( | |
color: Styles.textColor, | |
), | |
).paddingSymmetric(vertical: 12, horizontal: 12), | |
Expanded( | |
child: AppCard( | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.center, | |
mainAxisAlignment: MainAxisAlignment.spaceAround, | |
children: [ | |
Text( | |
controller.resultStatusTitle, | |
style: Styles.resultStatusTextStyle.copyWith( | |
color: controller.resultStatusColor, | |
), | |
), | |
Text( | |
controller.resultPointsString, | |
style: Styles.resultPointsTextStyle.copyWith( | |
color: Styles.textColor, | |
), | |
), | |
Text( | |
controller.resultText, | |
style: Styles.resultTextStyle.copyWith( | |
color: Styles.textColor, | |
), | |
textAlign: TextAlign.center, | |
), | |
], | |
).paddingAll(12), | |
).paddingSymmetric(vertical: 12, horizontal: 12), | |
), | |
AppMainButton( | |
label: 'RE-CALCULATE', | |
onTap: controller.handleReCalculateTap, | |
), | |
], | |
)); | |
} | |
} | |
class BMIHome extends GetView<BMIHomeController> { | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: BMIAppbar(centerTitle: true), | |
body: Column( | |
children: [ | |
Expanded(child: GenderSelector()), | |
Expanded(child: HeightPane()), | |
Expanded(child: WeightSelector().paddingOnly(bottom: 0)), | |
AppMainButton( | |
label: 'CALCULATE', | |
onTap: controller.handleCalculateTap, | |
), | |
], | |
), | |
); | |
} | |
} | |
class CardLabel extends StatelessWidget { | |
final String text; | |
final double size; | |
const CardLabel(this.text, {this.size = 16, Key key}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
context.theme; | |
return Text( | |
text, | |
style: Styles.cardLabelTextStyle.copyWith( | |
fontSize: size, | |
color: Styles.textColor.withOpacity(.54), | |
), | |
); | |
} | |
} | |
class WeightSelector extends GetView<BMIHomeController> { | |
@override | |
Widget build(BuildContext context) { | |
return Row( | |
children: [ | |
Expanded( | |
child: CounterPane( | |
label: 'WEIGHT', | |
onButtonState: (state, dir) => | |
controller.handleTickButton(controller.weight, state, dir), | |
realValue: controller.weight, | |
).paddingAll(12)), | |
Expanded( | |
child: CounterPane( | |
label: 'AGE', | |
onButtonState: (state, dir) => | |
controller.handleTickButton(controller.age, state, dir), | |
realValue: controller.age, | |
).paddingAll(12)), | |
], | |
); | |
} | |
} | |
class CounterPane extends StatelessWidget { | |
final String label; | |
final RxInt realValue; | |
final Function(bool, int) onButtonState; | |
const CounterPane({ | |
Key key, | |
this.label, | |
this.onButtonState, | |
this.realValue, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
context.theme; | |
return AppCard( | |
padding: EdgeInsets.all(4), | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
CardLabel(label), | |
Obx( | |
() => Text( | |
'$realValue', | |
style: Styles.bigValueTextStyle.copyWith(color: Styles.textColor), | |
), | |
), | |
Row( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: [ | |
AppIconButton.more( | |
onHighlight: onButtonState, | |
), | |
SizedBox(width: 8), | |
AppIconButton.less( | |
onHighlight: onButtonState, | |
// onTap: () => realValue(realValue() - 1), | |
), | |
], | |
) | |
], | |
), | |
); | |
} | |
} | |
class AppIconButton extends StatelessWidget { | |
final Function(int) onTap; | |
final Function(bool, int) onHighlight; | |
final IconData iconData; | |
final int dir; | |
const AppIconButton( | |
{Key key, this.iconData, this.onTap, this.onHighlight, this.dir = 0}) | |
: super(key: key); | |
AppIconButton.more({this.onTap, this.onHighlight}) | |
: iconData = Icons.add, | |
dir = 1, | |
super(); | |
AppIconButton.less({this.onTap, this.onHighlight}) | |
: iconData = Icons.remove, | |
dir = -1, | |
super(); | |
@override | |
Widget build(BuildContext context) { | |
context.theme; | |
return FlatButton( | |
visualDensity: VisualDensity.compact, | |
shape: const CircleBorder(), | |
minWidth: 48, | |
height: 54, | |
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, | |
// padding: EdgeInsets.zero, | |
child: Icon( | |
iconData, | |
size: 34, | |
color: Styles.iconColor, | |
), | |
onHighlightChanged: (flag) => onHighlight?.call(flag, dir), | |
onPressed: () => onTap?.call(dir), | |
color: Styles.iconColor.withOpacity(.12), | |
); | |
} | |
} | |
class HeightPane extends GetView<BMIHomeController> { | |
@override | |
Widget build(BuildContext context) { | |
context.theme; | |
return AppCard( | |
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.stretch, | |
children: [ | |
Center(child: CardLabel('HEIGHT')), | |
Center( | |
child: Row( | |
mainAxisSize: MainAxisSize.min, | |
crossAxisAlignment: CrossAxisAlignment.baseline, | |
children: [ | |
Obx( | |
() => Text( | |
controller.heightString, | |
textAlign: TextAlign.right, | |
style: Styles.bigValueTextStyle | |
.copyWith(color: Styles.textColor), | |
), | |
), | |
Text(' cm', style: Styles.cmTextStyle), | |
], | |
), | |
), | |
Obx( | |
() => Slider.adaptive( | |
min: controller.minHeight, | |
max: controller.maxHeight, | |
value: controller.height(), | |
onChanged: controller.height, | |
activeColor: Styles.primaryColor, | |
// activeColor: Styles.textColor, | |
// thumbColor: Get.theme.primaryColor, | |
), | |
), | |
], | |
), | |
).paddingAll(12); | |
} | |
} | |
class GenderSelector extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return Row( | |
children: [ | |
Expanded(child: AppToggle.male().paddingAll(12)), | |
Expanded(child: AppToggle.female().paddingAll(12)), | |
], | |
); | |
} | |
} | |
class AppMainButton extends StatelessWidget { | |
final VoidCallback onTap; | |
final String label; | |
const AppMainButton({Key key, this.onTap, this.label}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return SizedBox( | |
width: double.infinity, | |
height: 70, | |
child: FlatButton( | |
color: Get.theme.primaryColor, | |
onPressed: onTap, | |
shape: const ContinuousRectangleBorder(), | |
child: Text( | |
label, | |
style: Get.textTheme.headline5.copyWith( | |
color: Colors.white, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
), | |
); | |
} | |
} | |
class AppCard extends StatelessWidget { | |
final Widget child; | |
final EdgeInsets padding; | |
const AppCard({ | |
Key key, | |
this.child, | |
this.padding, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
context.theme; | |
return Container( | |
padding: padding, | |
clipBehavior: Clip.antiAlias, | |
decoration: BoxDecoration( | |
borderRadius: BorderRadius.circular(8), | |
// color: Colors.white.withOpacity(.06), | |
color: Styles.textColor.withOpacity(.06), | |
), | |
child: child, | |
); | |
} | |
} | |
class AppToggle extends GetView<BMIHomeController> { | |
final String label; | |
final Widget icon; | |
final Gender gender; | |
const AppToggle({Key key, this.label, this.icon, this.gender}) | |
: super(key: key); | |
AppToggle.male() | |
: label = 'MALE', | |
gender = Gender.male, | |
icon = Icon(Ionicons.md_male), | |
super(); | |
AppToggle.female() | |
: label = 'FEMALE', | |
gender = Gender.female, | |
icon = Icon(Ionicons.md_female), | |
super(); | |
@override | |
Widget build(BuildContext context) { | |
context.theme; | |
return AppCard( | |
child: FlatButton( | |
onPressed: () => controller.gender(gender), | |
padding: EdgeInsets.all(12), | |
shape: const ContinuousRectangleBorder(), | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
children: [ | |
Expanded( | |
child: FittedBox( | |
child: Obx( | |
() => Opacity( | |
opacity: controller.getGenderOpacity(gender), | |
child: IconTheme( | |
data: IconThemeData(color: Styles.iconColor), | |
child: icon, | |
), | |
), | |
), | |
), | |
), | |
SizedBox(height: 12), | |
CardLabel(label), | |
], | |
), | |
), | |
); | |
} | |
} |
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
# base dependencies. | |
dependencies: | |
flutter: | |
sdk: flutter | |
get: any | |
flutter_icons: any | |
google_fonts: any | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment