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 0000000..227a513 Binary files /dev/null and b/assets/logo.png differ diff --git a/lib/CookieAuth.dart b/lib/CookieAuth.dart index 687d98a..740d566 100644 --- a/lib/CookieAuth.dart +++ b/lib/CookieAuth.dart @@ -10,7 +10,7 @@ class CookieAuth implements Authentication { @override Future 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 0f0bd45..39446f6 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,14 +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'; \ No newline at end of file +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 1626f70..cee94f0 100644 --- a/lib/email_verification_page.dart +++ b/lib/email_verification_page.dart @@ -1,42 +1,45 @@ -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:floaty_client/api.dart'; import 'constants.dart'; import 'ui_components.dart'; class EmailVerificationPage extends StatefulWidget { - final String verificationToken; - - const EmailVerificationPage({Key? key, required this.verificationToken}) : super(key: key); + const EmailVerificationPage({Key? key}) : 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 apiClient = ApiClient(basePath: backendUrl); final authApi = AuthApi(apiClient); - await authApi.authVerifyEmailEmailVerificationTokenPost(widget.verificationToken); + await authApi.authVerifyEmailEmailVerificationTokenPost(token); + setState(() { _isProcessing = false; - _message = "Your email has been successfully verified!"; + _message = "Your email has been successfully verified! You may now continue to login."; + _isSuccess = true; }); } catch (e) { setState(() { _isProcessing = false; _message = "Verification failed. The token might be invalid or expired."; + _isSuccess = false; }); } } @@ -48,53 +51,83 @@ class _EmailVerificationPageState extends State { children: [ // Background const FloatyBackgroundWidget(), - // Verification Status - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), + // AuthContainer with Email Verification Form + Header(), + AuthContainer( + headerText: "Email Verification", + child: Form( + key: _formKey, child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, 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), - ), + 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(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(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, ), - ], + ), ), + ), ], ), ), ), + // Footer + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Footer(), + ), ], ), ); diff --git a/lib/flight_service.dart b/lib/flight_service.dart index b3f5b2d..8306153 100644 --- a/lib/flight_service.dart +++ b/lib/flight_service.dart @@ -5,25 +5,29 @@ 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 { 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 []; } } 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()); @@ -39,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 11f49ad..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,338 +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(), - 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) { - 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: Color(0xFF8BC34A), - 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 e64d89e..3a8d13c 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: 250.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..75aa59d 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, FLIGHTS_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 - final apiClient = ApiClient(basePath: BASE_URL); +/// Login logic helper +Future loginAndExtractSessionCookie( + String username, String password, CookieJar cookieJar) async { + final apiClient = ApiClient(basePath: backendUrl); 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); + final uri = Uri.parse(backendUrl); 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..a431199 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,13 @@ 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'; 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'; @@ -33,14 +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), + ADD_FLIGHT_ROUTE: (context) => AddFlightPage(), + EMAIL_VERIFICATION_ROUTE: (context) => EmailVerificationPage(), }, ), ); @@ -94,9 +98,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 +115,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..262d1fe 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), @@ -148,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 b6e50a6..62b1050 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 apiClient = ApiClient(basePath: backendUrl); 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..e456f2a 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,225 @@ 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) { + 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: [ + // 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 + if (appState.isLoggedIn) + if (isLargeScreen) + Row( + children: [ + _buildNavButton( + context, + "Home", + 0, + appState.selectedIndex, + ), + const SizedBox(width: 16.0), + _buildNavButton( + context, + "Flights", + 1, + appState.selectedIndex, + ), + const SizedBox(width: 16.0), + _buildNavButton( + context, + "Profile", + 2, + appState.selectedIndex, + ), + ], + ) + else + IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + _showMenuDialog(context, appState); + }, + ), + ], + ), + ); + } + + // 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 + + 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, + ), + ), + ), + ), + ); + } +} + + 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