diff --git a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 5c9d77fc1..ba9a79390 100644 --- a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine; public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.llfbandit.app_links.AppLinksPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin app_links, com.llfbandit.app_links.AppLinksPlugin", e); + } try { flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.connectivity.ConnectivityPlugin()); } catch (Exception e) { @@ -40,5 +45,10 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin sqflite_android, com.tekartik.sqflite.SqflitePlugin", e); } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); + } } } diff --git a/mobile/assets/images/google_g_logo.svg b/mobile/assets/images/google_g_logo.svg new file mode 100644 index 000000000..d733a534d --- /dev/null +++ b/mobile/assets/images/google_g_logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mobile/assets/images/logomark.svg b/mobile/assets/images/logomark.svg new file mode 100644 index 000000000..80e043546 --- /dev/null +++ b/mobile/assets/images/logomark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m index f59edcc75..5705b470d 100644 --- a/mobile/ios/Runner/GeneratedPluginRegistrant.m +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m @@ -6,6 +6,12 @@ #import "GeneratedPluginRegistrant.h" +#if __has_include() +#import +#else +@import app_links; +#endif + #if __has_include() #import #else @@ -36,14 +42,22 @@ @import sqflite_darwin; #endif +#if __has_include() +#import +#else +@import url_launcher_ios; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { + [AppLinksIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"AppLinksIosPlugin"]]; [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]]; [FlutterSecureStorageDarwinPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStorageDarwinPlugin"]]; [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; + [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; } @end diff --git a/mobile/lib/screens/backend_config_screen.dart b/mobile/lib/screens/backend_config_screen.dart index 48bf3dc66..edc50d774 100644 --- a/mobile/lib/screens/backend_config_screen.dart +++ b/mobile/lib/screens/backend_config_screen.dart @@ -35,9 +35,13 @@ class _BackendConfigScreenState extends State { Future _loadSavedUrl() async { final prefs = await SharedPreferences.getInstance(); final savedUrl = prefs.getString('backend_url'); - if (mounted && savedUrl != null && savedUrl.isNotEmpty) { + final urlToShow = (savedUrl != null && savedUrl.isNotEmpty) + ? savedUrl + : ApiConfig.baseUrl; + + if (mounted) { setState(() { - _urlController.text = savedUrl; + _urlController.text = urlToShow; }); } } @@ -53,30 +57,37 @@ class _BackendConfigScreenState extends State { try { // Normalize base URL by removing trailing slashes - final normalizedUrl = _urlController.text.trim().replaceAll(RegExp(r'/+$'), ''); + final normalizedUrl = _urlController.text.trim().replaceAll( + RegExp(r'/+$'), + '', + ); // Check /sessions/new page to verify it's a Sure backend final sessionsUrl = Uri.parse('$normalizedUrl/sessions/new'); - final sessionsResponse = await http.get( - sessionsUrl, - headers: {'Accept': 'text/html'}, - ).timeout( - const Duration(seconds: 10), - onTimeout: () { - throw Exception('Connection timeout. Please check the URL and try again.'); - }, - ); + final sessionsResponse = await http + .get(sessionsUrl, headers: {'Accept': 'text/html'}) + .timeout( + const Duration(seconds: 10), + onTimeout: () { + throw Exception( + 'Connection timeout. Please check the URL and try again.', + ); + }, + ); - if (sessionsResponse.statusCode >= 200 && sessionsResponse.statusCode < 400) { + if (sessionsResponse.statusCode >= 200 && + sessionsResponse.statusCode < 400) { if (mounted) { setState(() { - _successMessage = 'Connection successful! Sure backend is reachable.'; + _successMessage = + 'Connection successful!'; }); } } else { if (mounted) { setState(() { - _errorMessage = 'Server responded with status ${sessionsResponse.statusCode}. Please check if this is a Sure backend server.'; + _errorMessage = + 'Server responded with status ${sessionsResponse.statusCode}. Please check if this is a Sure backend server.'; }); } } @@ -105,7 +116,10 @@ class _BackendConfigScreenState extends State { try { // Normalize base URL by removing trailing slashes - final normalizedUrl = _urlController.text.trim().replaceAll(RegExp(r'/+$'), ''); + final normalizedUrl = _urlController.text.trim().replaceAll( + RegExp(r'/+$'), + '', + ); // Save URL to SharedPreferences final prefs = await SharedPreferences.getInstance(); @@ -141,7 +155,8 @@ class _BackendConfigScreenState extends State { final trimmedValue = value.trim(); // Check if it starts with http:// or https:// - if (!trimmedValue.startsWith('http://') && !trimmedValue.startsWith('https://')) { + if (!trimmedValue.startsWith('http://') && + !trimmedValue.startsWith('https://')) { return 'URL must start with http:// or https://'; } @@ -180,7 +195,7 @@ class _BackendConfigScreenState extends State { ), const SizedBox(height: 16), Text( - 'Backend Configuration', + 'Configuration', style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, color: colorScheme.primary, @@ -189,7 +204,7 @@ class _BackendConfigScreenState extends State { ), const SizedBox(height: 8), Text( - 'Enter your Sure Finance backend URL', + 'Update your Sure server URL', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -209,10 +224,7 @@ class _BackendConfigScreenState extends State { children: [ Row( children: [ - Icon( - Icons.info_outline, - color: colorScheme.primary, - ), + Icon(Icons.info_outline, color: colorScheme.primary), const SizedBox(width: 12), Text( 'Example URLs', @@ -225,7 +237,7 @@ class _BackendConfigScreenState extends State { ), const SizedBox(height: 12), Text( - '• https://sure.lazyrhythm.com\n' + '• https://demo.sure.am\n' '• https://your-domain.com\n' '• http://localhost:3000', style: TextStyle( @@ -249,15 +261,14 @@ class _BackendConfigScreenState extends State { ), child: Row( children: [ - Icon( - Icons.error_outline, - color: colorScheme.error, - ), + Icon(Icons.error_outline, color: colorScheme.error), const SizedBox(width: 12), Expanded( child: Text( _errorMessage!, - style: TextStyle(color: colorScheme.onErrorContainer), + style: TextStyle( + color: colorScheme.onErrorContainer, + ), ), ), IconButton( @@ -316,9 +327,9 @@ class _BackendConfigScreenState extends State { autocorrect: false, textInputAction: TextInputAction.done, decoration: const InputDecoration( - labelText: 'Backend URL', + labelText: 'Sure server URL', prefixIcon: Icon(Icons.cloud_outlined), - hintText: 'https://sure.lazyrhythm.com', + hintText: 'https://app.sure.am', ), validator: _validateUrl, onFieldSubmitted: (_) => _saveAndContinue(), diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index f3089293a..4e2879048 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../providers/auth_provider.dart'; import '../services/api_config.dart'; @@ -18,15 +21,37 @@ class _LoginScreenState extends State { final _passwordController = TextEditingController(); final _otpController = TextEditingController(); bool _obscurePassword = true; + late final TapGestureRecognizer _signUpTapRecognizer; + + @override + void initState() { + super.initState(); + _signUpTapRecognizer = TapGestureRecognizer()..onTap = _openSignUpPage; + } @override void dispose() { + _signUpTapRecognizer.dispose(); _emailController.dispose(); _passwordController.dispose(); _otpController.dispose(); super.dispose(); } + Future _openSignUpPage() async { + final signUpUrl = Uri.parse('${ApiConfig.defaultBaseUrl}/registration/new'); + final launched = await launchUrl( + signUpUrl, + mode: LaunchMode.externalApplication, + ); + + if (!launched && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Unable to open sign up page')), + ); + } + } + void _showApiKeyDialog() { final apiKeyController = TextEditingController(); final outerContext = context; @@ -44,9 +69,12 @@ class _LoginScreenState extends State { children: [ Text( 'Enter your API key to sign in.', - style: Theme.of(outerContext).textTheme.bodyMedium?.copyWith( - color: Theme.of(outerContext).colorScheme.onSurfaceVariant, - ), + style: + Theme.of(outerContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(outerContext) + .colorScheme + .onSurfaceVariant, + ), ), const SizedBox(height: 16), TextField( @@ -128,7 +156,8 @@ class _LoginScreenState extends State { if (!_formKey.currentState!.validate()) return; final authProvider = Provider.of(context, listen: false); - final hadOtpCode = authProvider.showMfaInput && _otpController.text.isNotEmpty; + final hadOtpCode = + authProvider.showMfaInput && _otpController.text.isNotEmpty; final success = await authProvider.login( email: _emailController.text.trim(), @@ -151,303 +180,321 @@ class _LoginScreenState extends State { final colorScheme = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar( - title: const Text(''), - actions: [ - IconButton( - icon: const Icon(Icons.settings_outlined), - tooltip: 'Backend Settings', - onPressed: widget.onGoToSettings, - ), - ], - ), body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 48), - // Logo/Title - Icon( - Icons.account_balance_wallet, - size: 80, - color: colorScheme.primary, - ), - const SizedBox(height: 16), - Text( - 'Sure Finance', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'Sign in to manage your finances', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 48), - - // Error Message - Consumer( - builder: (context, authProvider, _) { - if (authProvider.errorMessage != null) { - return Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: colorScheme.error, + child: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + // Logo/Title + SvgPicture.asset( + 'assets/images/logomark.svg', + width: 80, + height: 80, + ), + const SizedBox(height: 8), + Text.rich( + TextSpan( + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, ), - const SizedBox(width: 12), - Expanded( - child: Text( - authProvider.errorMessage!, - style: TextStyle(color: colorScheme.onErrorContainer), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => authProvider.clearError(), - iconSize: 20, - ), - ], - ), - ); - } - return const SizedBox.shrink(); - }, - ), - - // Email Field - TextFormField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - autocorrect: false, - textInputAction: TextInputAction.next, - decoration: const InputDecoration( - labelText: 'Email', - prefixIcon: Icon(Icons.email_outlined), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your email'; - } - if (!value.contains('@')) { - return 'Please enter a valid email'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Password and OTP Fields with Consumer - Consumer( - builder: (context, authProvider, _) { - final showOtp = authProvider.showMfaInput; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Password Field - TextFormField( - controller: _passwordController, - obscureText: _obscurePassword, - textInputAction: showOtp - ? TextInputAction.next - : TextInputAction.done, - decoration: InputDecoration( - labelText: 'Password', - prefixIcon: const Icon(Icons.lock_outlined), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, + children: [ + const TextSpan(text: 'Please '), + TextSpan( + text: 'Sign Up', + style: TextStyle( + color: colorScheme.primary, + decoration: TextDecoration.underline, + fontWeight: FontWeight.w600, ), + recognizer: _signUpTapRecognizer, ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your password'; - } - return null; - }, - onFieldSubmitted: showOtp ? null : (_) => _handleLogin(), - ), + const TextSpan(text: ' first!'), + ], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), - // OTP Field (shown when MFA is required) - if (showOtp) ...[ - const SizedBox(height: 16), - Container( + // Error Message + Consumer( + builder: (context, authProvider, _) { + if (authProvider.errorMessage != null) { + return Container( padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.3), + color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon( - Icons.security, - color: colorScheme.primary, + Icons.error_outline, + color: colorScheme.error, ), const SizedBox(width: 12), Expanded( child: Text( - 'Two-factor authentication is enabled. Enter your code.', - style: TextStyle(color: colorScheme.onSurface), + authProvider.errorMessage!, + style: TextStyle( + color: colorScheme.onErrorContainer), ), ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => authProvider.clearError(), + iconSize: 20, + ), ], ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _otpController, - keyboardType: TextInputType.number, - textInputAction: TextInputAction.done, - decoration: const InputDecoration( - labelText: 'Authentication Code', - prefixIcon: Icon(Icons.pin_outlined), + ); + } + return const SizedBox.shrink(); + }, + ), + + // Email Field + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Password and OTP Fields with Consumer + Consumer( + builder: (context, authProvider, _) { + final showOtp = authProvider.showMfaInput; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Password Field + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: showOtp + ? TextInputAction.next + : TextInputAction.done, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outlined), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + return null; + }, + onFieldSubmitted: + showOtp ? null : (_) => _handleLogin(), ), - validator: (value) { - if (showOtp && (value == null || value.isEmpty)) { - return 'Please enter your authentication code'; - } - return null; - }, - onFieldSubmitted: (_) => _handleLogin(), + + // OTP Field (shown when MFA is required) + if (showOtp) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.security, + color: colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Two-factor authentication is enabled. Enter your code.', + style: TextStyle( + color: colorScheme.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _otpController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Authentication Code', + prefixIcon: Icon(Icons.pin_outlined), + ), + validator: (value) { + if (showOtp && + (value == null || value.isEmpty)) { + return 'Please enter your authentication code'; + } + return null; + }, + onFieldSubmitted: (_) => _handleLogin(), + ), + ], + ], + ); + }, + ), + + const SizedBox(height: 24), + + // Login Button + Consumer( + builder: (context, authProvider, _) { + return ElevatedButton( + onPressed: + authProvider.isLoading ? null : _handleLogin, + child: authProvider.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign In'), + ); + }, + ), + + const SizedBox(height: 16), + + // Divider with "or" + Row( + children: [ + Expanded( + child: Divider(color: colorScheme.outlineVariant)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'or', + style: + TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + Expanded( + child: Divider(color: colorScheme.outlineVariant)), + ], + ), + + const SizedBox(height: 16), + + // Google Sign-In button + Consumer( + builder: (context, authProvider, _) { + return OutlinedButton.icon( + onPressed: authProvider.isLoading + ? null + : () => + authProvider.startSsoLogin('google_oauth2'), + icon: SvgPicture.asset( + 'assets/images/google_g_logo.svg', + width: 18, + height: 18, + ), + label: const Text('Sign in with Google'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + }, + ), + + const SizedBox(height: 24), + + // Backend URL info + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Text( + 'Sure server URL:', + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + ApiConfig.baseUrl, + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, ), ], - ], - ); - }, - ), - - const SizedBox(height: 24), - - // Login Button - Consumer( - builder: (context, authProvider, _) { - return ElevatedButton( - onPressed: authProvider.isLoading ? null : _handleLogin, - child: authProvider.isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Sign In'), - ); - }, - ), - - const SizedBox(height: 12), - - // API Key Login Button - TextButton.icon( - onPressed: _showApiKeyDialog, - icon: const Icon(Icons.vpn_key_outlined, size: 18), - label: const Text('Via API Key Login'), - ), - - const SizedBox(height: 16), - - // Divider with "or" - Row( - children: [ - Expanded(child: Divider(color: colorScheme.outlineVariant)), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'or', - style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), - Expanded(child: Divider(color: colorScheme.outlineVariant)), + + const SizedBox(height: 12), + + // API Key Login Button + TextButton.icon( + onPressed: _showApiKeyDialog, + icon: const Icon(Icons.vpn_key_outlined, size: 18), + label: const Text('API-Key Login'), + ), ], ), - - const SizedBox(height: 16), - - // Google Sign-In button - Consumer( - builder: (context, authProvider, _) { - return OutlinedButton.icon( - onPressed: authProvider.isLoading - ? null - : () => authProvider.startSsoLogin('google_oauth2'), - icon: const Icon(Icons.g_mobiledata, size: 24), - label: const Text('Sign in with Google'), - style: OutlinedButton.styleFrom( - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ); - }, - ), - - const SizedBox(height: 24), - - // Backend URL info - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - children: [ - Text( - 'Backend URL:', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - ApiConfig.baseUrl, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - fontFamily: 'monospace', - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - const SizedBox(height: 8), - Text( - 'Connect to your Sure Finance server to manage your accounts.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], + ), ), - ), + Positioned( + right: 8, + top: 8, + child: IconButton( + icon: const Icon(Icons.settings_outlined), + tooltip: 'Backend Settings', + onPressed: widget.onGoToSettings, + ), + ), + ], ), ), ); diff --git a/mobile/lib/services/api_config.dart b/mobile/lib/services/api_config.dart index bb5f353b0..441618a7c 100644 --- a/mobile/lib/services/api_config.dart +++ b/mobile/lib/services/api_config.dart @@ -5,9 +5,12 @@ class ApiConfig { // For local development, use: http://10.0.2.2:3000 (Android emulator) // For iOS simulator, use: http://localhost:3000 // For production, use your actual server URL - static String _baseUrl = 'https://app.sure.am'; + static const String _defaultBaseUrl = 'https://demo.sure.am'; + static const String _backendUrlKey = 'backend_url'; + static String _baseUrl = _defaultBaseUrl; static String get baseUrl => _baseUrl; + static String get defaultBaseUrl => _defaultBaseUrl; static void setBaseUrl(String url) { _baseUrl = url; @@ -34,32 +37,32 @@ class ApiConfig { /// In token mode, uses Authorization: Bearer header. static Map getAuthHeaders(String token) { if (_isApiKeyAuth && _apiKeyValue != null) { - return { - 'X-Api-Key': _apiKeyValue!, - 'Accept': 'application/json', - }; + return {'X-Api-Key': _apiKeyValue!, 'Accept': 'application/json'}; } - return { - 'Authorization': 'Bearer $token', - 'Accept': 'application/json', - }; + return {'Authorization': 'Bearer $token', 'Accept': 'application/json'}; } /// Initialize the API configuration by loading the backend URL from storage - /// Returns true if a saved URL was loaded, false otherwise + /// Returns true when a backend URL is configured (stored or default) static Future initialize() async { try { final prefs = await SharedPreferences.getInstance(); - final savedUrl = prefs.getString('backend_url'); + final savedUrl = prefs.getString(_backendUrlKey); if (savedUrl != null && savedUrl.isNotEmpty) { _baseUrl = savedUrl; return true; } - return false; + + // Seed first launch with the active development backend so the app can + // go straight to login while still letting users override it later. + _baseUrl = _defaultBaseUrl; + await prefs.setString(_backendUrlKey, _defaultBaseUrl); + return true; } catch (e) { // If initialization fails, keep the default URL - return false; + _baseUrl = _defaultBaseUrl; + return true; } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f1820a9de..fd516dab1 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1,6 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -214,6 +246,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -224,6 +264,14 @@ packages: description: flutter source: sdk version: "0.0.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" http: dependency: "direct main" description: @@ -344,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -597,6 +653,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: b1aca26728b7cc7a3af971bb6f601554a8ae9df2e0a006de8450ba06a17ad36a + url: "https://pub.dev" + source: hosted + version: "6.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: @@ -605,6 +725,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" + url: "https://pub.dev" + source: hosted + version: "1.1.20" vector_math: dependency: transitive description: @@ -662,5 +806,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1720033ce..24c23e10e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: uuid: ^4.5.2 app_links: ^6.4.0 url_launcher: ^6.2.5 + flutter_svg: ^2.2.0 dev_dependencies: flutter_test: @@ -33,6 +34,7 @@ flutter: uses-material-design: true assets: - assets/icon/ + - assets/images/ flutter_launcher_icons: android: true