From 119ed7224394774facfed3e19790473af71a35f8 Mon Sep 17 00:00:00 2001 From: matthaeusheer Date: Sun, 22 Dec 2024 01:40:38 +0100 Subject: [PATCH 1/2] added email verification and improved auth process and navigation --- lib/constants.dart | 7 +- lib/email_verification_page.dart | 192 +++++++++++----- lib/flight_service.dart | 8 +- lib/flights_page.dart | 153 +++++++------ lib/landing_page.dart | 132 ++++------- lib/login_page.dart | 363 ++++++++++++++++++------------- lib/main.dart | 63 +----- lib/profile_page.dart | 1 + lib/register_page.dart | 304 ++++++++++++++------------ lib/ui_components.dart | 212 ++++++++++++++++++ 10 files changed, 874 insertions(+), 561 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index 0f0bd45..5ae00e2 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,5 +1,5 @@ -const BASE_URL = 'https://test.floatyfly.com'; -// const BASE_URL = 'http://localhost:8080'; +// const BASE_URL = 'https://test.floatyfly.com'; +const BASE_URL = 'http://localhost:8080'; // const BASE_URL = 'http://10.0.2.2:8080'; // can be used for debugging TODO: Make configurable // For Android Emulator use 10.0.2.2 // For iOS Simulator use 127.0.0.1 @@ -11,4 +11,5 @@ const LOGIN_ROUTE = '/login'; const REGISTER_ROUTE = '/register'; const FORGOT_PASSWORD_ROUTE = '/forgot-password'; const PROFILE_ROUTE = '/profile'; -const FLIGHTS_ROUTE = '/flights'; \ No newline at end of file +const FLIGHTS_ROUTE = '/flights'; +const EMAIL_VERIFICATION_ROUTE = '/email-validation'; \ No newline at end of file diff --git a/lib/email_verification_page.dart b/lib/email_verification_page.dart index 1626f70..2632c05 100644 --- a/lib/email_verification_page.dart +++ b/lib/email_verification_page.dart @@ -1,102 +1,174 @@ -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:floaty_client/api.dart'; +import 'package:provider/provider.dart'; import 'constants.dart'; -import 'ui_components.dart'; +import 'main.dart'; class EmailVerificationPage extends StatefulWidget { - final String verificationToken; + final String username; - const EmailVerificationPage({Key? key, required this.verificationToken}) : super(key: key); + const EmailVerificationPage({Key? key, required this.username}) : super(key: key); @override _EmailVerificationPageState createState() => _EmailVerificationPageState(); } class _EmailVerificationPageState extends State { - bool _isProcessing = true; + final _formKey = GlobalKey(); + final _tokenController = TextEditingController(); + bool _isProcessing = false; String? _message; + bool _isSuccess = false; - @override - void initState() { - super.initState(); - _verifyEmail(); - } + Future _verifyEmail(String token) async { + setState(() { + _isProcessing = true; + _message = null; + _isSuccess = false; + }); - Future _verifyEmail() async { try { final apiClient = ApiClient(basePath: BASE_URL); final authApi = AuthApi(apiClient); - await authApi.authVerifyEmailEmailVerificationTokenPost(widget.verificationToken); + await authApi.authVerifyEmailEmailVerificationTokenPost(token); setState(() { _isProcessing = false; - _message = "Your email has been successfully verified!"; + _message = "Email successfully verified. You can now continue to login."; + _isSuccess = true; }); } catch (e) { setState(() { _isProcessing = false; _message = "Verification failed. The token might be invalid or expired."; + _isSuccess = false; }); } } @override Widget build(BuildContext context) { - return Scaffold( - body: Stack( + // Access AppState + final appState = Provider.of(context); + final isLargeScreen = MediaQuery.of(context).size.width >= 600; + + return Container( + height: 75.0, + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Background - const FloatyBackgroundWidget(), - // Verification Status - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Column( - mainAxisSize: MainAxisSize.min, + // Logo (aligned left) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Floaty", + style: TextStyle( + color: Colors.black, // Larger and black + fontSize: 28.0, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2.0), + Text( + "Simple paragliding logbook", + style: TextStyle( + color: Colors.grey[600], + fontSize: 14.0, + ), + ), + ], + ), + + // Show navigation links or menu only when logged in + if (appState.isLoggedIn) + if (isLargeScreen) + Row( children: [ - if (_isProcessing) - CircularProgressIndicator() - else - Column( - children: [ - Icon( - _message == "Your email has been successfully verified!" - ? Icons.check_circle - : Icons.error, - color: _message == "Your email has been successfully verified!" - ? Colors.green - : Colors.red, - size: 64.0, - ), - SizedBox(height: 16.0), - Text( - _message!, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - SizedBox(height: 32.0), - ElevatedButton( - onPressed: () { - Navigator.pushNamed(context, LOGIN_ROUTE); - }, - child: Text( - 'Go to Login', - style: TextStyle(color: Colors.black), - ), - ), - ], + TextButton( + onPressed: () => Navigator.pushNamed(context, HOME_ROUTE), + child: const Text( + "Home", + style: TextStyle(color: Colors.black, fontSize: 18.0), + ), + ), + const SizedBox(width: 16.0), + TextButton( + onPressed: () => Navigator.pushNamed(context, FLIGHTS_ROUTE), + child: const Text( + "Flights", + style: TextStyle(color: Colors.black, fontSize: 18.0), ), + ), + const SizedBox(width: 16.0), + TextButton( + onPressed: () => Navigator.pushNamed(context, PROFILE_ROUTE), + child: const Text( + "Profile", + style: TextStyle(color: Colors.black, fontSize: 18.0), + ), + ), ], + ) + else + IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + _showMenuDialog(context, appState); + }, ), - ), - ), ], ), ); } + + void _showMenuDialog(BuildContext context, AppState appState) { + showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: appState.isLoggedIn + ? [ + ListTile( + title: const Text("Home"), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, HOME_ROUTE); + }, + ), + ListTile( + title: const Text("Flights"), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, FLIGHTS_ROUTE); + }, + ), + ListTile( + title: const Text("Profile"), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, PROFILE_ROUTE); + }, + ), + ] + : [ + ListTile( + title: const Text("Login"), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, LOGIN_ROUTE); + }, + ), + ], + ), + ); + }, + ); + } + } diff --git a/lib/flight_service.dart b/lib/flight_service.dart index b3f5b2d..71b6c99 100644 --- a/lib/flight_service.dart +++ b/lib/flight_service.dart @@ -12,13 +12,17 @@ Future> fetchFlights(int userId, CookieAuth cookieAuth) async final List? response = await flightsApi.getFlights(userId); if (response != null && response.isNotEmpty) { + // Map the fetched flights to your model and return return response.map((flight) => model.Flight.fromJson(flight.toJson())).toList(); } else { - throw Exception('No flights found'); + // Return an empty list when no flights are found + return []; } } catch (e) { // Handle any errors that occur during the fetch operation - throw Exception('Failed to load flights: $e'); + // Log the error and return an empty list for consistency + print('Error fetching flights: $e'); + return []; } } diff --git a/lib/flights_page.dart b/lib/flights_page.dart index 11f49ad..7cd4bfe 100644 --- a/lib/flights_page.dart +++ b/lib/flights_page.dart @@ -135,11 +135,11 @@ class FlightsPageState extends State { TextFormField( controller: takeoffController, decoration: InputDecoration( - hintText: "Takeoff Location", - hintStyle: const TextStyle( - color: Colors.grey, - ), - icon: Icon(Icons.location_pin) + hintText: "Takeoff Location", + hintStyle: const TextStyle( + color: Colors.grey, + ), + icon: Icon(Icons.location_pin) ), validator: (value) { if (value == null || value.isEmpty) { @@ -151,11 +151,11 @@ class FlightsPageState extends State { TextFormField( controller: durationController, decoration: InputDecoration( - hintText: "Flight Duration (minutes)", - hintStyle: const TextStyle( - color: Colors.grey, - ), - icon: Icon(Icons.timer) + hintText: "Flight Duration (minutes)", + hintStyle: const TextStyle( + color: Colors.grey, + ), + icon: Icon(Icons.timer) ), validator: (value) { if (value == null || @@ -169,68 +169,68 @@ class FlightsPageState extends State { TextFormField( controller: descriptionController, decoration: InputDecoration( - hintText: "Description", - hintStyle: const TextStyle( - color: Colors.grey, - ), - icon: Icon(Icons.description) + hintText: "Description", + hintStyle: const TextStyle( + color: Colors.grey, + ), + icon: Icon(Icons.description) ), ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Cancel Button - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton( - onPressed: () { - overlayEntry.remove(); // Close overlay - }, - style: TextButton.styleFrom( - foregroundColor: Colors.deepOrange, backgroundColor: Colors.white, // Button background - side: BorderSide(color: Colors.deepOrange), // Border color - textStyle: TextStyle(fontSize: 14.0), // Text size - ), - child: Text('Cancel'), - ), - ), - // Save Button (only visible if form is valid) - Visibility( - visible: isFormValid, - child: Padding( + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Cancel Button + Padding( padding: const EdgeInsets.all(8.0), - child: ElevatedButton( - onPressed: () async { - try { - await _saveNewFlight(); - setState(() { - futureFlights = _fetchFlights(); - }); - overlayEntry.remove(); // Close the overlay - } catch (e) { - print("Failed to save flight, error: $e"); - } + child: TextButton( + onPressed: () { + overlayEntry.remove(); // Close overlay }, - style: ElevatedButton.styleFrom( - foregroundColor: Colors.black, backgroundColor: Colors.deepOrange, // Text color + style: TextButton.styleFrom( + foregroundColor: Colors.deepOrange, backgroundColor: Colors.white, // Button background + side: BorderSide(color: Colors.deepOrange), // Border color textStyle: TextStyle(fontSize: 14.0), // Text size ), - child: Text('Save'), + child: Text('Cancel'), ), ), - ), - ], + // Save Button (only visible if form is valid) + Visibility( + visible: isFormValid, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ElevatedButton( + onPressed: () async { + try { + await _saveNewFlight(); + setState(() { + futureFlights = _fetchFlights(); + }); + overlayEntry.remove(); // Close the overlay + } catch (e) { + print("Failed to save flight, error: $e"); + } + }, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.black, backgroundColor: Colors.deepOrange, // Text color + textStyle: TextStyle(fontSize: 14.0), // Text size + ), + child: Text('Save'), + ), + ), + ), + ], + ), ), - ), - ], + ], + ), + ), ), ), ), ), - ), - ), ); } @@ -240,6 +240,7 @@ class FlightsPageState extends State { return Scaffold( body: Stack(children: [ const FloatyBackgroundWidget(), + Header(), Column( children: [ SizedBox( @@ -270,6 +271,36 @@ class FlightsPageState extends State { future: futureFlights, builder: (context, snapshot) { if (snapshot.hasData) { + if (snapshot.data!.isEmpty) { + // Show a custom message when no flights are logged + return Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 5, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: 10), + Text( + 'You have no flights logged yet. Go fly! 🚀', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + } + return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { @@ -378,12 +409,12 @@ class FlightsPageState extends State { alignment: Alignment.bottomRight, child: Padding( padding: - const EdgeInsets.only(right: 30.0, bottom: 30.0, top: 5), + const EdgeInsets.only(right: 30.0, bottom: 30.0, top: 5), child: SizedBox( width: 80, // provide a custom width height: 80, // provide a custom height child: FloatingActionButton( - backgroundColor: Color(0xFF8BC34A), + backgroundColor: Colors.deepOrangeAccent, onPressed: () => showOverlay(context), child: Icon(Icons.add, size: 40), ), diff --git a/lib/landing_page.dart b/lib/landing_page.dart index e64d89e..13127ea 100644 --- a/lib/landing_page.dart +++ b/lib/landing_page.dart @@ -1,111 +1,65 @@ -import 'package:floaty/ui_components.dart'; +import 'package:floaty/register_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'main.dart'; +import 'package:floaty/ui_components.dart'; // Assuming you have AuthContainer and FloatyBackgroundWidget class LandingPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Consumer( // Listen for changes in AppState + return Consumer( builder: (context, appState, child) { - return Stack( - children: [ - const FloatyBackgroundWidget(), - Positioned( - left: 50, - right: 0, - top: MediaQuery.of(context).size.height * 0.3, // Adjust this value for desired height - child: Text( - 'FLOATY', - textAlign: TextAlign.left, - style: TextStyle( - fontSize: 80.0, - color: Colors.white.withOpacity(0.7), - fontWeight: FontWeight.bold, - fontFamily: 'ModernFont', - ), - ), - ), - Positioned( - left: 50, - right: 0, - top: MediaQuery.of(context).size.height * 0.42, // Adjust this value for desired height - child: Text( - 'Simple paragliding logbook', - textAlign: TextAlign.left, - style: TextStyle( - fontSize: 25.0, - color: Colors.white.withOpacity(0.7), - fontWeight: FontWeight.bold, - fontFamily: 'ModernFont', - ), - ), - ), - // Show Login and Register buttons only if the user is NOT logged in - if (!appState.isLoggedIn) ...[ - // Login Button + return Scaffold( + body: Stack( + children: [ + const FloatyBackgroundWidget(), + + // Top White Banner Positioned( - left: 50, - right: 50, - bottom: 100, - child: FractionallySizedBox( - widthFactor: 0.7, // Limit button width to 60% of the screen width - child: ElevatedButton( - onPressed: () { - Navigator.pushNamed(context, '/login'); - }, - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(Colors.deepOrangeAccent), - ), - child: Text( - 'Login', - style: TextStyle( - color: Colors.black, - ), - ), - ), - ), + left: 0, + right: 0, + top: 0, + child: Header(), ), - // Register Button - Positioned( - left: 50, - right: 50, - bottom: 50, - child: FractionallySizedBox( - widthFactor: 0.7, // Limit button width to 60% of the screen width - child: ElevatedButton( - onPressed: () { - Navigator.pushNamed(context, '/register'); - }, - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(Colors.white), - foregroundColor: MaterialStateProperty.all(Colors.brown), - ), - child: Text('Register'), + + // Main Content wrapped in SingleChildScrollView + SingleChildScrollView( + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + children: [ + SizedBox(height: 100.0), // Spacing below the top banner + + // AuthContainer for Register Form + AuthContainer( + headerText: "Register", + child: RegisterForm( + onSubmit: (username, email, password) async { + // Handle registration logic here + }, + errorMessage: null, + isProcessing: false, + ), + ), + ], ), ), ), - ], - // Show a "Welcome Back" message and a logout button if the user is logged in - if (appState.isLoggedIn) ...[ + + // Footer (slightly thinner and cream white color) Positioned( - left: 50, + left: 0, right: 0, - bottom: 100, - child: Text( - 'Welcome back, ${appState.currentUser?.name ?? "User"}!', - style: TextStyle( - fontSize: 30.0, - color: Colors.white.withOpacity(0.7), - fontWeight: FontWeight.bold, - fontFamily: 'ModernFont', - ), - ), + bottom: 0, + child: Footer(), ), ], - ], + ), ); }, ); } } + + diff --git a/lib/login_page.dart b/lib/login_page.dart index 097ed10..c973ed5 100644 --- a/lib/login_page.dart +++ b/lib/login_page.dart @@ -6,22 +6,154 @@ import 'package:floaty_client/api.dart'; import 'constants.dart'; import 'main.dart'; import 'model.dart'; -import 'register_page.dart'; import 'validator.dart'; import 'ui_components.dart'; import 'package:cookie_jar/cookie_jar.dart'; -class LoginPage extends StatefulWidget { + +/// Login Form Widget +class LoginForm extends StatefulWidget { + final Function(String username, String password) onSubmit; + final String? errorMessage; + final bool isProcessing; + + const LoginForm({ + Key? key, + required this.onSubmit, + this.errorMessage, + this.isProcessing = false, + }) : super(key: key); + @override - _LoginPageState createState() => _LoginPageState(); + _LoginFormState createState() => _LoginFormState(); } -class _LoginPageState extends State { +class _LoginFormState extends State { final _formKey = GlobalKey(); final _userNameTextController = TextEditingController(); final _passwordTextController = TextEditingController(); final _focusUserName = FocusNode(); final _focusPassword = FocusNode(); + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Username Field + TextFormField( + controller: _userNameTextController, + focusNode: _focusUserName, + decoration: const InputDecoration( + hintText: "Username", + prefixIcon: Icon(Icons.person, color: Colors.grey), + ), + style: const TextStyle(color: Colors.black), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your username'; + } + return null; + }, + ), + const SizedBox(height: 14.0), + // Password Field + TextFormField( + controller: _passwordTextController, + focusNode: _focusPassword, + obscureText: true, + decoration: const InputDecoration( + hintText: "Password", + prefixIcon: Icon(Icons.lock, color: Colors.grey), + ), + style: const TextStyle(color: Colors.black ), + validator: (value) => Validator.validatePassword(password: value), + ), + const SizedBox(height: 16.0), + // Error Message + if (widget.errorMessage != null) + Text( + widget.errorMessage!, + style: const TextStyle(color: Colors.red, fontSize: 14), + ), + const SizedBox(height: 32.0), + // Login Button + widget.isProcessing + ? const CircularProgressIndicator() + : SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + _focusUserName.unfocus(); + _focusPassword.unfocus(); + widget.onSubmit( + _userNameTextController.text, + _passwordTextController.text, + ); + } + }, + child: const Text( + 'Login', + style: TextStyle( + color: Colors.black, + ), + ), + ), + ), + const SizedBox(height: 32.0), + // Forgot Password and Register Links + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () { + Navigator.pushNamed(context, FORGOT_PASSWORD_ROUTE); + }, + child: const Text( + "Forgot Password?", + style: TextStyle( + color: Colors.blue, + fontSize: 14, + ), + ), + ), + const Text( + " | ", + style: TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, REGISTER_ROUTE); + }, + child: const Text( + "Register", + style: TextStyle( + color: Colors.blue, + fontSize: 14, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// Login Page +class LoginPage extends StatefulWidget { + @override + _LoginPageState createState() => _LoginPageState(); +} + +class _LoginPageState extends State { bool _isProcessing = false; String? _errorMessage; @@ -34,176 +166,95 @@ class _LoginPageState extends State { children: [ // Background const FloatyBackgroundWidget(), - // Login Form - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Username Field - TextFormField( - controller: _userNameTextController, - focusNode: _focusUserName, - decoration: InputDecoration( - hintText: "Username", - prefixIcon: Icon(Icons.person, color: Colors.grey), - ), - style: TextStyle(color: Colors.black), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your username'; - } - return null; - }, - ), - SizedBox(height: 14.0), - // Password Field - TextFormField( - controller: _passwordTextController, - focusNode: _focusPassword, - obscureText: true, - decoration: InputDecoration( - hintText: "Password", - prefixIcon: Icon(Icons.lock, color: Colors.grey), - ), - style: TextStyle(color: Colors.black), - validator: (value) => - Validator.validatePassword(password: value), - ), - SizedBox(height: 16.0), - // Error Message - if (_errorMessage != null) - Text( - _errorMessage!, - style: TextStyle(color: Colors.red, fontSize: 14), - ), - SizedBox(height: 32.0), - // Login Button - Wrap it in a SizedBox to match input field width - _isProcessing - ? CircularProgressIndicator() - : SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () async { - _focusUserName.unfocus(); - _focusPassword.unfocus(); - - if (_formKey.currentState!.validate()) { - setState(() { - _isProcessing = true; - _errorMessage = null; - }); - - try { - final user = await loginAndExtractSessionCookie( - _userNameTextController.text, - _passwordTextController.text, - cookieJar, - ); - - setState(() { - _isProcessing = false; - }); - - if (user != null) { - var floatyUser = FloatyUser.fromUserDto(user); - - // Update AppState to reflect the user login - Provider.of(context, listen: false).login(floatyUser); - - Navigator.pushNamed( - context, - HOME_ROUTE - ); - } - } catch (e) { - setState(() { - _isProcessing = false; - _errorMessage = 'Login failed. Please try again.'; - }); - } - } - }, - child: Text( - 'Login', - style: TextStyle( - color: Colors.black, - ), - ), - ), - ), - SizedBox(height: 32.0), - // Forgot Password and Register Links - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: () { - Navigator.pushNamed(context, FORGOT_PASSWORD_ROUTE); - }, - child: Text( - "Forgot Password?", - style: TextStyle( - color: Colors.blue, - fontSize: 14, - ), - ), - ), - Text( - " | ", - style: TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), - GestureDetector( - onTap: () { - Navigator.pushNamed(context, REGISTER_ROUTE); - }, - child: Text( - "Register", - style: TextStyle( - color: Colors.blue, - fontSize: 14, - ), - ), - ), - ], - ), - ], - ), - ), + // AuthContainer with LoginForm + Header(), + AuthContainer( + headerText: "Login", + child: LoginForm( + isProcessing: _isProcessing, + errorMessage: _errorMessage, + onSubmit: (username, password) async { + setState(() { + _isProcessing = true; + _errorMessage = null; + }); + + try { + final user = await loginAndExtractSessionCookie( + username, + password, + cookieJar, + ); + + setState(() { + _isProcessing = false; + }); + + if (user != null) { + var floatyUser = FloatyUser.fromUserDto(user); + + Provider.of(context, listen: false).login(floatyUser); + + Navigator.pushNamed(context, HOME_ROUTE); + } + } on EmailNotVerifiedException { + setState(() { + _isProcessing = false; + }); + + Navigator.pushNamed( + context, + EMAIL_VERIFICATION_ROUTE, + arguments: username, + ); + } catch (e) { + setState(() { + _isProcessing = false; + _errorMessage = 'Login failed. Please try again.'; + }); + } + }, ), ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Footer() + ), ], ), ); } } -Future loginAndExtractSessionCookie(String username, String password, CookieJar cookieJar) async { - // Set up the needed api clients +/// Login logic helper +Future loginAndExtractSessionCookie( + String username, String password, CookieJar cookieJar) async { final apiClient = ApiClient(basePath: BASE_URL); final authApi = AuthApi(apiClient); - // Make the login call final loginRequest = LoginRequest(name: username, password: password); final response = await authApi.loginUserWithHttpInfo(loginRequest); + + if (response.statusCode == 401) { + final responseBody = await _decodeBodyBytes(response); + if (responseBody == "Email for user is not verified yet.") { + throw EmailNotVerifiedException(); + } + throw ApiException(response.statusCode, responseBody); + } + if (response.statusCode >= 400) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } - // Extract the session cookie from the `Set-Cookie` header final setCookieHeader = response.headers['set-cookie']; if (setCookieHeader != null) { final uri = Uri.parse(BASE_URL); cookieJar.saveFromResponse(uri, [Cookie.fromSetCookieValue(setCookieHeader)]); } - // Deserialize the body into a User object, just like the original method if (response.body.isNotEmpty && response.statusCode != 204) { return await apiClient.deserializeAsync( await _decodeBodyBytes(response), @@ -214,11 +265,13 @@ Future loginAndExtractSessionCookie(String username, String password, Coo return null; } -/// Returns the decoded body as UTF-8 if the given headers indicate an 'application/json' -/// content type. Otherwise, returns the decoded body as decoded by dart:http package. Future _decodeBodyBytes(Response response) async { final contentType = response.headers['content-type']; return contentType != null && contentType.toLowerCase().startsWith('application/json') - ? response.bodyBytes.isEmpty ? '' : utf8.decode(response.bodyBytes) + ? response.bodyBytes.isEmpty + ? '' + : utf8.decode(response.bodyBytes) : response.body; -} \ No newline at end of file +} + +class EmailNotVerifiedException implements Exception {} diff --git a/lib/main.dart b/lib/main.dart index 99d97e1..16b5c5d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:cookie_jar/cookie_jar.dart'; import 'package:floaty/constants.dart'; +import 'package:floaty/email_verification_page.dart'; import 'package:floaty/model.dart'; import 'package:floaty/profile_page.dart'; import 'package:floaty/register_page.dart'; @@ -41,6 +42,7 @@ class FloatyApp extends StatelessWidget { REGISTER_ROUTE: (context) => RegisterPage(), FORGOT_PASSWORD_ROUTE: (context) => ForgotPasswordPage(), FLIGHTS_ROUTE: (context) => FlightsPage(user: Provider.of(context).currentUser), + EMAIL_VERIFICATION_ROUTE: (context) => EmailVerificationPage(username: ''), }, ), ); @@ -94,9 +96,12 @@ class _HomePageState extends State { return Consumer( builder: (context, appState, child) { Widget page; + if (!appState.isLoggedIn) { + // Redirect to LandingPage if not logged in page = LandingPage(); } else { + // Determine which page to display based on selected index switch (appState.selectedIndex) { case 0: page = LandingPage(); @@ -108,67 +113,13 @@ class _HomePageState extends State { page = ProfilePage(user: appState.currentUser); break; default: - page = LandingPage(); // Default to LandingPage if unsure + page = LandingPage(); // Fallback to LandingPage break; } } - bool showNavBar = appState.isLoggedIn; - return Scaffold( - body: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - if (constraints.maxWidth < 600) { - return Column( - children: [ - Expanded(child: page), - if (showNavBar) // Conditionally render the BottomNavigationBar - BottomNavigationBar( - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home), label: 'Home'), - BottomNavigationBarItem( - icon: Icon(Icons.paragliding_sharp), - label: 'Flights'), - BottomNavigationBarItem( - icon: Icon(Icons.person_sharp), label: 'Profile'), - ], - currentIndex: appState.selectedIndex, - onTap: (index) { - if (appState.isLoggedIn) { - appState.setSelectedIndex(index); - } - }, - ), - ], - ); - } else { - return Row( - children: [ - if (showNavBar) // Conditionally render the NavigationRail - NavigationRail( - selectedIndex: appState.selectedIndex, - onDestinationSelected: (index) { - if (appState.isLoggedIn) { - appState.setSelectedIndex(index); - } - }, - destinations: [ - NavigationRailDestination( - icon: Icon(Icons.home), label: Text('Home')), - NavigationRailDestination( - icon: Icon(Icons.paragliding_sharp), - label: Text('Flights')), - NavigationRailDestination( - icon: Icon(Icons.person), label: Text('Profile')), - ], - ), - Expanded(child: page), - ], - ); - } - }, - ), + body: page, // Directly display the selected page ); }, ); diff --git a/lib/profile_page.dart b/lib/profile_page.dart index 962e6a3..616b510 100644 --- a/lib/profile_page.dart +++ b/lib/profile_page.dart @@ -48,6 +48,7 @@ class ProfilePageState extends State { children: [ // Background const FloatyBackgroundWidget(), + Header(), Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), diff --git a/lib/register_page.dart b/lib/register_page.dart index b6e50a6..a008b41 100644 --- a/lib/register_page.dart +++ b/lib/register_page.dart @@ -4,12 +4,24 @@ import 'constants.dart'; import 'validator.dart'; import 'ui_components.dart'; -class RegisterPage extends StatefulWidget { +/// Register Form Widget +class RegisterForm extends StatefulWidget { + final Function(String username, String email, String password) onSubmit; + final String? errorMessage; + final bool isProcessing; + + const RegisterForm({ + Key? key, + required this.onSubmit, + this.errorMessage, + this.isProcessing = false, + }) : super(key: key); + @override - _RegisterPageState createState() => _RegisterPageState(); + _RegisterFormState createState() => _RegisterFormState(); } -class _RegisterPageState extends State { +class _RegisterFormState extends State { final _formKey = GlobalKey(); final _userNameTextController = TextEditingController(); final _emailTextController = TextEditingController(); @@ -17,6 +29,128 @@ class _RegisterPageState extends State { final _focusUserName = FocusNode(); final _focusEmail = FocusNode(); final _focusPassword = FocusNode(); + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Username Field + TextFormField( + controller: _userNameTextController, + focusNode: _focusUserName, + decoration: const InputDecoration( + hintText: "Username", + prefixIcon: Icon(Icons.person, color: Colors.grey), + ), + style: const TextStyle(color: Colors.black), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your username'; + } + return null; + }, + ), + const SizedBox(height: 14.0), + // Email Field + TextFormField( + controller: _emailTextController, + focusNode: _focusEmail, + decoration: const InputDecoration( + hintText: "Email", + prefixIcon: Icon(Icons.email, color: Colors.grey), + ), + style: const TextStyle(color: Colors.black), + validator: (value) => Validator.validateEmail(email: value), + ), + const SizedBox(height: 14.0), + // Password Field + TextFormField( + controller: _passwordTextController, + focusNode: _focusPassword, + obscureText: true, + decoration: const InputDecoration( + hintText: "Password", + prefixIcon: Icon(Icons.lock, color: Colors.grey), + ), + style: const TextStyle(color: Colors.black), + validator: (value) => Validator.validatePassword(password: value), + ), + const SizedBox(height: 16.0), + // Error Message + if (widget.errorMessage != null) + Text( + widget.errorMessage!, + style: const TextStyle(color: Colors.red, fontSize: 14), + ), + const SizedBox(height: 32.0), + // Register Button + widget.isProcessing + ? const CircularProgressIndicator() + : SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + _focusUserName.unfocus(); + _focusEmail.unfocus(); + _focusPassword.unfocus(); + widget.onSubmit( + _userNameTextController.text, + _emailTextController.text, + _passwordTextController.text, + ); + } + }, + child: const Text( + 'Register', + style: TextStyle( + color: Colors.black, + ), + ), + ), + ), + const SizedBox(height: 32.0), + // Login Link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Already have an account? ", + style: TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, LOGIN_ROUTE); + }, + child: const Text( + "Login", + style: TextStyle( + color: Colors.blue, + fontSize: 14, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// Register Page +class RegisterPage extends StatefulWidget { + @override + _RegisterPageState createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State { bool _isProcessing = false; String? _errorMessage; @@ -27,152 +161,53 @@ class _RegisterPageState extends State { children: [ // Background const FloatyBackgroundWidget(), - // Register Form - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Username Field - TextFormField( - controller: _userNameTextController, - focusNode: _focusUserName, - decoration: InputDecoration( - hintText: "Username", - prefixIcon: Icon(Icons.person, color: Colors.grey), - ), - style: TextStyle(color: Colors.black), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your username'; - } - return null; - }, - ), - SizedBox(height: 14.0), - // Email Field - TextFormField( - controller: _emailTextController, - focusNode: _focusEmail, - decoration: InputDecoration( - hintText: "Email", - prefixIcon: Icon(Icons.email, color: Colors.grey), - ), - style: TextStyle(color: Colors.black), - validator: (value) => Validator.validateEmail(email: value), - ), - SizedBox(height: 14.0), - // Password Field - TextFormField( - controller: _passwordTextController, - focusNode: _focusPassword, - obscureText: true, - decoration: InputDecoration( - hintText: "Password", - prefixIcon: Icon(Icons.lock, color: Colors.grey), - ), - style: TextStyle(color: Colors.black), - validator: (value) => Validator.validatePassword(password: value), - ), - SizedBox(height: 16.0), - // Error Message - if (_errorMessage != null) - Text( - _errorMessage!, - style: TextStyle(color: Colors.red, fontSize: 14), - ), - SizedBox(height: 32.0), - // Register Button - Wrap it in a SizedBox to match input field width - _isProcessing - ? CircularProgressIndicator() - : SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () async { - _focusUserName.unfocus(); - _focusEmail.unfocus(); - _focusPassword.unfocus(); + Header(), + // AuthContainer with RegisterForm + AuthContainer( + headerText: "Register", + child: RegisterForm( + isProcessing: _isProcessing, + errorMessage: _errorMessage, + onSubmit: (username, email, password) async { + setState(() { + _isProcessing = true; + _errorMessage = null; + }); - if (_formKey.currentState!.validate()) { - setState(() { - _isProcessing = true; - _errorMessage = null; - }); + try { + await registerUser(username, email, password); - try { - await registerUser( - _userNameTextController.text, - _emailTextController.text, - _passwordTextController.text, - ); + setState(() { + _isProcessing = false; + }); - setState(() { - _isProcessing = false; - }); - - Navigator.pushNamed(context, LOGIN_ROUTE); - } catch (e) { - setState(() { - _isProcessing = false; - _errorMessage = - 'Registration failed. Please try again.'; - }); - } - } - }, - child: Text( - 'Register', - style: TextStyle( - color: Colors.black, - ), - ), - ), - ), - SizedBox(height: 32.0), - // Login Link - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Already have an account? ", - style: TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), - GestureDetector( - onTap: () { - Navigator.pushNamed(context, LOGIN_ROUTE); - }, - child: Text( - "Login", - style: TextStyle( - color: Colors.blue, - fontSize: 14, - ), - ), - ), - ], - ), - ], - ), - ), + Navigator.pushNamed(context, EMAIL_VERIFICATION_ROUTE); + } catch (e) { + setState(() { + _isProcessing = false; + _errorMessage = 'Registration failed. Please try again.'; + }); + } + }, ), ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Footer() + ), ], ), ); } } +/// Register logic helper Future registerUser(String username, String email, String password) async { final apiClient = ApiClient(basePath: BASE_URL); final authApi = AuthApi(apiClient); - // Create a registration request final registerRequest = RegisterRequest( username: username, email: email, @@ -181,4 +216,3 @@ Future registerUser(String username, String email, String password) async return await authApi.registerUser(registerRequest); } - diff --git a/lib/ui_components.dart b/lib/ui_components.dart index eff73ce..3e86623 100644 --- a/lib/ui_components.dart +++ b/lib/ui_components.dart @@ -1,4 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'constants.dart'; +import 'main.dart'; class FloatyBackgroundWidget extends StatelessWidget { const FloatyBackgroundWidget({Key? key}) : super(key: key); @@ -54,3 +58,211 @@ class DotGridPainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } + +/// A reusable container for authentication pages (e.g., Login, Register) +class AuthContainer extends StatelessWidget { + final String headerText; + final Widget child; + + const AuthContainer({ + Key? key, + required this.headerText, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 400, + height: 550, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(6.0), + ), + child: Column( + children: [ + // Header Box + Container( + width: double.infinity, + height: 100, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.8), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12.0), + ), + ), + alignment: Alignment.center, + child: Text( + headerText, + style: const TextStyle( + color: Colors.white, + fontSize: 24.0, + fontWeight: FontWeight.bold, + ), + ), + ), + // Child widget (e.g., LoginForm) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 16.0), + child: child, + ), + ), + ], + ), + ), + ); + } +} + + +class Header extends StatelessWidget { + const Header({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + // Access AppState + final appState = Provider.of(context); + final isLargeScreen = MediaQuery.of(context).size.width >= 600; + + return Container( + height: 75.0, + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Floaty Logo + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Floaty", + style: TextStyle( + color: Colors.black, // Changed to black + fontSize: 28.0, // Larger size + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2.0), + Text( + "Simple paragliding logbook", + style: TextStyle( + color: Colors.grey[600], + fontSize: 14.0, + ), + ), + ], + ), + + // Show navigation links or menu only when logged in + if (appState.isLoggedIn) + if (isLargeScreen) + Row( + children: [ + TextButton( + onPressed: () => Navigator.pushNamed(context, HOME_ROUTE), + child: const Text( + "Home", + style: TextStyle(color: Colors.black, fontSize: 18.0), + ), + ), + const SizedBox(width: 16.0), + TextButton( + onPressed: () => Navigator.pushNamed(context, FLIGHTS_ROUTE), + child: const Text( + "Flights", + style: TextStyle(color: Colors.black, fontSize: 18.0), + ), + ), + const SizedBox(width: 16.0), + TextButton( + onPressed: () => Navigator.pushNamed(context, PROFILE_ROUTE), + child: const Text( + "Profile", + style: TextStyle(color: Colors.black, fontSize: 18.0), + ), + ), + ], + ) + else + IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + _showMenuDialog(context, appState); + }, + ), + ], + ), + ); + } + + void _showMenuDialog(BuildContext context, AppState appState) { + if (!appState.isLoggedIn) return; // If the user is not logged in, do nothing + + showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: const Text("Home"), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, HOME_ROUTE); + }, + ), + ListTile( + title: const Text("Flights"), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, FLIGHTS_ROUTE); + }, + ), + ListTile( + title: const Text("Profile"), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, PROFILE_ROUTE); + }, + ), + ], + ), + ); + }, + ); + } +} + +class Footer extends StatelessWidget { + const Footer({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: Duration(milliseconds: 300), + height: MediaQuery.of(context).size.height * 0.10, // 10% height + color: const Color(0xFFF8F4E3), // Cream white color + child: Center( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + '© 2024 Floaty. All rights reserved.', + style: TextStyle( + fontSize: 14.0, + color: Colors.black, + ), + ), + ), + ), + ); + } +} + + From 8924eb314a134501103f1bd1e35b00af03e3e00f Mon Sep 17 00:00:00 2001 From: matthaeusheer Date: Sun, 22 Dec 2024 17:01:50 +0100 Subject: [PATCH 2/2] env awareness, navigation, auth flows flights wip --- .github/workflows/firebase-hosting-merge.yml | 2 +- .../firebase-hosting-pull-request.yml | 2 +- assets/logo.png | Bin 0 -> 22667 bytes lib/CookieAuth.dart | 2 +- lib/add_flight_page.dart | 204 ++++++++ lib/auth_service.dart | 2 +- lib/config.dart | 15 + lib/constants.dart | 13 +- lib/email_verification_page.dart | 195 +++----- lib/flight_service.dart | 6 +- lib/flights_page.dart | 443 +++--------------- lib/landing_page.dart | 2 +- lib/login_page.dart | 6 +- lib/main.dart | 8 +- lib/profile_page.dart | 2 +- lib/register_page.dart | 2 +- lib/ui_components.dart | 102 ++-- lib/user_service.dart | 2 +- pubspec.yaml | 1 + 19 files changed, 441 insertions(+), 568 deletions(-) create mode 100644 assets/logo.png create mode 100644 lib/add_flight_page.dart create mode 100644 lib/config.dart diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index 9f54ccb..f4a9cb5 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -20,7 +20,7 @@ jobs: flutter create --project-name floaty . flutter config --enable-web flutter pub get - flutter build web --no-tree-shake-icons + flutter build web --no-tree-shake-icons --dart-define=ENV=prod - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index b7e8f73..912f632 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -18,7 +18,7 @@ jobs: flutter create --project-name floaty . flutter config --enable-web flutter pub get - flutter build web --no-tree-shake-icons + flutter build web --no-tree-shake-icons --dart-define=ENV=prod - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: '${{ secrets.GITHUB_TOKEN }}' diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..227a513b6d636aad054c831c32bc81be3a5b7d34 GIT binary patch literal 22667 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 applyToParams(List queryParams, Map headerParams) async { - final uri = Uri.parse(BASE_URL); // The URL your API is hosted at + final uri = Uri.parse(backendUrl); // The URL your API is hosted at final cookies = await cookieJar.loadForRequest(uri); // Find the session token diff --git a/lib/add_flight_page.dart b/lib/add_flight_page.dart new file mode 100644 index 0000000..6e3d433 --- /dev/null +++ b/lib/add_flight_page.dart @@ -0,0 +1,204 @@ +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:floaty/flight_service.dart'; +import 'package:floaty/ui_components.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'CookieAuth.dart'; +import 'model.dart'; + +class AddFlightPage extends StatefulWidget { + const AddFlightPage(); + + @override + _AddFlightPageState createState() => _AddFlightPageState(); +} + +class _AddFlightPageState extends State { + final _formKey = GlobalKey(); + bool isFormValid = false; + + final TextEditingController dateController = TextEditingController(); + final TextEditingController takeoffController = TextEditingController(); + final TextEditingController durationController = TextEditingController(); + final TextEditingController descriptionController = TextEditingController(); + + final DateFormat formatter = DateFormat("yyyy-MM-dd'T'HH:mm:ss"); + + CookieAuth _getCookieAuth() { + CookieJar cookieJar = Provider.of(context, listen: false); + return CookieAuth(cookieJar); + } + + Future _saveNewFlight() async { + try { + final formattedDate = formatter.format(DateTime.parse(dateController.text)); + + Flight flight = Flight( + flightId: "", + dateTime: formattedDate, + takeOff: takeoffController.text, + duration: int.parse(durationController.text), + description: descriptionController.text, + ); + + await addFlight(flight, _getCookieAuth()); + Navigator.pop(context); // Return to FlightsPage after saving the flight + } catch (e) { + print("Failed to save flight, error: $e"); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + const FloatyBackgroundWidget(), // The background remains in the stack + Positioned( + left: 0, + right: 0, + top: 0, + child: Header(), // Header is at the top + ), + Positioned( + top: 120.0, // Adjust for header space + left: 0, + right: 0, + bottom: 0, // Ensure the form takes up the remaining space + child: AddFlightContainer( + headerText: "Add New Flight", + child: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: () { + setState(() { + isFormValid = _formKey.currentState?.validate() ?? false; + }); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: dateController, + decoration: InputDecoration(labelText: "Date (YYYY-MM-DD)"), + validator: (value) { + if (value == null || value.isEmpty || DateTime.tryParse(value) == null || DateTime.parse(value).isAfter(DateTime.now())) { + return "Please enter a valid date."; + } + return null; + }, + ), + TextFormField( + controller: takeoffController, + decoration: InputDecoration(labelText: "Takeoff Location"), + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter a takeoff location."; + } + return null; + }, + ), + TextFormField( + controller: durationController, + decoration: InputDecoration(labelText: "Flight Duration (minutes)"), + validator: (value) { + if (value == null || value.isEmpty || int.tryParse(value) == null) { + return "Please enter a valid duration in minutes."; + } + return null; + }, + ), + TextFormField( + controller: descriptionController, + decoration: InputDecoration(labelText: "Description"), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Save Button + ElevatedButton( + onPressed: isFormValid ? _saveNewFlight : null, + child: Text("Save Flight"), + ), + SizedBox(width: 16), + // Cancel Button + ElevatedButton( + onPressed: () { + Navigator.pop(context); // Navigate back to the Flights page + }, + child: Text("Cancel"), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + + +class AddFlightContainer extends StatelessWidget { + final String headerText; + final Widget child; + + const AddFlightContainer({ + Key? key, + required this.headerText, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 400, + height: 550, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(6.0), + ), + child: Column( + children: [ + // Header Box + Container( + width: double.infinity, + height: 100, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.8), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12.0), + ), + ), + alignment: Alignment.center, + child: Text( + headerText, + style: const TextStyle( + color: Colors.white, + fontSize: 24.0, + fontWeight: FontWeight.bold, + ), + ), + ), + // Child widget (the form input logic) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 16.0), + child: child, + ), + ), + ], + ), + ), + ); + } +} + diff --git a/lib/auth_service.dart b/lib/auth_service.dart index ebb3a85..629f2c3 100644 --- a/lib/auth_service.dart +++ b/lib/auth_service.dart @@ -4,7 +4,7 @@ import 'CookieAuth.dart'; import 'constants.dart'; Future logout(int userId, CookieAuth cookieAuth) async { - final apiClient = api.ApiClient(basePath: BASE_URL, authentication: cookieAuth); + final apiClient = api.ApiClient(basePath: backendUrl, authentication: cookieAuth); final usersApi = api.AuthApi(apiClient); diff --git a/lib/config.dart b/lib/config.dart new file mode 100644 index 0000000..eff1d98 --- /dev/null +++ b/lib/config.dart @@ -0,0 +1,15 @@ +class Config { + // For Android Emulator use 10.0.2.2 + // For iOS Simulator use 127.0.0.1 + // For web use actual IP on host + static String get backendUrl { + const String env = String.fromEnvironment('ENV', defaultValue: 'dev'); + switch (env) { + case 'prod': + return 'https://test.floatyfly.com'; // TODO: Switch to prod once staging is there. + case 'dev': + default: + return 'http://localhost:8080'; // Local development URL + } + } +} diff --git a/lib/constants.dart b/lib/constants.dart index 5ae00e2..39446f6 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,15 +1,12 @@ -// const BASE_URL = 'https://test.floatyfly.com'; -const BASE_URL = 'http://localhost:8080'; -// const BASE_URL = 'http://10.0.2.2:8080'; // can be used for debugging TODO: Make configurable -// For Android Emulator use 10.0.2.2 -// For iOS Simulator use 127.0.0.1 -// For web use actual IP on host -// For real device, use actual IP +import 'config.dart'; -const HOME_ROUTE = '/'; +var backendUrl = Config.backendUrl; + +const HOME_ROUTE = '/register'; const LOGIN_ROUTE = '/login'; const REGISTER_ROUTE = '/register'; const FORGOT_PASSWORD_ROUTE = '/forgot-password'; const PROFILE_ROUTE = '/profile'; const FLIGHTS_ROUTE = '/flights'; +const ADD_FLIGHT_ROUTE = '/add-flight'; const EMAIL_VERIFICATION_ROUTE = '/email-validation'; \ No newline at end of file diff --git a/lib/email_verification_page.dart b/lib/email_verification_page.dart index 2632c05..cee94f0 100644 --- a/lib/email_verification_page.dart +++ b/lib/email_verification_page.dart @@ -1,13 +1,10 @@ import 'package:flutter/material.dart'; import 'package:floaty_client/api.dart'; -import 'package:provider/provider.dart'; import 'constants.dart'; -import 'main.dart'; +import 'ui_components.dart'; class EmailVerificationPage extends StatefulWidget { - final String username; - - const EmailVerificationPage({Key? key, required this.username}) : super(key: key); + const EmailVerificationPage({Key? key}) : super(key: key); @override _EmailVerificationPageState createState() => _EmailVerificationPageState(); @@ -28,13 +25,14 @@ class _EmailVerificationPageState extends State { }); try { - final apiClient = ApiClient(basePath: BASE_URL); + final apiClient = ApiClient(basePath: backendUrl); final authApi = AuthApi(apiClient); await authApi.authVerifyEmailEmailVerificationTokenPost(token); + setState(() { _isProcessing = false; - _message = "Email successfully verified. You can now continue to login."; + _message = "Your email has been successfully verified! You may now continue to login."; _isSuccess = true; }); } catch (e) { @@ -48,127 +46,90 @@ class _EmailVerificationPageState extends State { @override Widget build(BuildContext context) { - // Access AppState - final appState = Provider.of(context); - final isLargeScreen = MediaQuery.of(context).size.width >= 600; - - return Container( - height: 75.0, - color: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return Scaffold( + body: Stack( children: [ - // Logo (aligned left) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Floaty", - style: TextStyle( - color: Colors.black, // Larger and black - fontSize: 28.0, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2.0), - Text( - "Simple paragliding logbook", - style: TextStyle( - color: Colors.grey[600], - fontSize: 14.0, - ), - ), - ], - ), - - // Show navigation links or menu only when logged in - if (appState.isLoggedIn) - if (isLargeScreen) - Row( + // Background + const FloatyBackgroundWidget(), + // AuthContainer with Email Verification Form + Header(), + AuthContainer( + headerText: "Email Verification", + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, children: [ - TextButton( - onPressed: () => Navigator.pushNamed(context, HOME_ROUTE), - child: const Text( - "Home", - style: TextStyle(color: Colors.black, fontSize: 18.0), - ), + const SizedBox(height: 40.0), + // Instruction Text + const Text( + "Only one step left to using Floaty! Please check your email for the email verification token to enter below.", + style: TextStyle(fontSize: 14.0, color: Colors.white), + textAlign: TextAlign.center, ), - const SizedBox(width: 16.0), - TextButton( - onPressed: () => Navigator.pushNamed(context, FLIGHTS_ROUTE), - child: const Text( - "Flights", - style: TextStyle(color: Colors.black, fontSize: 18.0), + const SizedBox(height: 40.0), + + // Token Input Field + TextFormField( + controller: _tokenController, + decoration: const InputDecoration( + hintText: "Enter Verification Code", + prefixIcon: Icon(Icons.code, color: Colors.grey), ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter the verification code.'; + } + return null; + }, ), - const SizedBox(width: 16.0), - TextButton( - onPressed: () => Navigator.pushNamed(context, PROFILE_ROUTE), - child: const Text( - "Profile", - style: TextStyle(color: Colors.black, fontSize: 18.0), + const SizedBox(height: 16.0), + + // Error or Success Message + if (_message != null) + Text( + _message!, + style: TextStyle( + color: _isSuccess ? Colors.green : Colors.red, + fontSize: 14.0, + ), + ), + const SizedBox(height: 32.0), + + // Verify Button + _isProcessing + ? const CircularProgressIndicator() + : SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (_isSuccess) { + Navigator.pushNamed(context, LOGIN_ROUTE); + } else if (_formKey.currentState!.validate()) { + _verifyEmail(_tokenController.text); + } + }, + child: Text( + _isSuccess ? 'Go to Login' : 'Verify Email', + style: const TextStyle( + color: Colors.black, + ), + ), ), ), ], - ) - else - IconButton( - icon: const Icon(Icons.menu), - onPressed: () { - _showMenuDialog(context, appState); - }, ), + ), + ), + // Footer + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Footer(), + ), ], ), ); } - - void _showMenuDialog(BuildContext context, AppState appState) { - showModalBottomSheet( - context: context, - builder: (context) { - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: appState.isLoggedIn - ? [ - ListTile( - title: const Text("Home"), - onTap: () { - Navigator.pop(context); - Navigator.pushNamed(context, HOME_ROUTE); - }, - ), - ListTile( - title: const Text("Flights"), - onTap: () { - Navigator.pop(context); - Navigator.pushNamed(context, FLIGHTS_ROUTE); - }, - ), - ListTile( - title: const Text("Profile"), - onTap: () { - Navigator.pop(context); - Navigator.pushNamed(context, PROFILE_ROUTE); - }, - ), - ] - : [ - ListTile( - title: const Text("Login"), - onTap: () { - Navigator.pop(context); - Navigator.pushNamed(context, LOGIN_ROUTE); - }, - ), - ], - ), - ); - }, - ); - } - } diff --git a/lib/flight_service.dart b/lib/flight_service.dart index 71b6c99..8306153 100644 --- a/lib/flight_service.dart +++ b/lib/flight_service.dart @@ -5,7 +5,7 @@ import 'constants.dart'; import 'model.dart' as model; Future> fetchFlights(int userId, CookieAuth cookieAuth) async { - final apiClient = api.ApiClient(basePath: BASE_URL, authentication: cookieAuth); + final apiClient = api.ApiClient(basePath: backendUrl, authentication: cookieAuth); final flightsApi = api.FlightsApi(apiClient); try { @@ -27,7 +27,7 @@ Future> fetchFlights(int userId, CookieAuth cookieAuth) async } Future addFlight(model.Flight flight, CookieAuth cookieAuth) async { - final apiClient = api.ApiClient(basePath: BASE_URL, authentication: cookieAuth); + final apiClient = api.ApiClient(basePath: backendUrl, authentication: cookieAuth); final flightsApi = api.FlightsApi(apiClient); api.Flight? flightDto = api.Flight.fromJson(flight.toJson()); @@ -43,7 +43,7 @@ Future addFlight(model.Flight flight, CookieAuth cookieAuth) async } Future deleteFlight(String flightId, CookieAuth cookieAuth) async { - final apiClient = api.ApiClient(basePath: BASE_URL, authentication: cookieAuth); + final apiClient = api.ApiClient(basePath: backendUrl, authentication: cookieAuth); final flightsApi = api.FlightsApi(apiClient); try { diff --git a/lib/flights_page.dart b/lib/flights_page.dart index 7cd4bfe..05014aa 100644 --- a/lib/flights_page.dart +++ b/lib/flights_page.dart @@ -1,12 +1,11 @@ import 'package:cookie_jar/cookie_jar.dart'; -import 'package:floaty/ui_components.dart'; -import 'package:intl/intl.dart'; -import 'package:flutter/material.dart'; import 'package:floaty/flight_service.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; - import 'CookieAuth.dart'; import 'model.dart'; +import 'add_flight_page.dart'; +import 'ui_components.dart'; // Import your UI components here like FloatyBackgroundWidget and Header class FlightsPage extends StatefulWidget { final FloatyUser? user; @@ -14,37 +13,13 @@ class FlightsPage extends StatefulWidget { const FlightsPage({required this.user}); @override - FlightsPageState createState() => FlightsPageState(); + _FlightsPageState createState() => _FlightsPageState(); } -class FlightsPageState extends State { +class _FlightsPageState extends State { late Future> futureFlights; late FloatyUser _currentUser; - String? date; - String? takeoff; - int? duration; - - // Overlay for new flight entry - late OverlayEntry overlayEntry; - - // Input validation utilities - final _formKey = GlobalKey(); - bool isFormValid = false; - final DateFormat formatter = DateFormat('dd.MM.yyyy'); - - TextEditingController dateController = TextEditingController(); - TextEditingController takeoffController = TextEditingController(); - TextEditingController durationController = TextEditingController(); - TextEditingController descriptionController = TextEditingController(); - - // Button style - final ButtonStyle style = ElevatedButton.styleFrom( - textStyle: const TextStyle( - fontSize: 12.0, - ), - ); - @override void initState() { super.initState(); @@ -61,369 +36,73 @@ class FlightsPageState extends State { return fetchFlights(_currentUser.id, _getCookieAuth()); } - Future _saveNewFlight() async { - final DateFormat formatter = DateFormat("yyyy-MM-dd'T'HH:mm:ss"); - final formattedDate = formatter.format(DateTime.parse(dateController.text)); - - Flight flight = Flight( - flightId: "", - dateTime: formattedDate, - takeOff: takeoffController.text, - duration: int.parse(durationController.text), - description: descriptionController.text, - ); - await addFlight(flight, _getCookieAuth()); - } - Future _deleteFlight(String flightId) async { await deleteFlight(flightId, _getCookieAuth()); } - void showOverlay(BuildContext context) { - overlayEntry = createAddFlightOverlay(context); - Overlay.of(context).insert(overlayEntry); - } - - OverlayEntry createAddFlightOverlay(BuildContext context) { - return OverlayEntry( - builder: (context) => Container( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, - color: Colors.black54, // Adds semi-transparent overlay - child: Center( - child: Material( - elevation: 10.0, - borderRadius: BorderRadius.circular(20), - child: Container( - width: MediaQuery.of(context).size.width * 0.8, - height: MediaQuery.of(context).size.height * 0.7, - padding: EdgeInsets.all(20.0), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: Form( - key: _formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - onChanged: () { - setState(() { - isFormValid = _formKey.currentState?.validate() ?? false; - }); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - TextFormField( - controller: dateController, - decoration: InputDecoration( - hintText: "Date YYYY-MM-DD", - hintStyle: const TextStyle( - color: Colors.grey, - ), - icon: Icon(Icons.date_range), - ), - validator: (value) { - if (value == null || - value.isEmpty || - DateTime.tryParse(value) == null || - DateTime.parse(value).isAfter(DateTime.now())) { - return "Please enter a valid date in the format yyyy-mm-dd"; - } - return null; - }, - ), - TextFormField( - controller: takeoffController, - decoration: InputDecoration( - hintText: "Takeoff Location", - hintStyle: const TextStyle( - color: Colors.grey, - ), - icon: Icon(Icons.location_pin) - ), - validator: (value) { - if (value == null || value.isEmpty) { - return "Please enter a takeoff location"; - } - return null; - }, - ), - TextFormField( - controller: durationController, - decoration: InputDecoration( - hintText: "Flight Duration (minutes)", - hintStyle: const TextStyle( - color: Colors.grey, - ), - icon: Icon(Icons.timer) - ), - validator: (value) { - if (value == null || - value.isEmpty || - int.tryParse(value) == null) { - return "Please enter a valid duration in minutes"; - } - return null; - }, - ), - TextFormField( - controller: descriptionController, - decoration: InputDecoration( - hintText: "Description", - hintStyle: const TextStyle( - color: Colors.grey, - ), - icon: Icon(Icons.description) - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Cancel Button - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton( - onPressed: () { - overlayEntry.remove(); // Close overlay - }, - style: TextButton.styleFrom( - foregroundColor: Colors.deepOrange, backgroundColor: Colors.white, // Button background - side: BorderSide(color: Colors.deepOrange), // Border color - textStyle: TextStyle(fontSize: 14.0), // Text size - ), - child: Text('Cancel'), - ), - ), - // Save Button (only visible if form is valid) - Visibility( - visible: isFormValid, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: ElevatedButton( - onPressed: () async { - try { - await _saveNewFlight(); - setState(() { - futureFlights = _fetchFlights(); - }); - overlayEntry.remove(); // Close the overlay - } catch (e) { - print("Failed to save flight, error: $e"); - } - }, - style: ElevatedButton.styleFrom( - foregroundColor: Colors.black, backgroundColor: Colors.deepOrange, // Text color - textStyle: TextStyle(fontSize: 14.0), // Text size - ), - child: Text('Save'), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - - } - @override Widget build(BuildContext context) { return Scaffold( - body: Stack(children: [ - const FloatyBackgroundWidget(), - Header(), - Column( - children: [ - SizedBox( - height: 10, - ), - Expanded( - child: ShaderMask( - shaderCallback: (Rect bounds) { - return LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.white, - Colors.white, - Colors.transparent - ], - stops: [ - 0.01, - 0.05, - 0.95, - 1.0 - ], // Adjust the stops to determine the fade area - ).createShader(bounds); - }, - blendMode: BlendMode.dstIn, - child: FutureBuilder>( - future: futureFlights, - builder: (context, snapshot) { - if (snapshot.hasData) { - if (snapshot.data!.isEmpty) { - // Show a custom message when no flights are logged - return Center( - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black12, - blurRadius: 5, - offset: Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: 10), - Text( - 'You have no flights logged yet. Go fly! 🚀', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), - ), - ], - ), - ), - ); - } - - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - Flight flight = snapshot.data![index]; - return Stack( - children: [ - Card( - margin: EdgeInsets.all(10.0), - child: Padding( - padding: EdgeInsets.all(15.0), - child: Row( // Main horizontal layout - crossAxisAlignment: CrossAxisAlignment.start, // Align items at the start vertically - children: [ - // Container for icons and labels to ensure they are tightly packed - Container( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.date_range, color: Colors.lightBlueAccent), - SizedBox(width: 8.0), - Text( - flight.dateTime.toString(), - style: TextStyle(fontSize: 16.0), - ), - ], - ), - SizedBox(height: 8.0), - Row( - children: [ - Icon(Icons.flight_takeoff, color: Colors.lightGreen), - SizedBox(width: 8.0), - Text( - 'Takeoff: ${flight.takeOff}', - style: TextStyle(fontSize: 16.0), - ), - ], - ), - SizedBox(height: 8.0), - Row( - children: [ - Icon(Icons.timer, color: Colors.redAccent), - SizedBox(width: 8.0), - Text( - '${flight.duration.toString()} minutes', - style: TextStyle(fontSize: 16.0), - ), - ], - ), - ], - ), - ), - // Expanded widget for the description to ensure it fills the remaining space - Expanded( - child: Container( - margin: EdgeInsets.only(top: 20.0, left: 120.0, right: 40), // Top margin to avoid overlapping with delete button - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), // Rounded corners - ), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), // Internal padding - child: Text( - flight.description != null ? flight.description : "", - style: TextStyle(fontSize: 16.0), - textAlign: TextAlign.justify, - ), - ), - ), - ), - ], - ), - ), - ), - - Positioned( - top: 10.0, - right: 10.0, - child: IconButton( - icon: Icon( - Icons.delete, - color: Colors.grey[400], // muted color - ), - onPressed: () async { - await _deleteFlight(flight.flightId); - setState(() { - futureFlights = _fetchFlights(); - }); - }, - ), - ), - ], - ); + body: Stack( + children: [ + // Background + const FloatyBackgroundWidget(), + // Header + Header(), + // Main Content + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0).copyWith(top: 120.0), + child: FutureBuilder>( + future: futureFlights, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return Center(child: Text('No flights added yet.')); + } + + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + Flight flight = snapshot.data![index]; + return ListTile( + title: Text(flight.takeOff), + subtitle: Text(flight.dateTime), + trailing: IconButton( + icon: Icon(Icons.delete), + onPressed: () async { + await _deleteFlight(flight.flightId); + setState(() { + futureFlights = _fetchFlights(); + }); }, - ); - } else if (snapshot.hasError) { - return Center(child: Text('${snapshot.error}')); - } - return Center(child: CircularProgressIndicator()); + ), + ); }, - ), - ), + ); + }, ), - Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: - const EdgeInsets.only(right: 30.0, bottom: 30.0, top: 5), - child: SizedBox( - width: 80, // provide a custom width - height: 80, // provide a custom height - child: FloatingActionButton( - backgroundColor: Colors.deepOrangeAccent, - onPressed: () => showOverlay(context), - child: Icon(Icons.add, size: 40), - ), - ), - ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + // Navigate to AddFlightPage + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AddFlightPage(), ), - ], - ), - ]), + ).then((_) { + setState(() { + futureFlights = _fetchFlights(); // Refresh the flight list after adding a new flight + }); + }); + }, + child: Icon(Icons.add), + ), ); } } diff --git a/lib/landing_page.dart b/lib/landing_page.dart index 13127ea..3a8d13c 100644 --- a/lib/landing_page.dart +++ b/lib/landing_page.dart @@ -29,7 +29,7 @@ class LandingPage extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 20.0), child: Column( children: [ - SizedBox(height: 100.0), // Spacing below the top banner + SizedBox(height: 250.0), // Spacing below the top banner // AuthContainer for Register Form AuthContainer( diff --git a/lib/login_page.dart b/lib/login_page.dart index c973ed5..75aa59d 100644 --- a/lib/login_page.dart +++ b/lib/login_page.dart @@ -195,7 +195,7 @@ class _LoginPageState extends State { Provider.of(context, listen: false).login(floatyUser); - Navigator.pushNamed(context, HOME_ROUTE); + Navigator.pushNamed(context, FLIGHTS_ROUTE); } } on EmailNotVerifiedException { setState(() { @@ -231,7 +231,7 @@ class _LoginPageState extends State { /// Login logic helper Future loginAndExtractSessionCookie( String username, String password, CookieJar cookieJar) async { - final apiClient = ApiClient(basePath: BASE_URL); + final apiClient = ApiClient(basePath: backendUrl); final authApi = AuthApi(apiClient); final loginRequest = LoginRequest(name: username, password: password); @@ -251,7 +251,7 @@ Future loginAndExtractSessionCookie( final setCookieHeader = response.headers['set-cookie']; if (setCookieHeader != null) { - final uri = Uri.parse(BASE_URL); + final uri = Uri.parse(backendUrl); cookieJar.saveFromResponse(uri, [Cookie.fromSetCookieValue(setCookieHeader)]); } diff --git a/lib/main.dart b/lib/main.dart index 16b5c5d..a431199 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:floaty/register_page.dart'; import 'package:floaty/theme.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'add_flight_page.dart'; import 'forgot_password_page.dart'; import 'flights_page.dart'; @@ -34,15 +35,16 @@ class FloatyApp extends StatelessWidget { child: MaterialApp( title: 'Floaty', theme: buildThemeData(), - initialRoute: '/', + initialRoute: REGISTER_ROUTE, routes: { - HOME_ROUTE: (context) => HomePage(), + HOME_ROUTE: (context) => RegisterPage(), LOGIN_ROUTE: (context) => LoginPage(), PROFILE_ROUTE: (context) => ProfilePage(user: Provider.of(context).currentUser), REGISTER_ROUTE: (context) => RegisterPage(), FORGOT_PASSWORD_ROUTE: (context) => ForgotPasswordPage(), FLIGHTS_ROUTE: (context) => FlightsPage(user: Provider.of(context).currentUser), - EMAIL_VERIFICATION_ROUTE: (context) => EmailVerificationPage(username: ''), + ADD_FLIGHT_ROUTE: (context) => AddFlightPage(), + EMAIL_VERIFICATION_ROUTE: (context) => EmailVerificationPage(), }, ), ); diff --git a/lib/profile_page.dart b/lib/profile_page.dart index 616b510..262d1fe 100644 --- a/lib/profile_page.dart +++ b/lib/profile_page.dart @@ -149,7 +149,7 @@ class ProfilePageState extends State { Provider.of(context, listen: false) .logout(); - Navigator.pushReplacementNamed(context, HOME_ROUTE); + Navigator.pushReplacementNamed(context, LOGIN_ROUTE); } catch (e) { // Handle error if logout fails print('Logout failed: $e'); diff --git a/lib/register_page.dart b/lib/register_page.dart index a008b41..62b1050 100644 --- a/lib/register_page.dart +++ b/lib/register_page.dart @@ -205,7 +205,7 @@ class _RegisterPageState extends State { /// Register logic helper Future registerUser(String username, String email, String password) async { - final apiClient = ApiClient(basePath: BASE_URL); + final apiClient = ApiClient(basePath: backendUrl); final authApi = AuthApi(apiClient); final registerRequest = RegisterRequest( diff --git a/lib/ui_components.dart b/lib/ui_components.dart index 3e86623..e456f2a 100644 --- a/lib/ui_components.dart +++ b/lib/ui_components.dart @@ -116,15 +116,16 @@ class AuthContainer extends StatelessWidget { } } - class Header extends StatelessWidget { const Header({Key? key}) : super(key: key); @override Widget build(BuildContext context) { - // Access AppState final appState = Provider.of(context); - final isLargeScreen = MediaQuery.of(context).size.width >= 600; + final isLargeScreen = MediaQuery + .of(context) + .size + .width >= 600; return Container( height: 75.0, @@ -133,28 +134,11 @@ class Header extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Floaty Logo - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Floaty", - style: TextStyle( - color: Colors.black, // Changed to black - fontSize: 28.0, // Larger size - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2.0), - Text( - "Simple paragliding logbook", - style: TextStyle( - color: Colors.grey[600], - fontSize: 14.0, - ), - ), - ], + // Replace text with image logo + Image.asset( + "assets/logo.png", + height: 55.0, + fit: BoxFit.contain, ), // Show navigation links or menu only when logged in @@ -162,28 +146,25 @@ class Header extends StatelessWidget { if (isLargeScreen) Row( children: [ - TextButton( - onPressed: () => Navigator.pushNamed(context, HOME_ROUTE), - child: const Text( - "Home", - style: TextStyle(color: Colors.black, fontSize: 18.0), - ), + _buildNavButton( + context, + "Home", + 0, + appState.selectedIndex, ), const SizedBox(width: 16.0), - TextButton( - onPressed: () => Navigator.pushNamed(context, FLIGHTS_ROUTE), - child: const Text( - "Flights", - style: TextStyle(color: Colors.black, fontSize: 18.0), - ), + _buildNavButton( + context, + "Flights", + 1, + appState.selectedIndex, ), const SizedBox(width: 16.0), - TextButton( - onPressed: () => Navigator.pushNamed(context, PROFILE_ROUTE), - child: const Text( - "Profile", - style: TextStyle(color: Colors.black, fontSize: 18.0), - ), + _buildNavButton( + context, + "Profile", + 2, + appState.selectedIndex, ), ], ) @@ -199,8 +180,40 @@ class Header extends StatelessWidget { ); } + // Helper function to build navigation buttons + Widget _buildNavButton(BuildContext context, String label, int index, + int selectedIndex) { + final isSelected = index == selectedIndex; + return TextButton( + onPressed: () { + // Update the selected index in AppState + Provider.of(context, listen: false).setSelectedIndex(index); + // Navigate to the corresponding page + if (index == 0) { + Navigator.pushNamed(context, HOME_ROUTE); + } else if (index == 1) { + Navigator.pushNamed(context, FLIGHTS_ROUTE); + } else if (index == 2) { + Navigator.pushNamed(context, PROFILE_ROUTE); + } + }, + child: Text( + label, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.black, + // Blue color for selected item + fontSize: 18.0, + fontWeight: isSelected ? FontWeight.bold : FontWeight + .normal, // Bold for selected item + ), + ), + ); + } + + void _showMenuDialog(BuildContext context, AppState appState) { - if (!appState.isLoggedIn) return; // If the user is not logged in, do nothing + if (!appState.isLoggedIn) + return; // If the user is not logged in, do nothing showModalBottomSheet( context: context, @@ -238,6 +251,7 @@ class Header extends StatelessWidget { } } + class Footer extends StatelessWidget { const Footer({ super.key, diff --git a/lib/user_service.dart b/lib/user_service.dart index 12682ea..4ca46ee 100644 --- a/lib/user_service.dart +++ b/lib/user_service.dart @@ -5,7 +5,7 @@ import 'constants.dart'; import 'model.dart'; Future fetchUserById(String userId) async { - final String apiUrl = '$BASE_URL/users/$userId'; + final String apiUrl = '$backendUrl/users/$userId'; final response = await http.get(Uri.parse(apiUrl)); diff --git a/pubspec.yaml b/pubspec.yaml index 42740cf..8553ad8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,4 +31,5 @@ flutter: uses-material-design: true assets: - assets/background.jpg + - assets/logo.png - staticwebapp.config.json \ No newline at end of file