diff --git a/.gitignore b/.gitignore index e2abf47..0633bed 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,7 @@ migrate_working_dir/ .flutter-plugins-dependencies .pub-cache/ .pub/ -/build/ +app/build/ # Symbolication related app.*.symbols diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index b5b3e7c..d99e12e 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.fernalabs.ferna.ferna" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/app/lib/main.dart b/app/lib/main.dart index 7b7f5b6..3a0105a 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,122 +1,48 @@ +import 'package:ferna/providers/auth_provider.dart'; +import 'package:ferna/screens/login_screen.dart'; +import 'package:ferna/theme/theme_data.dart'; import 'package:flutter/material.dart'; - -void main() { - runApp(const MyApp()); +import 'package:provider/provider.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialzie auth provider + final authProvider = await AuthProvider.initialize(); + + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: authProvider), + ], + child: FernaApp(), + ), + ); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class FernaApp extends StatelessWidget { + const FernaApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), + title: 'Ferna', + debugShowCheckedModeBanner: false, + theme: FernaTheme.light, + darkTheme: FernaTheme.dark, + themeMode: ThemeMode.system, + // TODO: Replace with router + home: const LoginScreen(), + routes: {'/home': (_) => const HomeScreen()}, ); } } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. - ); + return const Placeholder(child: Text(":)")); } } diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart new file mode 100644 index 0000000..4bcd21e --- /dev/null +++ b/app/lib/providers/auth_provider.dart @@ -0,0 +1,80 @@ +import 'package:ferna/services/http_client.dart'; +import 'package:flutter/material.dart'; +// ignore: unused_import +import 'package:provider/provider.dart'; + +import '../services/auth_service.dart'; +import '../services/storage_service.dart'; + +class AuthProvider with ChangeNotifier { + final AuthService _authService = AuthService.instance; + AuthProvider._(); + bool _isLoading = false; + bool get isLoading => _isLoading; + String _serverUrl = ''; + String get serverUrl => _serverUrl; + + // Must call this before using any other AuthProvider methods. + static Future initialize() async { + final provider = AuthProvider._(); + + // Load saved serverUrl or use a default. + final savedUrl = await StorageService.getServerUrl(); + provider._serverUrl = savedUrl ?? 'http://ferna.local'; + + // Initialize AuthService (sets up Dio & loads any persisted cookies). + await provider._authService.initialize(serverUrl: provider._serverUrl); + + return provider; + } + + /// Change serverUrl, persist it, and re-initialize Dio so its baseUrl updates. + Future updateServerUrl(String newUrl) async { + if (newUrl == _serverUrl) return; + + _serverUrl = newUrl; + notifyListeners(); + + // Persist the new URL: + await StorageService.saveServerUrl(newUrl); + + // Re-init only Dio’s baseUrl; reuse the same cookieJar + await HttpClient.instance.init(baseUrl: _serverUrl); + + notifyListeners(); + } + + Future login({required String email, required String password}) async { + _isLoading = true; + notifyListeners(); + + try { + await _authService.login(email: email, password: password); + _isLoading = false; + notifyListeners(); + } catch (e) { + _isLoading = false; + notifyListeners(); + rethrow; + } + } + + Future signUp({required String email, required String password}) async { + _isLoading = true; + notifyListeners(); + + try { + final userId = await _authService.signup( + email: email, + password: password, + ); + _isLoading = false; + notifyListeners(); + return userId; + } catch (e) { + _isLoading = false; + notifyListeners(); + rethrow; + } + } +} diff --git a/app/lib/screens/login_screen.dart b/app/lib/screens/login_screen.dart new file mode 100644 index 0000000..6573054 --- /dev/null +++ b/app/lib/screens/login_screen.dart @@ -0,0 +1,448 @@ +import 'package:ferna/providers/auth_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State + with SingleTickerProviderStateMixin { + static const _animationDuration = Duration(milliseconds: 300); + + final _formKey = GlobalKey(); + + // Controllers for the form fields. + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + final TextEditingController _serverUrlController = TextEditingController( + text: 'https://ferna.local', + ); + + bool _obscurePassword = true; + bool _obscureConfirm = true; + bool _isLogin = true; // Toggle between “Login” and “Sign Up” + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _serverUrlController.dispose(); + super.dispose(); + } + + void _showServerConfigDialog() { + final auth = context.read(); + final theme = Theme.of(context); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Configure Server'), + content: TextFormField( + controller: _serverUrlController, + decoration: InputDecoration( + labelText: 'Server URL', + hintText: 'http://ferna.local', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.onSurfaceVariant, + ), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final newUrl = _serverUrlController.text.trim(); + if (newUrl.isNotEmpty && newUrl != auth.serverUrl) { + // Update in AuthProvider (persists to SharedPreferences, re-inits Dio) + await auth.updateServerUrl(newUrl); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Save'), + ), + ], + ); + }, + ); + } + + /// Called when “Login” or “Sign Up” button is pressed. + Future _onSubmit() async { + final auth = context.read(); + if (!_formKey.currentState!.validate()) return; + + final email = _emailController.text.trim(); + final password = _passwordController.text; + + try { + if (_isLogin) { + await auth.login(email: email, password: password); + } else { + await auth.signUp(email: email, password: password); + } + + // On success, navigate to your home screen: + if (!mounted) return; + Navigator.of(context).pushReplacementNamed('/home'); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${_isLogin ? 'Login' : 'Sign Up'} failed: $error'), + backgroundColor: Theme.of(context).colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + /// Builds the “Email” TextFormField. + Widget _buildEmailField(ColorScheme cs) { + return TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: 'Email', + hintText: 'hello@example.com', + prefixIcon: const Icon(Icons.email_outlined), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Email cannot be empty'; + } + final emailRegex = RegExp( + r"^[a-zA-Z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$", + ); + if (!emailRegex.hasMatch(value.trim())) { + return 'Enter a valid email'; + } + return null; + }, + ); + } + + /// Builds the “Password” TextFormField. + Widget _buildPasswordField(ColorScheme cs) { + return TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password cannot be empty'; + } + if (value.length < 6) { + return 'At least 6 characters'; + } + return null; + }, + ); + } + + /// Builds the “Confirm Password” TextFormField (only in Sign Up mode). + Widget _buildConfirmField(ColorScheme cs) { + return TextFormField( + controller: _confirmPasswordController, + obscureText: _obscureConfirm, + decoration: InputDecoration( + labelText: 'Confirm Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(_obscureConfirm ? Icons.visibility_off : Icons.visibility), + onPressed: () { + setState(() => _obscureConfirm = !_obscureConfirm); + }, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Confirm your password'; + } + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final auth = context.watch(); + final cs = theme.colorScheme; + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // App Icon + CircleAvatar( + radius: 32, + backgroundColor: cs.primaryContainer, + child: Icon( + Icons.spa_outlined, + size: 40, + color: cs.onPrimaryContainer, + ), + ), + const SizedBox(height: 16), + + // Title & Subtitle + Text( + 'Welcome to Ferna', + style: theme.textTheme.headlineSmall!.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'Your personal plant care companion', + style: theme.textTheme.bodyMedium!.copyWith( + color: cs.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + + // Toggle: Login / Sign Up + Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + if (!_isLogin) { + setState(() { + _isLogin = true; + }); + } + }, + child: AnimatedContainer( + duration: _animationDuration, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: _isLogin + ? cs.primaryContainer + : cs.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + ), + alignment: Alignment.center, + child: Text( + 'Login', + style: theme.textTheme.titleMedium!.copyWith( + color: _isLogin + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + + Expanded( + child: GestureDetector( + onTap: () { + if (_isLogin) { + setState(() { + _isLogin = false; + }); + } + }, + child: AnimatedContainer( + duration: _animationDuration, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: !_isLogin + ? cs.primaryContainer + : cs.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + alignment: Alignment.center, + child: Text( + 'Sign Up', + style: theme.textTheme.titleMedium!.copyWith( + color: !_isLogin + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + + Form( + key: _formKey, + child: Column( + children: [ + // Email field + _buildEmailField(cs), + const SizedBox(height: 16), + + // Password field + _buildPasswordField(cs), + const SizedBox(height: 8), + + // If in Login: show “Forgot password?” link + if (_isLogin) ...[ + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + // TODO: push to Forgot Password screen + }, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(50, 30), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: cs.primary, + textStyle: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + child: const Text('Forgot password?'), + ), + ), + const SizedBox(height: 16), + ] else + // If in Sign Up: leave a bit of vertical spacing before confirm field + const SizedBox(height: 16), + + // CONFIRM PASSWORD FIELD + AnimatedSize( + duration: _animationDuration, + curve: Curves.easeInOut, + child: _isLogin + ? const SizedBox.shrink() + : Column( + children: [ + _buildConfirmField(cs), + const SizedBox(height: 24), + ], + ), + ), + + // SUBMIT BUTTON + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: auth.isLoading ? null : _onSubmit, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: auth.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + _isLogin ? 'Login' : 'Sign Up', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 24), + + // CONFIGURE SERVER + TextButton.icon( + onPressed: _showServerConfigDialog, + icon: Icon( + Icons.settings_outlined, + color: cs.onSurfaceVariant, + size: 20, + ), + label: Text( + 'Configure Server', + style: theme.textTheme.bodyMedium!.copyWith( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(50, 30), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/services/auth_service.dart b/app/lib/services/auth_service.dart new file mode 100644 index 0000000..9de5530 --- /dev/null +++ b/app/lib/services/auth_service.dart @@ -0,0 +1,51 @@ +import 'package:ferna/services/http_client.dart'; + +class AuthService { + AuthService._(); + + static final AuthService instance = AuthService._(); + + //Must be called before using any otehr methods of AuthService + Future initialize({required String serverUrl}) async { + await HttpClient.instance.init(baseUrl: serverUrl); + } + + /// Sign up a new user. Returns the user_id on success. + Future signup({required String email, required String password}) async { + final dio = HttpClient.instance.dio; + final response = await dio.post( + '/auth/local/signup', + data: {'user': email, 'passwd': password}, + ); + + if (response.statusCode == 201) { + final data = response.data as Map; + if (data['success'] == true && data['user_id'] != null) { + return data['user_id'] as int; + } else { + throw Exception('Sign-up failed: ${response.data}'); + } + } else { + throw Exception( + 'Sign-up HTTP ${response.statusCode}: ${response.statusMessage}', + ); + } + } + + Future login({required String email, required String password}) async { + final dio = HttpClient.instance.dio; + final response = await dio.post( + '/auth/local/login', + data: {'user': email, 'passwd': password}, + ); + + if (response.statusCode == 200) { + // CookieManager already saved any Set-Cookie headers to disk. + return; + } else { + throw Exception( + 'Login HTTP ${response.statusCode}: ${response.statusMessage}', + ); + } + } +} diff --git a/app/lib/services/http_client.dart b/app/lib/services/http_client.dart new file mode 100644 index 0000000..08cae73 --- /dev/null +++ b/app/lib/services/http_client.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:path_provider/path_provider.dart'; + +class HttpClient { + HttpClient._(); + + static final HttpClient instance = HttpClient._(); + + late Dio dio; + late final PersistCookieJar cookieJar; + bool _cookieJarInitialized = false; + + Future init({required String baseUrl, String? testCookieDir}) async { + if (!_cookieJarInitialized) { + //figure out cookie path + final cookiesPath = + testCookieDir ?? + (await (() async { + final appDocDir = await getApplicationDocumentsDirectory(); + return '${appDocDir.path}/.cookies/'; + })()); + + //create persistant cookie jar + cookieJar = PersistCookieJar( + ignoreExpires: false, + storage: FileStorage(cookiesPath), + ); + _cookieJarInitialized = true; + } + + //create dio with base url + dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + headers: {HttpHeaders.contentTypeHeader: 'application/json'}, + ), + ); + + dio.interceptors.add(CookieManager(cookieJar)); + } + + Future clearCookies() async { + await cookieJar.deleteAll(); + } +} diff --git a/app/lib/services/storage_service.dart b/app/lib/services/storage_service.dart new file mode 100644 index 0000000..5cee733 --- /dev/null +++ b/app/lib/services/storage_service.dart @@ -0,0 +1,20 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +const _kServerUrlKey = 'ferna_server_url'; + +class StorageService { + static Future _prefs() async => + await SharedPreferences.getInstance(); + + /// Save the server URL for future app launches. + static Future saveServerUrl(String url) async { + final prefs = await _prefs(); + await prefs.setString(_kServerUrlKey, url); + } + + /// Read the persisted server URL (or null if none was saved). + static Future getServerUrl() async { + final prefs = await _prefs(); + return prefs.getString(_kServerUrlKey); + } +} diff --git a/app/lib/theme/theme_data.dart b/app/lib/theme/theme_data.dart new file mode 100644 index 0000000..2d50345 --- /dev/null +++ b/app/lib/theme/theme_data.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; + +class FernaTheme { + static const double _borderRadius = 12.0; + static const Color _seedColor = Color(0xFF4CAF50); + + /// Light theme + static final ThemeData light = _buildTheme(Brightness.light); + + /// Dark theme + static final ThemeData dark = _buildTheme(Brightness.dark); + + static ThemeData _buildTheme(Brightness brightness) { + final colorScheme = ColorScheme.fromSeed( + seedColor: _seedColor, + brightness: brightness, + ); + + final isDark = brightness == Brightness.dark; + + return ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: colorScheme, + + // Scaffold / background + scaffoldBackgroundColor: colorScheme.surface, + + // AppBar + appBarTheme: AppBarTheme( + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + elevation: 0, + centerTitle: true, + titleTextStyle: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 20, + ), + ), + + // Text themes + textTheme: isDark + ? Typography.material2021().white + : Typography.material2021().black, + + // ElevatedButton + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_borderRadius), + ), + textStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + + // OutlinedButton + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.primary, + side: BorderSide(color: colorScheme.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_borderRadius), + ), + textStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + + // TextButton + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + textStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + ), // Input fields + inputDecorationTheme: InputDecorationTheme( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + labelStyle: TextStyle(color: colorScheme.primary), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(_borderRadius), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(_borderRadius), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(_borderRadius), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + ), + + // SnackBar + snackBarTheme: SnackBarThemeData( + backgroundColor: colorScheme.secondaryContainer, + contentTextStyle: TextStyle( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_borderRadius), + ), + ), + // Dialogs + dialogTheme: DialogThemeData( + backgroundColor: colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_borderRadius), + ), + titleTextStyle: TextStyle( + color: colorScheme.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + contentTextStyle: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + + // BottomNavigationBar (fixed, Material 3 style) + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: colorScheme.surface, + selectedItemColor: colorScheme.primary, + unselectedItemColor: colorScheme.onSurfaceVariant, + showUnselectedLabels: true, + elevation: 8, + ), + + // FloatingActionButton + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + ), + + // Divider + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant, + thickness: 1, + space: 1, + ), + + // Card + cardTheme: CardThemeData( + color: colorScheme.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_borderRadius), + ), + elevation: isDark ? 2 : 4, + ), + + // Chip + chipTheme: ChipThemeData( + backgroundColor: colorScheme.primaryContainer, + disabledColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.12), + selectedColor: colorScheme.primary, + labelStyle: TextStyle( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + secondaryLabelStyle: TextStyle( + color: colorScheme.onPrimary, + fontWeight: FontWeight.w500, + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_borderRadius), + ), + secondarySelectedColor: colorScheme.primaryContainer, + brightness: brightness, + ), // Slider + sliderTheme: SliderThemeData( + activeTrackColor: colorScheme.primary, + inactiveTrackColor: colorScheme.primary.withValues(alpha: 0.3), + thumbColor: colorScheme.primary, + valueIndicatorColor: colorScheme.primaryContainer, + ), + + // Switch + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (states) => colorScheme.primary, + ), + trackColor: WidgetStateProperty.resolveWith( + (states) => colorScheme.primary.withValues(alpha: 0.5), + ), + ), + + // ProgressIndicator + progressIndicatorTheme: ProgressIndicatorThemeData( + color: colorScheme.primary, + ), + + // IconButton + iconTheme: IconThemeData(color: colorScheme.onSurface), + ); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 3af1452..86dbc01 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + url: "https://pub.dev" + source: hosted + version: "82.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + url: "https://pub.dev" + source: hosted + version: "7.4.5" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -17,6 +41,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" + url: "https://pub.dev" + source: hosted + version: "8.10.1" characters: dependency: transitive description: @@ -25,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -33,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" collection: dependency: transitive description: @@ -41,6 +153,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + sha256: a6ac027d3ed6ed756bfce8f3ff60cb479e266f3b0fdabd6242b804b6765e52de + url: "https://pub.dev" + source: hosted + version: "4.0.8" + coverage: + dependency: transitive + description: + name: coverage + sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080 + url: "https://pub.dev" + source: hosted + version: "1.14.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -49,6 +193,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + dio: + dependency: "direct main" + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + sha256: "47cacbf6a783c263bfa7cd7d08101e93127d87760ddb003ba289162f7be0f679" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" fake_async: dependency: transitive description: @@ -57,6 +233,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -75,6 +275,83 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -107,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -131,6 +416,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "4546eac99e8967ea91bae633d2ca7698181d008e95fa4627330cf903d573277a" + url: "https://pub.dev" + source: hosted + version: "5.4.6" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -139,11 +464,219 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -168,6 +701,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -184,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + url: "https://pub.dev" + source: hosted + version: "1.25.15" test_api: dependency: transitive description: @@ -192,6 +741,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + test_core: + dependency: transitive + description: + name: test_core + sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + url: "https://pub.dev" + source: hosted + version: "0.6.8" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" vector_math: dependency: transitive description: @@ -208,6 +789,62 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.8.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.27.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a2215e9..f109b14 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -34,6 +34,12 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + provider: ^6.1.5 + dio: ^5.8.0+1 + cookie_jar: ^4.0.8 + dio_cookie_manager: ^3.2.0 + path_provider: ^2.1.5 + shared_preferences: ^2.5.3 dev_dependencies: flutter_test: @@ -45,6 +51,9 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^5.0.0 + mockito: ^5.4.6 + test: any + build_runner: any # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/app/test/mocks.dart b/app/test/mocks.dart new file mode 100644 index 0000000..50e9d18 --- /dev/null +++ b/app/test/mocks.dart @@ -0,0 +1,6 @@ +import 'package:dio/dio.dart'; +import 'package:mockito/annotations.dart'; + +// Tell Mockito to generate a MockDio class for us. +@GenerateMocks([Dio]) +void main() {} diff --git a/app/test/mocks.mocks.dart b/app/test/mocks.mocks.dart new file mode 100644 index 0000000..5208d07 --- /dev/null +++ b/app/test/mocks.mocks.dart @@ -0,0 +1,806 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in ferna/test/mocks.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i8; + +import 'package:dio/src/adapter.dart' as _i3; +import 'package:dio/src/cancel_token.dart' as _i9; +import 'package:dio/src/dio.dart' as _i7; +import 'package:dio/src/dio_mixin.dart' as _i5; +import 'package:dio/src/options.dart' as _i2; +import 'package:dio/src/response.dart' as _i6; +import 'package:dio/src/transformer.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions { + _FakeBaseOptions_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeHttpClientAdapter_1 extends _i1.SmartFake + implements _i3.HttpClientAdapter { + _FakeHttpClientAdapter_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTransformer_2 extends _i1.SmartFake implements _i4.Transformer { + _FakeTransformer_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeInterceptors_3 extends _i1.SmartFake implements _i5.Interceptors { + _FakeInterceptors_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeResponse_4 extends _i1.SmartFake implements _i6.Response { + _FakeResponse_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio { + _FakeDio_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [Dio]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDio extends _i1.Mock implements _i7.Dio { + MockDio() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.BaseOptions get options => + (super.noSuchMethod( + Invocation.getter(#options), + returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)), + ) + as _i2.BaseOptions); + + @override + _i3.HttpClientAdapter get httpClientAdapter => + (super.noSuchMethod( + Invocation.getter(#httpClientAdapter), + returnValue: _FakeHttpClientAdapter_1( + this, + Invocation.getter(#httpClientAdapter), + ), + ) + as _i3.HttpClientAdapter); + + @override + _i4.Transformer get transformer => + (super.noSuchMethod( + Invocation.getter(#transformer), + returnValue: _FakeTransformer_2( + this, + Invocation.getter(#transformer), + ), + ) + as _i4.Transformer); + + @override + _i5.Interceptors get interceptors => + (super.noSuchMethod( + Invocation.getter(#interceptors), + returnValue: _FakeInterceptors_3( + this, + Invocation.getter(#interceptors), + ), + ) + as _i5.Interceptors); + + @override + set options(_i2.BaseOptions? _options) => super.noSuchMethod( + Invocation.setter(#options, _options), + returnValueForMissingStub: null, + ); + + @override + set httpClientAdapter(_i3.HttpClientAdapter? _httpClientAdapter) => + super.noSuchMethod( + Invocation.setter(#httpClientAdapter, _httpClientAdapter), + returnValueForMissingStub: null, + ); + + @override + set transformer(_i4.Transformer? _transformer) => super.noSuchMethod( + Invocation.setter(#transformer, _transformer), + returnValueForMissingStub: null, + ); + + @override + void close({bool? force = false}) => super.noSuchMethod( + Invocation.method(#close, [], {#force: force}), + returnValueForMissingStub: null, + ); + + @override + _i8.Future<_i6.Response> head( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #head, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #head, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> headUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #headUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #headUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> get( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #get, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #get, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> getUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #getUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #getUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> post( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> postUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #postUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #postUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> put( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #put, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> putUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #putUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #putUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> patch( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #patch, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> patchUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #patchUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #patchUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> delete( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #delete, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> deleteUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #deleteUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #deleteUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> download( + String? urlPath, + dynamic savePath, { + _i2.ProgressCallback? onReceiveProgress, + Map? queryParameters, + _i9.CancelToken? cancelToken, + bool? deleteOnError = true, + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', + Object? data, + _i2.Options? options, + }) => + (super.noSuchMethod( + Invocation.method( + #download, + [urlPath, savePath], + { + #onReceiveProgress: onReceiveProgress, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #download, + [urlPath, savePath], + { + #onReceiveProgress: onReceiveProgress, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> downloadUri( + Uri? uri, + dynamic savePath, { + _i2.ProgressCallback? onReceiveProgress, + _i9.CancelToken? cancelToken, + bool? deleteOnError = true, + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', + Object? data, + _i2.Options? options, + }) => + (super.noSuchMethod( + Invocation.method( + #downloadUri, + [uri, savePath], + { + #onReceiveProgress: onReceiveProgress, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #downloadUri, + [uri, savePath], + { + #onReceiveProgress: onReceiveProgress, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> request( + String? url, { + Object? data, + Map? queryParameters, + _i9.CancelToken? cancelToken, + _i2.Options? options, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #request, + [url], + { + #data: data, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #request, + [url], + { + #data: data, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> requestUri( + Uri? uri, { + Object? data, + _i9.CancelToken? cancelToken, + _i2.Options? options, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #requestUri, + [uri], + { + #data: data, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #requestUri, + [uri], + { + #data: data, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> fetch(_i2.RequestOptions? requestOptions) => + (super.noSuchMethod( + Invocation.method(#fetch, [requestOptions]), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method(#fetch, [requestOptions]), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i7.Dio clone({ + _i2.BaseOptions? options, + _i5.Interceptors? interceptors, + _i3.HttpClientAdapter? httpClientAdapter, + _i4.Transformer? transformer, + }) => + (super.noSuchMethod( + Invocation.method(#clone, [], { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }), + returnValue: _FakeDio_5( + this, + Invocation.method(#clone, [], { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }), + ), + ) + as _i7.Dio); +} diff --git a/app/test/services/auth_service_test.dart b/app/test/services/auth_service_test.dart new file mode 100644 index 0000000..b44c77d --- /dev/null +++ b/app/test/services/auth_service_test.dart @@ -0,0 +1,116 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ferna/services/auth_service.dart'; +import 'package:ferna/services/http_client.dart'; +import 'package:mockito/mockito.dart'; +import '../mocks.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AuthService.signup', () { + late MockDio mockDio; + late AuthService authService; + + setUp(() async { + // Create a temporary directory for cookie storage + final tempDir = Directory.systemTemp.createTempSync(); + final cookieDirPath = '${tempDir.path}/cookies_test/'; + + // Initialize HttpClient, passing the temp folder so path_provider isn't needed + await HttpClient.instance.init( + baseUrl: 'http://example.com', + testCookieDir: cookieDirPath, + ); + + // Create and assign the mocked Dio + mockDio = MockDio(); + HttpClient.instance.dio = mockDio; + + // Grab the singleton AuthService (it will use HttpClient.instance.dio) + authService = AuthService.instance; + }); + + test('returns user_id on success (201 + success:true)', () async { + // Arrange: stub the mock response with status 201 and {"success": true, "user_id": 42} + final fakeResponse = Response( + data: {'success': true, 'user_id': 42}, + statusCode: 201, + requestOptions: RequestOptions(path: '/auth/local/signup'), + ); + when( + mockDio.post( + '/auth/local/signup', + data: anyNamed('data'), + ), + ).thenAnswer((_) async => fakeResponse); + + // Act: + final result = await authService.signup( + email: 'test@example.com', + password: 'password123', + ); + + // Assert: + expect(result, 42); + + // The actual implementation sends { "user": ..., "passwd": ... } + verify( + mockDio.post( + '/auth/local/signup', + data: {'user': 'test@example.com', 'passwd': 'password123'}, + ), + ).called(1); + }); + + test('throws when server returns success:false', () async { + // Arrange: server returns 200 but { "success": false, "error": "Invalid data" } + final fakeResponse = Response( + data: {'success': false, 'error': 'Invalid data'}, + statusCode: 200, + requestOptions: RequestOptions(path: '/auth/local/signup'), + ); + when( + mockDio.post( + '/auth/local/signup', + data: anyNamed('data'), + ), + ).thenAnswer((_) async => fakeResponse); + + // Act & Assert: signup() should throw because success is false. + expect( + () async => await authService.signup( + email: 'bad@example.com', + password: 'short', + ), + throwsA(isA()), + ); + }); + + test('throws when HTTP status is non-200/201', () async { + // Arrange: server returns 500 status + final fakeResponse = Response( + data: 'Internal Server Error', + statusCode: 500, + requestOptions: RequestOptions(path: '/auth/local/signup'), + statusMessage: 'Internal Server Error', + ); + when( + mockDio.post( + '/auth/local/signup', + data: anyNamed('data'), + ), + ).thenAnswer((_) async => fakeResponse); + + // Act & Assert: + expect( + () async => await authService.signup( + email: 'test@example.com', + password: 'password123', + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart deleted file mode 100644 index df71632..0000000 --- a/app/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:ferna/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}