diff --git a/.env b/.env index 0887302..bd0a208 100644 --- a/.env +++ b/.env @@ -2,5 +2,5 @@ # - Android emulator: use http://10.0.2.2:3001 (emulator's alias for host) # - iOS simulator: use http://localhost:3001 # - Physical device: use your computer's IP, e.g. http://192.168.1.5:3001 (find IP in system settings) -#API_BASE_URL=http://localhost:3001 -API_BASE_URL=http://10.0.2.2:3001 +#API_BASE_URL=http://10.0.2.2:3001 +API_BASE_URL=https://omotomo.satoshinakamoto.win diff --git a/lib/env.dart b/lib/env.dart index 4d3bbce..3f7cf78 100644 --- a/lib/env.dart +++ b/lib/env.dart @@ -1,10 +1,48 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -/// API base URL (no trailing slash). Loaded from .env as API_BASE_URL. +const String _overrideKey = 'api_base_url_override'; + +String? _override; + +String _fromEnv() { + final raw = dotenv.env['API_BASE_URL']?.trim() ?? ''; + final url = raw.replaceAll(' ', ''); + if (url.isEmpty) return 'http://localhost:3001'; + return url.endsWith('/') ? url.substring(0, url.length - 1) : url; +} + +/// API base URL (no trailing slash). Uses in-app override if set, else .env. +/// Strips spaces so accidental spaces don't break DNS. String get apiBaseUrl { - final url = dotenv.env['API_BASE_URL']?.trim(); - if (url == null || url.isEmpty) { - return 'http://localhost:3001'; + if (_override != null && _override!.trim().isNotEmpty) { + final u = _override!.trim().replaceAll(' ', ''); + if (u.isNotEmpty) { + return u.endsWith('/') ? u.substring(0, u.length - 1) : u; + } } - return url.endsWith('/') ? url.substring(0, url.length - 1) : url; + return _fromEnv(); } + +/// Load saved override from disk. Call once at startup. +Future loadApiBaseUrlOverride() async { + final prefs = await SharedPreferences.getInstance(); + _override = prefs.getString(_overrideKey); +} + +/// Save or clear the in-app base URL override. Pass null or empty to use .env again. +Future setApiBaseUrlOverride(String? value) async { + final prefs = await SharedPreferences.getInstance(); + if (value == null || value.trim().isEmpty) { + await prefs.remove(_overrideKey); + _override = null; + return; + } + final u = value.trim().replaceAll(' ', ''); + final withoutSlash = u.endsWith('/') ? u.substring(0, u.length - 1) : u; + await prefs.setString(_overrideKey, withoutSlash); + _override = withoutSlash; +} + +/// Current default from .env (ignoring override). For display only. +String get apiBaseUrlDefault => _fromEnv(); diff --git a/lib/main.dart b/lib/main.dart index a89f0eb..47d6461 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'routes.dart'; import 'theme.dart'; +import 'env.dart'; import 'services/api_auth_service.dart'; import 'services/deck_storage.dart'; @@ -11,6 +12,7 @@ void main() async { await dotenv.load(fileName: '.env').catchError( (_) => dotenv.load(fileName: '.env.example'), ); + await loadApiBaseUrlOverride(); await DeckStorage().initialize(); await ApiAuthService.instance.init(); diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index f051454..e117c81 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../env.dart'; import '../services/api_auth_service.dart'; class LoginScreen extends StatefulWidget { @@ -13,19 +14,75 @@ class _LoginScreenState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + final _serverUrlController = TextEditingController(); bool _loading = false; String? _errorMessage; bool _isRegister = false; final _displayNameController = TextEditingController(); + @override + void initState() { + super.initState(); + _serverUrlController.text = apiBaseUrl; + } + @override void dispose() { _emailController.dispose(); _passwordController.dispose(); + _serverUrlController.dispose(); _displayNameController.dispose(); super.dispose(); } + Future _saveServerUrl() async { + final raw = _serverUrlController.text.trim().replaceAll(' ', ''); + if (raw.isEmpty) { + await setApiBaseUrlOverride(null); + if (mounted) { + setState(() { + _serverUrlController.text = apiBaseUrlDefault; + _errorMessage = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Using default server URL')), + ); + } + return; + } + final withoutSlash = raw.endsWith('/') ? raw.substring(0, raw.length - 1) : raw; + final uri = Uri.tryParse(withoutSlash); + if (uri == null || + (!uri.scheme.startsWith('http') || uri.host.isEmpty)) { + if (mounted) { + setState(() { + _errorMessage = 'Enter a valid URL (e.g. https://example.com)'; + }); + } + return; + } + await setApiBaseUrlOverride(withoutSlash); + if (mounted) { + setState(() => _errorMessage = null); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Server URL saved')), + ); + } + } + + Future _useDefaultServerUrl() async { + await setApiBaseUrlOverride(null); + if (mounted) { + setState(() { + _serverUrlController.text = apiBaseUrlDefault; + _errorMessage = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Using default server URL')), + ); + } + } + Future _submit() async { setState(() { _errorMessage = null; @@ -113,6 +170,36 @@ class _LoginScreenState extends State { return null; }, ), + const SizedBox(height: 20), + Text( + 'Server URL', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + TextFormField( + controller: _serverUrlController, + decoration: const InputDecoration( + hintText: 'Leave empty for app default', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + autocorrect: false, + textInputAction: TextInputAction.done, + ), + const SizedBox(height: 8), + Row( + children: [ + FilledButton.tonal( + onPressed: _loading ? null : _saveServerUrl, + child: const Text('Save server URL'), + ), + const SizedBox(width: 12), + TextButton( + onPressed: _loading ? null : _useDefaultServerUrl, + child: const Text('Use default'), + ), + ], + ), if (_errorMessage != null) ...[ const SizedBox(height: 16), Text( diff --git a/lib/utils/connection_error.dart b/lib/utils/connection_error.dart index cd381ed..92b0828 100644 --- a/lib/utils/connection_error.dart +++ b/lib/utils/connection_error.dart @@ -10,6 +10,11 @@ String connectionErrorMessage(Object error) { error is http.ClientException || error is HandshakeException || error is TlsException) { + final msg = error.toString().toLowerCase(); + if (msg.contains('host lookup') || + msg.contains('no address associated with hostname')) { + return "This device can't resolve the server hostname. On an emulator, try setting DNS to 8.8.8.8 or use a real device."; + } return 'Connection to server has broken. Check API URL and network.'; } return 'Network error. Please check the API URL and try again.';