Faça um aplicativo Tesla App em Flutter com com design
Neste tutorial, vou mostrar a você como construir um ótimo aplicativo similar ao da Tesla App usando o Flutter e alguns componentes, é um projeto muito legal que vai fazer você entender alguns conceitos avançados de programação. sem mais delongas, vamos começar a construir o projeto
Configure o projeto
Primeiro você precisará criar um novo diretório chamado ‘Assets’ e adicionar as pastas do diretório ‘Assets’ do nosso github, link abaixo não se esqueça de adicionar o diretório dentro do arquivo pubspec.yaml apenas copie e cole este código dentro das dependências
dependencies: flutter: sdk: flutter flutter_svg: ^2.0.10+1 provider: ^6.1.2
Abaixo uma amostra da pasta de Assets.
Agora vamos começar a construir o aplicativo
Pastas a serem usadas, no projeto.
Código fonte:
A pasta constants
contem apenas uma pasta com parâmetros básicos do projeto.
Lib->Src->constants
constants.dart
import 'package:flutter/material.dart'; const Color primaryColor = Color(0xFF53F9FF); const Color redColor = Color(0xFFFF5368); const double defaultPadding = 16.0; const Duration defaultDuration = Duration(milliseconds: 300);
A pasta models
contem apenas uma pasta com modelos de dados simulados para o projeto.
Lib->Src->models
typepsi.dart
class TyrePsi { final double psi; final int temp; final bool isLowPressure; TyrePsi({required this.psi, required this.temp, required this.isLowPressure}); } final List<TyrePsi> demoPsiList = [ TyrePsi(psi: 23.6, temp: 56, isLowPressure: true), TyrePsi(psi: 35.0, temp: 41, isLowPressure: false), TyrePsi(psi: 34.6, temp: 41, isLowPressure: false), TyrePsi(psi: 34.8, temp: 42, isLowPressure: false), ];
A pasta screens
contem uma pasta de componentes para a tela home_screen, e também seu controlador do projeto.
Lib->Src->screens->components
battery_status.dart
import 'package:flutter/material.dart'; import '../../constants/constants.dart'; class BatteryStatus extends StatelessWidget { const BatteryStatus({ Key? key, required this.constrains, }) : super(key: key); final BoxConstraints constrains; @override Widget build(BuildContext context) { return SizedBox( height: constrains.maxHeight, width: constrains.maxWidth, child: Column( children: [ Text( "220 mi", style: Theme.of(context) .textTheme .displaySmall! .copyWith(color: Colors.white), ), Text( "62%", style: TextStyle(fontSize: 24), ), Spacer(), Text( "CHARGING", style: const TextStyle(fontSize: 20), ), Text( "18 min remaining", style: const TextStyle(fontSize: 20), ), SizedBox(height: constrains.maxHeight * 0.14), Padding( padding: const EdgeInsets.symmetric(horizontal: defaultPadding), child: DefaultTextStyle( style: TextStyle( fontSize: 18, color: Colors.white, fontWeight: FontWeight.bold, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text("22 mi/hr"), Text("232 v"), ], ), ), ), const SizedBox(height: defaultPadding), ], ), ); } }
bottom_navigation.dart
import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../constants/constants.dart'; class TeslaAppBottomNavigation extends StatelessWidget { TeslaAppBottomNavigation({ Key? key, required this.onTap, required this.selectedTab, }) : super(key: key); final ValueChanged<int> onTap; final int selectedTab; static List<String> _tabsIcons = [ "assets/icons/Lock.svg", "assets/icons/Charge.svg", "assets/icons/Temp.svg", "assets/icons/Tyre.svg" ]; @override Widget build(BuildContext context) { return BottomNavigationBar( onTap: onTap, currentIndex: selectedTab, type: BottomNavigationBarType.fixed, backgroundColor: Colors.black, items: List.generate( _tabsIcons.length, (index) => BottomNavigationBarItem( icon: SvgPicture.asset( _tabsIcons[index], colorFilter: ColorFilter.mode(index == selectedTab ? primaryColor : Colors.white54, BlendMode.srcIn), ), label: "", ), ), ); } }
door_lock.dart
import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; class DoorLock extends StatelessWidget { const DoorLock({ Key? key, required this.isDoorLock, required this.press, }) : super(key: key); final bool isDoorLock; final VoidCallback press; Widget doorLock() { return isDoorLock ? SvgPicture.asset( "assets/icons/door_lock.svg", key: ValueKey("lock"), ) : SvgPicture.asset( "assets/icons/door_unlock.svg", key: ValueKey("unlock"), ); } @override Widget build(BuildContext context) { return GestureDetector( onTap: press, child: AnimatedSwitcher( switchInCurve: Curves.easeInOutBack, duration: Duration(milliseconds: 300), transitionBuilder: (child, animation) => ScaleTransition( scale: animation, child: child, ), child: doorLock(), ), ); } }
temp_counter.dart
import 'package:flutter/material.dart'; class TempCounter extends StatelessWidget { const TempCounter({ Key? key, required this.temperature, required this.pressIncrement, required this.pressDecrement, }) : super(key: key); final int temperature; final VoidCallback pressIncrement, pressDecrement; @override Widget build(BuildContext context) { return Column( children: [ IconButton( padding: EdgeInsets.zero, onPressed: pressIncrement, icon: Icon( Icons.arrow_drop_up, size: 48, ), ), Text( temperature.toString() + "\u2103", style: TextStyle(fontSize: 86), ), IconButton( padding: EdgeInsets.zero, onPressed: pressDecrement, icon: Icon( Icons.arrow_drop_down, size: 48, ), ), ], ); } }
temp_details.dart
import 'package:flutter/material.dart'; import 'package:tesla/src/screens/home_controller.dart'; import '../../constants/constants.dart'; import 'temp_counter.dart'; import 'temperature_btn.dart'; class TempDetails extends StatelessWidget { TempDetails({ Key? key, required this.constrains, required this.press, required this.isCoolTabSelected, }) : super(key: key); final BoxConstraints constrains; final VoidCallback press; final bool isCoolTabSelected; final HomeController _controller = HomeController(); @override Widget build(BuildContext context) { return SizedBox( height: constrains.maxHeight, width: constrains.maxWidth, child: Padding( padding: const EdgeInsets.all(defaultPadding), child: AnimatedBuilder( animation: _controller, builder: (context, snapshot) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Spacer(), SizedBox( height: 110, child: Row( // mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ TemperatureBtn( svgSrc: "assets/icons/coolShape.svg", text: "Cool", press: press, isActive: isCoolTabSelected, ), const SizedBox(width: defaultPadding * 1.5), TemperatureBtn( svgSrc: "assets/icons/heatShape.svg", text: "Heat", press: press, isActive: !isCoolTabSelected, activeColor: redColor, ), ], ), ), Spacer(flex: 2), TempCounter( temperature: _controller.coolTem, pressDecrement: () => _controller.coolTemHandler(false), pressIncrement: () => _controller.coolTemHandler(true), ), Spacer(flex: 2), Text("CURRENT TEMPERATURE"), const SizedBox(height: defaultPadding), Row( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Inside".toUpperCase(), ), Text( "20" + "\u2103", style: Theme.of(context).textTheme.headlineSmall, ) ], ), const SizedBox(width: defaultPadding), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Outside".toUpperCase(), style: TextStyle(color: Colors.white60), ), Text( "35" + "\u2103", style: Theme.of(context) .textTheme .headlineSmall! .copyWith(color: Colors.white60), ) ], ), ], ), ], ); }), ), ); } }
temperature_btn.dart
import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../constants/constants.dart'; class TemperatureBtn extends StatelessWidget { const TemperatureBtn({ Key? key, required this.svgSrc, required this.text, required this.press, this.isActive = false, this.activeColor = primaryColor, }) : super(key: key); final String svgSrc, text; final VoidCallback press; final bool isActive; final Color activeColor; @override Widget build(BuildContext context) { return GestureDetector( onTap: press, child: Column( children: [ AnimatedContainer( curve: Curves.easeInOutBack, duration: Duration(milliseconds: 200), height: isActive ? 76 : 50, width: isActive ? 76 : 50, child: SvgPicture.asset( svgSrc, colorFilter: ColorFilter.mode(isActive ? activeColor : Colors.white38, BlendMode.srcIn), fit: BoxFit.cover, ), ), const SizedBox(height: defaultPadding / 2), AnimatedDefaultTextStyle( duration: Duration(milliseconds: 200), style: TextStyle( color: isActive ? activeColor : Colors.white38, fontSize: 16, fontWeight: isActive ? FontWeight.bold : FontWeight.normal), child: Text( text.toUpperCase(), ), ), ], ), ); } }
tyres.dart
import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; List<Positioned> tyres(BoxConstraints constrains) { return [ Positioned( top: constrains.maxHeight * 0.2, left: constrains.maxWidth * 0.22, child: SvgPicture.asset("assets/icons/FL_Tyre.svg"), ), Positioned( top: constrains.maxHeight * 0.2, right: constrains.maxWidth * 0.22, child: SvgPicture.asset("assets/icons/FL_Tyre.svg"), ), Positioned( top: constrains.maxHeight * 0.63, right: constrains.maxWidth * 0.22, child: SvgPicture.asset("assets/icons/FL_Tyre.svg"), ), Positioned( top: constrains.maxHeight * 0.63, left: constrains.maxWidth * 0.22, child: SvgPicture.asset("assets/icons/FL_Tyre.svg"), ), ]; }
Lib->Src->screens
home_controller.dart
import 'package:flutter/material.dart'; class HomeController extends ChangeNotifier { int selectedTab = 0; onBottomNavigationTabChage(int index) { selectedTab = index; notifyListeners(); } bool leftDoorLock = true; bool rightDoorLock = true; bool trunkLock = true; bool bonnetLock = true; void chnageLeftDoorLock() { leftDoorLock = !leftDoorLock; notifyListeners(); } void chnageRightDoorLock() { rightDoorLock = !rightDoorLock; notifyListeners(); } void chnagetrunkLock() { trunkLock = !trunkLock; notifyListeners(); } void chnageBonnetLock() { bonnetLock = !bonnetLock; notifyListeners(); } bool isCoolSelected = true; void updateTempTab() { isCoolSelected = !isCoolSelected; notifyListeners(); } int coolTem = 20; void coolTemHandler(isIncremt) { isIncremt ? coolTem++ : coolTem--; notifyListeners(); } bool isShowTyre = false; void showTyreHadaler(int index) { if (selectedTab == 3 && index != 3) isShowTyre = false; else if (selectedTab != 3 && index == 3) Future.delayed(Duration(milliseconds: 400), () { isShowTyre = true; }); } }
home_screen.dart
import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tesla/src/constants/constants.dart'; import 'package:tesla/src/screens/home_controller.dart'; import 'package:tesla/src/models/TyrePsi.dart'; import 'components/battery_status.dart'; import 'components/bottom_navigation.dart'; import 'components/door_lock.dart'; import 'components/temp_details.dart'; import 'components/tyres.dart'; class HomeScreen extends StatefulWidget { @override State<HomeScreen> createState() => _HomeScreenState(); } class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin { late AnimationController _batteryAnimationController; late Animation<double> _animationBattery; late Animation<double> _animationBatteryStatus; late AnimationController _tempAnimationController; late Animation<double> _animationCarShift; late Animation<double> _animationShowTempInfo; late Animation<double> _animationCoolGlow; late AnimationController _tyreAnimationController; late Animation<double> _animationTyre1Psi; late Animation<double> _animationTyre2Psi; late Animation<double> _animationTyre3Psi; late Animation<double> _animationTyre4Psi; void setupBatteryAnimation() { _batteryAnimationController = AnimationController(vsync: this, duration: Duration(milliseconds: 600)); _animationBattery = CurvedAnimation( parent: _batteryAnimationController, curve: Interval(0.0, 0.5), ); _animationBatteryStatus = CurvedAnimation( parent: _batteryAnimationController, curve: Interval(0.6, 1), ); } void setupTemAnimation() { _tempAnimationController = AnimationController( vsync: this, duration: Duration(milliseconds: 1500), ); _animationCarShift = CurvedAnimation( parent: _tempAnimationController, curve: Interval(0.2, 0.4), ); _animationShowTempInfo = CurvedAnimation( parent: _tempAnimationController, curve: Interval(0.45, 0.65), ); _animationCoolGlow = CurvedAnimation( parent: _tempAnimationController, curve: Interval(0.7, 1), ); } void setupTyreAnimation() { _tyreAnimationController = AnimationController( vsync: this, duration: Duration(milliseconds: 1200)); _animationTyre1Psi = CurvedAnimation( parent: _tyreAnimationController, curve: Interval(0.34, 0.5, curve: Curves.easeOutQuad), ); _animationTyre2Psi = CurvedAnimation( parent: _tyreAnimationController, curve: Interval(0.5, 0.66, curve: Curves.easeOutQuad), ); _animationTyre3Psi = CurvedAnimation( parent: _tyreAnimationController, curve: Interval(0.66, 0.82, curve: Curves.easeOutQuad), ); _animationTyre4Psi = CurvedAnimation( parent: _tyreAnimationController, curve: Interval(0.82, 1, curve: Curves.easeOutQuad), ); } final _controller = HomeController(); @override void initState() { setupBatteryAnimation(); setupTemAnimation(); setupTyreAnimation(); super.initState(); } @override void dispose() { _batteryAnimationController.dispose(); _tempAnimationController.dispose(); _tyreAnimationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // Size _size = MediaQuery.of(context).size; // int _currentTabIndex = _controller.selectedTab; return AnimatedBuilder( animation: Listenable.merge( [_controller, _batteryAnimationController, _tempAnimationController]), builder: (context, _) { return Scaffold( bottomNavigationBar: TeslaAppBottomNavigation( onTap: (index) { if (index == 1) _batteryAnimationController.forward(); else if (_controller.selectedTab == 1 && index != 1) _batteryAnimationController.reverse(from: 0.7); if (index == 2) _tempAnimationController.forward(); else if (_controller.selectedTab == 2 && index != 2) _tempAnimationController.reverse(from: 0.4); if (index == 3) _tyreAnimationController.forward(); else if (_controller.selectedTab == 3 && index != 3) { _tyreAnimationController.reverse(); // _animationShowTyre.status; } // if (index == 3) _tempAnimationController.forward(); _controller.showTyreHadaler(index); _controller.onBottomNavigationTabChage(index); }, selectedTab: _controller.selectedTab, ), body: SafeArea( child: SizedBox( // height: double.infinity, child: LayoutBuilder( builder: (context, constrains) { return Stack( alignment: Alignment.center, // fit: StackFit.expand, children: [ SizedBox( height: constrains.maxHeight, width: constrains.maxWidth), Positioned( height: constrains.maxHeight, width: constrains.maxWidth, left: constrains.maxWidth / 2 * _animationCarShift.value, child: Padding( padding: EdgeInsets.symmetric( vertical: constrains.maxHeight * 0.1), child: SvgPicture.asset( "assets/icons/Car.svg", width: double.infinity, // height: constrains.maxHeight * 0.8, ), ), ), AnimatedPositioned( duration: defaultDuration, right: _controller.selectedTab == 0 ? constrains.maxWidth * 0.05 : constrains.maxWidth / 2, //20 child: AnimatedOpacity( duration: defaultDuration, opacity: _controller.selectedTab == 0 ? 1 : 0, child: DoorLock( isDoorLock: _controller.rightDoorLock, press: _controller.chnageRightDoorLock, ), ), ), AnimatedPositioned( duration: defaultDuration, left: _controller.selectedTab == 0 ? constrains.maxWidth * 0.05 : constrains.maxWidth / 2, child: AnimatedOpacity( duration: defaultDuration, opacity: _controller.selectedTab == 0 ? 1 : 0, child: DoorLock( isDoorLock: _controller.leftDoorLock, press: _controller.chnageLeftDoorLock, ), ), ), AnimatedPositioned( duration: defaultDuration, top: _controller.selectedTab == 0 ? constrains.maxHeight * 0.13 : constrains.maxHeight * 0.4, //20 child: AnimatedOpacity( duration: defaultDuration, opacity: _controller.selectedTab == 0 ? 1 : 0, child: DoorLock( isDoorLock: _controller.bonnetLock, press: _controller.chnageBonnetLock, ), ), ), AnimatedPositioned( duration: defaultDuration, bottom: _controller.selectedTab == 0 ? constrains.maxHeight * 0.17 : constrains.maxHeight * 0.4, child: AnimatedOpacity( duration: defaultDuration, opacity: _controller.selectedTab == 0 ? 1 : 0, child: DoorLock( isDoorLock: _controller.trunkLock, press: _controller.chnagetrunkLock, ), ), ), // Battery Positioned( child: Opacity( // duration: defaultDuration, opacity: _animationBattery.value, child: SvgPicture.asset( "assets/icons/Battery.svg", width: constrains.maxWidth * 0.45, ), ), ), Positioned( top: 50 * (1 - _animationBatteryStatus.value), child: Opacity( opacity: _animationBatteryStatus.value, // opacity: 1, child: BatteryStatus(constrains: constrains), ), ), // Temp Positioned( top: 40 * (1 - _animationShowTempInfo.value), child: Opacity( opacity: _animationShowTempInfo.value, child: TempDetails( constrains: constrains, press: _controller.updateTempTab, isCoolTabSelected: _controller.isCoolSelected, ), ), ), Positioned( right: -180 * (1 - _animationCoolGlow.value), child: AnimatedSwitcher( duration: defaultDuration, child: _controller.isCoolSelected ? Image.asset( "assets/images/Cool_glow_2.png", key: UniqueKey(), width: 200, ) : Image.asset( "assets/images/Hot_glow_4.png", key: UniqueKey(), width: 200, ), ), ), if (_controller.isShowTyre) ...tyres(constrains), GridView( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: defaultPadding, crossAxisSpacing: defaultPadding, childAspectRatio: constrains.maxWidth / constrains.maxHeight, ), children: [ ScaleTransition( scale: _animationTyre1Psi, child: TyrePsiCard(tyrePsi: demoPsiList[0]), ), ScaleTransition( scale: _animationTyre2Psi, child: TyrePsiCard(tyrePsi: demoPsiList[1]), ), ScaleTransition( scale: _animationTyre3Psi, child: TyrePsiCard( tyrePsi: demoPsiList[2], isBottomTwoTyre: true, ), ), ScaleTransition( scale: _animationTyre4Psi, child: TyrePsiCard( tyrePsi: demoPsiList[3], isBottomTwoTyre: true, ), ), ], ), ], ); }, ), ), ), ); }, ); } } class TyrePsiCard extends StatelessWidget { const TyrePsiCard({ Key? key, required this.tyrePsi, this.isBottomTwoTyre = false, }) : super(key: key); final TyrePsi tyrePsi; final bool isBottomTwoTyre; @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.all(defaultPadding), decoration: BoxDecoration( color: tyrePsi.isLowPressure ? redColor.withOpacity(0.1) : Colors.white10, borderRadius: BorderRadius.all(Radius.circular(6)), border: Border.all( color: tyrePsi.isLowPressure ? redColor : primaryColor, width: 2), ), child: isBottomTwoTyre ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (tyrePsi.isLowPressure) LowPressureText(), Spacer(), Text( "${tyrePsi.temp}\u2103", style: TextStyle(fontSize: 16), ), const SizedBox(height: defaultPadding), PsiText(psi: tyrePsi.psi), ], ) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ PsiText(psi: tyrePsi.psi), const SizedBox(height: defaultPadding), Text( "${tyrePsi.temp}\u2103", style: TextStyle(fontSize: 16), ), Spacer(), if (tyrePsi.isLowPressure) LowPressureText(), ], ), ); } } class PsiText extends StatelessWidget { const PsiText({ Key? key, required this.psi, }) : super(key: key); final double psi; @override Widget build(BuildContext context) { return Text.rich( TextSpan( text: psi.toString(), style: Theme.of(context).textTheme.headlineMedium!.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), children: [ TextSpan( text: "psi", style: TextStyle(fontSize: 24), ), ], ), ); } } class LowPressureText extends StatelessWidget { const LowPressureText({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Low".toUpperCase(), style: Theme.of(context) .textTheme .displaySmall! .copyWith(color: Colors.white, fontWeight: FontWeight.bold), ), Text( "PRESSURE", style: TextStyle( fontSize: 20, ), ), ], ); } }
A pasta src
contem as pastas já demonstradas acima (constants
, models
e screens
) e o arquivo principal main
do projeto.
Lib->Src
main.dart
import 'dart:async'; import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tesla/src/screens/home_controller.dart'; import 'package:tesla/src/screens/home_screen.dart'; void main() { ErrorWidget.builder = (FlutterErrorDetails errorDatails) => CustomError(errorDatails: errorDatails); runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); runApp(MyApp()); }, (error, stack) { log('Erro não tratado',error: error, stackTrace: stack); throw error; }); } class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: 'Flutter Demo', theme: ThemeData.dark().copyWith( scaffoldBackgroundColor: Colors.black, ), home: ChangeNotifierProvider( create: (context) => HomeController(), child: HomeScreen(), ), ); } } class CustomError extends StatelessWidget { final FlutterErrorDetails errorDatails; const CustomError({ super.key, required this.errorDatails, }); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: Center( child: Container( decoration: BoxDecoration( color: Colors.redAccent.withOpacity(.3), borderRadius: BorderRadius.circular(12), ), width: 300, height: 300, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.error_outline_outlined, color: Colors.red, size: 80, ), const SizedBox( height: 10.0, ), const Text( 'Error Occurred!', style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold), ), Text( kDebugMode ? 'Oops... something went wrong' : errorDatails.exception.toString(), textAlign: TextAlign.center, style: const TextStyle(fontSize: 16.0), ), ], ), ), ), ); } }
Abaixo imagens do projeto
Vídeo demonstrando o projeto
Segue o código fonte completo no meu Github (link)