From cd8f22959e62c0b7b191e64f4ecfec53cba9f364 Mon Sep 17 00:00:00 2001 From: Iván Ávalos Date: Sat, 20 May 2023 16:43:21 -0600 Subject: Se añade autenticación y onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/firebase/auth.dart | 83 ++++++++++++++++ lib/firebase/storage.dart | 16 +++ lib/main.dart | 67 +++++++------ lib/providers/theme_provider.dart | 25 +++++ lib/routes.dart | 15 +++ lib/screens/dashboard_screen.dart | 100 +++++++++++++++++++ lib/screens/login_screen.dart | 171 ++++++++++++++++++++++++++++++++ lib/screens/onboarding_screen.dart | 103 ++++++++++++++++++++ lib/screens/register_screen.dart | 178 ++++++++++++++++++++++++++++++++++ lib/settings/preferences.dart | 35 +++++++ lib/settings/themes.dart | 16 +++ lib/widgets/avatar_picker.dart | 61 ++++++++++++ lib/widgets/loading_modal_widget.dart | 22 +++++ lib/widgets/responsive.dart | 34 +++++++ 14 files changed, 899 insertions(+), 27 deletions(-) create mode 100644 lib/firebase/auth.dart create mode 100644 lib/firebase/storage.dart create mode 100644 lib/providers/theme_provider.dart create mode 100644 lib/routes.dart create mode 100644 lib/screens/dashboard_screen.dart create mode 100644 lib/screens/login_screen.dart create mode 100644 lib/screens/onboarding_screen.dart create mode 100644 lib/screens/register_screen.dart create mode 100644 lib/settings/preferences.dart create mode 100644 lib/settings/themes.dart create mode 100644 lib/widgets/avatar_picker.dart create mode 100644 lib/widgets/loading_modal_widget.dart create mode 100644 lib/widgets/responsive.dart (limited to 'lib') diff --git a/lib/firebase/auth.dart b/lib/firebase/auth.dart new file mode 100644 index 0000000..a876d5b --- /dev/null +++ b/lib/firebase/auth.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart'; + +import 'storage.dart'; + +class Auth { + final FirebaseAuth _auth = FirebaseAuth.instance; + final GithubAuthProvider _githubProvider = GithubAuthProvider(); + final GoogleAuthProvider _googleAuthProvider = GoogleAuthProvider(); + + User? get currentUser => _auth.currentUser; + + Future createUserWithEmailAndPassword({ + required String email, + required String password, + required String displayName, + required File avatar, + }) async { + try { + UserCredential cred = await _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + User? user = cred.user; + if (user != null) { + user.updateDisplayName(displayName); + user.updatePhotoURL(await Storage().uploadAvatar(user.uid, avatar)); + } + return true; + } catch (e) { + if (kDebugMode) print(e); + return false; + } + } + + Future signInWithEmailAndPassword({ + required String email, + required String password, + }) async { + try { + UserCredential cred = await _auth.signInWithEmailAndPassword( + email: email, + password: password, + ); + return cred.user?.emailVerified == true; + } catch (e) { + if (kDebugMode) print(e); + return false; + } + } + + Future signInWithGoogle() async { + try { + await _auth.signInWithProvider(_googleAuthProvider); + return true; + } catch (e) { + if (kDebugMode) print(e); + return false; + } + } + + Future signInWithGithub() async { + try { + await _auth.signInWithProvider(_githubProvider); + return true; + } catch (e) { + if (kDebugMode) print(e); + return false; + } + } + + Future signOut() async { + try { + await _auth.signOut(); + return true; + } catch (e) { + if (kDebugMode) print(e); + return false; + } + } +} diff --git a/lib/firebase/storage.dart b/lib/firebase/storage.dart new file mode 100644 index 0000000..3e0c629 --- /dev/null +++ b/lib/firebase/storage.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:path/path.dart'; + +class Storage { + final FirebaseStorage _storage = FirebaseStorage.instance; + + Future uploadAvatar(String userId, File file) async { + String filename = basename(file.path); + Reference ref = _storage.ref().child(userId).child(filename); + UploadTask task = ref.putFile(file); + await task.whenComplete(() => {}); + return await ref.getDownloadURL(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 0d97e25..a0ff04a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,45 +1,58 @@ +import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; -void main() { - runApp(const MyApp()); +import 'providers/theme_provider.dart'; +import 'routes.dart'; +import 'settings/themes.dart'; + +void main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(); + return runApp(const MainContent()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class MainContent extends StatefulWidget { + const MainContent({super.key}); @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'LinkChat', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const MyHomePage(title: 'LinkChat'), - ); - } + State createState() => _MainContentState(); } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); +class _MainContentState extends State { + int contador = 0; - final String title; + @override + void initState() { + super.initState(); + contador = 0; + } @override - State createState() => _MyHomePageState(); + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => ThemeProvider()), + ], + child: const LinkChat(), + ); + } } -class _MyHomePageState extends State { +class LinkChat extends StatelessWidget { + const LinkChat({super.key}); + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(widget.title), - ), - body: Center( - child: Text(widget.title), - ), + final ThemeProvider provider = context.watch(); + final ThemeData? theme = provider.theme; + provider.syncFromPrefs(); + return MaterialApp( + theme: theme ?? ThemeSettings.lightTheme, + darkTheme: theme ?? ThemeSettings.darkTheme, + themeMode: ThemeMode.system, + routes: getApplicationRoutes(), + initialRoute: '/login', ); } } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..3a65731 --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import '../settings/preferences.dart'; + +class ThemeProvider with ChangeNotifier { + bool synced = false; + ThemeData? _theme; + + void syncFromPrefs() { + if (synced) return; + Preferences.getTheme().then((t) { + synced = true; + _theme = t; + notifyListeners(); + }); + } + + ThemeData? get theme => _theme; + + set theme(ThemeData? theme) { + Preferences.setTheme(theme); + _theme = theme; + notifyListeners(); + } +} diff --git a/lib/routes.dart b/lib/routes.dart new file mode 100644 index 0000000..9446a01 --- /dev/null +++ b/lib/routes.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import 'screens/dashboard_screen.dart'; +import 'screens/login_screen.dart'; +import 'screens/onboarding_screen.dart'; +import 'screens/register_screen.dart'; + +Map getApplicationRoutes() { + return { + '/login': (BuildContext context) => const LoginScreen(), + '/register': (BuildContext context) => const RegisterScreen(), + '/onboard': (BuildContext context) => const OnboardingScreen(), + '/dash': (BuildContext context) => const DashboardScreen() + }; +} diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart new file mode 100644 index 0000000..cb31a58 --- /dev/null +++ b/lib/screens/dashboard_screen.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../firebase/auth.dart'; +import '../providers/theme_provider.dart'; +import '../settings/themes.dart'; + +class DashboardScreen extends StatefulWidget { + const DashboardScreen({super.key}); + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + late Auth _auth; + + @override + void initState() { + super.initState(); + _auth = Auth(); + } + + @override + Widget build(BuildContext context) { + final ThemeProvider themeProvider = context.watch(); + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar.large( + title: const Text('Inicio'), + ), + const SliverFillRemaining( + child: Placeholder(), + ), + ], + ), + drawer: Drawer( + child: ListView( + children: [ + UserAccountsDrawerHeader( + currentAccountPicture: CircleAvatar( + backgroundImage: _auth.currentUser?.photoURL != null + ? NetworkImage(_auth.currentUser!.photoURL!) + : null, + ), + accountName: Text(_auth.currentUser?.displayName ?? "Lincite"), + accountEmail: _auth.currentUser?.email != null + ? Text(_auth.currentUser!.email!) + : null, + ), + ListTile( + title: const Text('Tema'), + trailing: SegmentedButton( + segments: [ + const ButtonSegment( + value: null, + icon: Icon(Icons.brightness_auto), + ), + ButtonSegment( + value: ThemeSettings.lightTheme, + icon: const Icon(Icons.light_mode), + ), + ButtonSegment( + value: ThemeSettings.darkTheme, + icon: const Icon(Icons.dark_mode)), + ], + selected: {themeProvider.theme}, + onSelectionChanged: ((Set newSelection) { + themeProvider.theme = newSelection.first; + }), + ), + ), + const Divider(), + ListTile( + title: const Text('Cerrar sesión'), + leading: const Icon(Icons.logout), + onTap: () => signOut(context), + ) + ], + ), + ), + floatingActionButton: FloatingActionButton.extended( + label: const Text('Nuevo'), + icon: const Icon(Icons.create), + onPressed: () { + Navigator.of(context).pushNamed('/new'); + }, + ), + ); + } + + void signOut(BuildContext context) { + _auth.signOut().then((success) { + if (success) { + Navigator.of(context).popUntil(ModalRoute.withName('/login')); + } + }); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..44cef6f --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:social_login_buttons/social_login_buttons.dart'; + +import '../firebase/auth.dart'; +import '../widgets/loading_modal_widget.dart'; +import '../widgets/responsive.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + final Auth _auth = Auth(); + + bool isLoading = false; + + final padding = 16.0; + final spacer = const SizedBox(height: 16.0); + + // TextField controllers + late TextEditingController _emailController; + late TextEditingController _passwordController; + + @override + void initState() { + super.initState(); + _emailController = TextEditingController(); + _passwordController = TextEditingController(); + _controller = AnimationController(vsync: this); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget logoItc() => Padding( + padding: EdgeInsets.all(padding * 2), + child: Image.asset('assets/logo.png', height: 120.0), + ); + + Widget loginForm() => Column( + children: [ + spacer, + Container( + width: double.infinity, + alignment: Alignment.centerLeft, + padding: EdgeInsets.symmetric(horizontal: padding), + child: Text( + 'Iniciar sesión', + style: Theme.of(context).textTheme.displaySmall, + textAlign: TextAlign.left, + ), + ), + Card( + margin: EdgeInsets.all(padding), + child: Padding( + padding: EdgeInsets.all(padding), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _emailController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Correo electrónico', + hintText: 'test@example.com', + ), + keyboardType: TextInputType.emailAddress, + ), + spacer, + TextField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Contraseña', + ), + ), + spacer, + SocialLoginButton( + buttonType: SocialLoginButtonType.generalLogin, + text: 'Iniciar sesión', + backgroundColor: Theme.of(context).colorScheme.primary, + onPressed: () => onLoginClicked(context), + ), + spacer, + TextButton( + onPressed: () { + Navigator.of(context).pushNamed('/register'); + }, + child: const Text('Crear cuenta'), + ), + ], + ), + ), + ), + ], + ); + + void onLoginClicked(BuildContext context) { + _auth + .signInWithEmailAndPassword( + email: _emailController.text, + password: _passwordController.text, + ) + .then((success) { + setState(() { + isLoading = false; + }); + // TODO: checar si el resultado es true + Navigator.of(context).pushNamed('/dash'); + }); + } + + void onGoogleLoginClicked(BuildContext context) { + _auth.signInWithGoogle().then((success) { + setState(() { + isLoading = false; + }); + if (success) { + Navigator.of(context).pushNamed('/dash'); + } + }); + } + + void onGithubLoginClicked(BuildContext context) { + _auth.signInWithGithub().then((success) { + setState(() { + isLoading = false; + }); + if (success) { + Navigator.of(context).pushNamed('/dash'); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + SingleChildScrollView( + child: SafeArea( + child: Responsive( + mobile: Column( + children: [logoItc(), loginForm()], + ), + desktop: Row( + children: [ + Expanded(child: logoItc()), + Expanded(child: loginForm()), + ], + ), + ), + ), + ), + isLoading ? const LoadingModal() : const SizedBox.shrink(), + ], + ), + ); + } +} diff --git a/lib/screens/onboarding_screen.dart b/lib/screens/onboarding_screen.dart new file mode 100644 index 0000000..e1755c4 --- /dev/null +++ b/lib/screens/onboarding_screen.dart @@ -0,0 +1,103 @@ +import 'package:concentric_transition/concentric_transition.dart'; +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +import '../widgets/responsive.dart'; + +class PageData { + final String title; + final String icon; + final MaterialColor bgColor; + final Color textColor; + + const PageData({ + required this.title, + required this.icon, + required this.bgColor, + required this.textColor, + }); +} + +class OnboardingScreen extends StatelessWidget { + const OnboardingScreen({super.key}); + + static const pages = [ + PageData( + title: '¡Bienvenidx a LinkChat!', + icon: 'assets/star.json', + bgColor: Colors.deepPurple, + textColor: Colors.white, + ), + PageData( + title: + 'Una nueva experiencia de chat donde solo puedes compartir enlaces', + icon: 'assets/share.json', + bgColor: Colors.red, + textColor: Colors.white, + ), + PageData( + title: 'Guarda tus enlaces favoritos para verlos más tarde', + icon: 'assets/heart.json', + bgColor: Colors.green, + textColor: Colors.black, + ), + ]; + + Widget icon(PageData data) => CircleAvatar( + radius: 100.0, + backgroundColor: data.bgColor.shade200, + child: Lottie.asset( + data.icon, + height: 100.0, + fit: BoxFit.fill, + ), + ); + + Widget text(BuildContext context, PageData data) => Text( + data.title, + textAlign: TextAlign.center, + style: Theme.of(context) + .typography + .englishLike + .headlineMedium! + .copyWith(color: data.textColor), + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: ConcentricPageView( + colors: pages.map((PageData p) => p.bgColor).toList(), + itemCount: pages.length, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (int index) { + PageData data = pages[index]; + return Center( + child: Padding( + padding: const EdgeInsets.all(15.0), + child: Responsive( + mobile: Column( + mainAxisSize: MainAxisSize.min, + children: [ + icon(data), + const SizedBox(height: 50.0), + text(context, data), + ], + ), + desktop: Row( + children: [ + Expanded(child: icon(data)), + Expanded(child: text(context, data)), + ], + ), + ), + ), + ); + }, + onFinish: () { + Navigator.of(context).pushNamed('/dash'); + }, + ), + ); + } +} diff --git a/lib/screens/register_screen.dart b/lib/screens/register_screen.dart new file mode 100644 index 0000000..349cac0 --- /dev/null +++ b/lib/screens/register_screen.dart @@ -0,0 +1,178 @@ +import 'dart:io'; + +import 'package:email_validator/email_validator.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:social_login_buttons/social_login_buttons.dart'; + +import '../firebase/auth.dart'; +import '../widgets/avatar_picker.dart'; +import '../widgets/loading_modal_widget.dart'; + +class RegisterScreen extends StatefulWidget { + const RegisterScreen({super.key}); + + @override + State createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + bool isLoading = false; + + final padding = 16.0; + final spacer = const SizedBox(height: 16.0); + + XFile? _avatar; + + // TextField controllers + late TextEditingController _nameController; + late TextEditingController _emailController; + late TextEditingController _passwordController; + + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _emailController = TextEditingController(); + _passwordController = TextEditingController(); + } + + bool validateForm() { + if (_formKey.currentState!.validate() && _avatar != null) { + return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar.large( + title: const Text('Crear cuenta'), + ), + SliverFillRemaining( + child: Stack( + children: [ + SingleChildScrollView( + child: Column( + children: [ + spacer, + Card( + margin: + EdgeInsets.fromLTRB(padding, 0, padding, padding), + child: Padding( + padding: EdgeInsets.all(padding), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AvatarPicker( + avatar: _avatar, + onAvatarPicked: (avatar) { + setState(() { + _avatar = avatar; + }); + }, + ), + spacer, + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Nombre', + hintText: 'Juan Pérez', + ), + keyboardType: TextInputType.name, + validator: (value) { + if (value == null || value.isEmpty) { + return 'El nombre no debe estar vacío'; + } + return null; + }, + ), + spacer, + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Correo electrónico', + hintText: 'test@example.com', + ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return 'El correo no debe estar vacío'; + } else if (!EmailValidator.validate( + value)) { + return 'El formato del correo es inválido'; + } + return null; + }, + ), + spacer, + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Contraseña', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'La contraseña no debe estar vacía'; + } + return null; + }, + ), + spacer, + SocialLoginButton( + buttonType: + SocialLoginButtonType.generalLogin, + text: 'Crear cuenta', + backgroundColor: + Theme.of(context).colorScheme.primary, + onPressed: () => onRegisterClicked(context), + ), + ], + ), + ), + ), + ), + ], + ), + ), + isLoading ? const LoadingModal() : const SizedBox.shrink(), + ], + ), + ) + ], + ), + ); + } + + void onRegisterClicked(BuildContext context) { + setState(() { + isLoading = false; + if (validateForm()) { + Auth() + .createUserWithEmailAndPassword( + email: _emailController.text, + password: _passwordController.text, + displayName: _nameController.text, + avatar: File(_avatar!.path), + ) + .then((success) { + if (success) { + Navigator.of(context).pushNamed('/onboard'); + } + }); + } + }); + } +} diff --git a/lib/settings/preferences.dart b/lib/settings/preferences.dart new file mode 100644 index 0000000..3c0ef5d --- /dev/null +++ b/lib/settings/preferences.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../settings/themes.dart'; + +class Preferences { + static SharedPreferences? _prefs; + + static Future get prefs async { + _prefs ??= await SharedPreferences.getInstance(); + return _prefs!; + } + + static Future getTheme() async { + switch ((await prefs).getString('theme')) { + case 'light': + return ThemeSettings.lightTheme; + case 'dark': + return ThemeSettings.darkTheme; + } + return null; + } + + static void setTheme(ThemeData? theme) { + prefs.then((p) { + if (theme == ThemeSettings.lightTheme) { + p.setString('theme', 'light'); + } else if (theme == ThemeSettings.darkTheme) { + p.setString('theme', 'dark'); + } else { + p.remove('theme'); + } + }); + } +} diff --git a/lib/settings/themes.dart b/lib/settings/themes.dart new file mode 100644 index 0000000..bc489b7 --- /dev/null +++ b/lib/settings/themes.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class ThemeSettings { + static ThemeData lightTheme = ThemeData( + useMaterial3: true, + primaryColor: Colors.purple, + ); + + static ThemeData darkTheme = ThemeData.dark( + useMaterial3: true, + ).copyWith( + colorScheme: const ColorScheme.dark().copyWith( + primary: Colors.purple, + ), + ); +} diff --git a/lib/widgets/avatar_picker.dart b/lib/widgets/avatar_picker.dart new file mode 100644 index 0000000..bdfec6d --- /dev/null +++ b/lib/widgets/avatar_picker.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +class AvatarPicker extends StatelessWidget { + final ImagePicker picker = ImagePicker(); + final XFile? avatar; + final Function(XFile? avatar) onAvatarPicked; + + AvatarPicker({ + super.key, + required this.avatar, + required this.onAvatarPicked, + }); + + File? _getAvatarFile() { + return avatar == null ? null : File(avatar!.path); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: 50.0, + backgroundImage: + _getAvatarFile() == null ? null : FileImage(_getAvatarFile()!), + child: _getAvatarFile() == null + ? const Icon(Icons.person, size: 50.0) + : null, + ), + const SizedBox(width: 18.0), + Column( + children: [ + FilledButton.icon( + icon: const Icon(Icons.camera_alt), + label: const Text('Cámara'), + onPressed: () { + picker.pickImage(source: ImageSource.camera).then((image) { + onAvatarPicked(image); + }); + }, + ), + const SizedBox(height: 16.0), + FilledButton.icon( + icon: const Icon(Icons.collections), + label: const Text('Galería'), + onPressed: () { + picker.pickImage(source: ImageSource.gallery).then((image) { + onAvatarPicked(image); + }); + }, + ) + ], + ) + ], + ); + } +} diff --git a/lib/widgets/loading_modal_widget.dart b/lib/widgets/loading_modal_widget.dart new file mode 100644 index 0000000..c19a411 --- /dev/null +++ b/lib/widgets/loading_modal_widget.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class LoadingModal extends StatelessWidget { + const LoadingModal({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Card( + shape: CircleBorder(), + child: Padding( + padding: EdgeInsets.all(20.0), + child: SizedBox( + width: 200, + height: 200, + child: CircularProgressIndicator(value: null), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/responsive.dart b/lib/widgets/responsive.dart new file mode 100644 index 0000000..f5f2739 --- /dev/null +++ b/lib/widgets/responsive.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class Responsive extends StatelessWidget { + final Widget mobile; + final Widget? tablet; + final Widget desktop; + + const Responsive({ + super.key, + required this.mobile, + this.tablet, + required this.desktop, + }); + + static bool isMobile(BuildContext context) => + MediaQuery.of(context).size.width < 576; + + static bool isTablet(BuildContext context) => + MediaQuery.of(context).size.width >= 576 && + MediaQuery.of(context).size.width <= 992; + + static bool isDesktop(BuildContext context) => + MediaQuery.of(context).size.width > 992; + + @override + Widget build(BuildContext context) { + if (isDesktop(context)) { + return desktop; + } else if (tablet != null && isTablet(context)) { + return tablet!; + } + return mobile; + } +} -- cgit v1.2.3