aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorIván Ávalos <avalos@disroot.org>2023-05-20 16:43:21 -0600
committerIván Ávalos <avalos@disroot.org>2023-05-20 16:43:21 -0600
commitcd8f22959e62c0b7b191e64f4ecfec53cba9f364 (patch)
tree9e40717d4e7b5c18694ab65558acda65875f817b /lib
parent3a3af285a7460a7e2f6850b9a8dd7465492163f6 (diff)
downloadlinkchat-cd8f22959e62c0b7b191e64f4ecfec53cba9f364.tar.gz
linkchat-cd8f22959e62c0b7b191e64f4ecfec53cba9f364.tar.bz2
linkchat-cd8f22959e62c0b7b191e64f4ecfec53cba9f364.zip
Se añade autenticación y onboarding
Diffstat (limited to 'lib')
-rw-r--r--lib/firebase/auth.dart83
-rw-r--r--lib/firebase/storage.dart16
-rw-r--r--lib/main.dart67
-rw-r--r--lib/providers/theme_provider.dart25
-rw-r--r--lib/routes.dart15
-rw-r--r--lib/screens/dashboard_screen.dart100
-rw-r--r--lib/screens/login_screen.dart171
-rw-r--r--lib/screens/onboarding_screen.dart103
-rw-r--r--lib/screens/register_screen.dart178
-rw-r--r--lib/settings/preferences.dart35
-rw-r--r--lib/settings/themes.dart16
-rw-r--r--lib/widgets/avatar_picker.dart61
-rw-r--r--lib/widgets/loading_modal_widget.dart22
-rw-r--r--lib/widgets/responsive.dart34
14 files changed, 899 insertions, 27 deletions
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<bool> 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<bool> 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<bool> signInWithGoogle() async {
+ try {
+ await _auth.signInWithProvider(_googleAuthProvider);
+ return true;
+ } catch (e) {
+ if (kDebugMode) print(e);
+ return false;
+ }
+ }
+
+ Future<bool> signInWithGithub() async {
+ try {
+ await _auth.signInWithProvider(_githubProvider);
+ return true;
+ } catch (e) {
+ if (kDebugMode) print(e);
+ return false;
+ }
+ }
+
+ Future<bool> 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<String> 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<String> 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<MainContent> createState() => _MainContentState();
}
-class MyHomePage extends StatefulWidget {
- const MyHomePage({super.key, required this.title});
+class _MainContentState extends State<MainContent> {
+ int contador = 0;
- final String title;
+ @override
+ void initState() {
+ super.initState();
+ contador = 0;
+ }
@override
- State<MyHomePage> createState() => _MyHomePageState();
+ Widget build(BuildContext context) {
+ return MultiProvider(
+ providers: [
+ ChangeNotifierProvider(create: (_) => ThemeProvider()),
+ ],
+ child: const LinkChat(),
+ );
+ }
}
-class _MyHomePageState extends State<MyHomePage> {
+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<ThemeProvider>();
+ 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<String, WidgetBuilder> getApplicationRoutes() {
+ return <String, WidgetBuilder>{
+ '/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<DashboardScreen> createState() => _DashboardScreenState();
+}
+
+class _DashboardScreenState extends State<DashboardScreen> {
+ late Auth _auth;
+
+ @override
+ void initState() {
+ super.initState();
+ _auth = Auth();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final ThemeProvider themeProvider = context.watch<ThemeProvider>();
+ 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<ThemeData?>(
+ segments: [
+ const ButtonSegment<ThemeData?>(
+ value: null,
+ icon: Icon(Icons.brightness_auto),
+ ),
+ ButtonSegment<ThemeData?>(
+ value: ThemeSettings.lightTheme,
+ icon: const Icon(Icons.light_mode),
+ ),
+ ButtonSegment<ThemeData?>(
+ value: ThemeSettings.darkTheme,
+ icon: const Icon(Icons.dark_mode)),
+ ],
+ selected: <ThemeData?>{themeProvider.theme},
+ onSelectionChanged: ((Set<ThemeData?> 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<LoginScreen> createState() => _LoginScreenState();
+}
+
+class _LoginScreenState extends State<LoginScreen>
+ 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<RegisterScreen> createState() => _RegisterScreenState();
+}
+
+class _RegisterScreenState extends State<RegisterScreen> {
+ 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<FormState>();
+
+ @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<SharedPreferences> get prefs async {
+ _prefs ??= await SharedPreferences.getInstance();
+ return _prefs!;
+ }
+
+ static Future<ThemeData?> 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;
+ }
+}