diff --git a/Mobile/lib/createreview.dart b/Mobile/lib/createreview.dart new file mode 100644 index 0000000..c784de2 --- /dev/null +++ b/Mobile/lib/createreview.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:mobile/base/sidemenu.dart'; +import 'models.dart'; +import 'api.dart' as api; + +class CreateReviewPage extends StatefulWidget { + const CreateReviewPage({super.key}); + + @override + State createState() => _CreateReviewState(); + +} + +class _CreateReviewState extends State { + final titleInput = TextEditingController(); + final contentInput = TextEditingController(); + Place? place; + var rating = 0; + + @override + Widget build(BuildContext context) { + place = ModalRoute.of(context)!.settings.arguments as Place; + + return SideMenu( + selectedIndex: -1, + body: Scaffold( + backgroundColor: const Color(0xFFF9F9F9), + body: SingleChildScrollView( + child: Center( + child: Container( + width: MediaQuery.of(context).size.width, + padding: const EdgeInsets.all(40), + constraints: const BoxConstraints(maxWidth: 400), + child: Column(children: [ + Text(place!.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24)), + Text(place!.description, style: const TextStyle(color: Colors.grey)), + const SizedBox(height: 50), + TextField( + controller: titleInput, + enableSuggestions: true, + autocorrect: true, + decoration: const InputDecoration( + hintText: 'Review Title', + ), + ), + const SizedBox(height: 30), + TextField( + controller: contentInput, + minLines: 5, + maxLines: null, + decoration: const InputDecoration( + hintText: 'Write a review...', + ) + ), + const SizedBox(height: 30), + + // Review Stars + + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < rating; i++) IconButton(onPressed: () => setState(() => rating = i+1), icon: const Icon(Icons.star, color: Colors.yellow)), + for (var i = rating; i < 5; i++) IconButton(onPressed: () => setState(() => rating = i+1), icon: const Icon(Icons.star_border)), + ], + ), + + const SizedBox(height: 30), + ElevatedButton(onPressed: _submitReview, child: const Text('Submit Review')), + ]), + ) + ) + ) + ) + ); + } + + @override + void dispose() { + super.dispose(); + titleInput.dispose(); + contentInput.dispose(); + } + + Future _submitReview() async { + final response = await api.request(context, api.ApiService.app, 'POST', '/reviews', { + 'title': titleInput.text, + 'content': contentInput.text, + 'place_name': place!.name, + 'place_description': place!.description, + 'rating': rating, + 'lat': place!.point.latitude, + 'lng': place!.point.longitude, + }); + + if (response == null || !mounted) return; + + final review = Review.fromJson(jsonDecode(response)); + + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Review submitted'))); + + Navigator.pop(context, review); + } +} \ No newline at end of file diff --git a/Mobile/lib/main.dart b/Mobile/lib/main.dart index 1b0f3c3..fec07a2 100644 --- a/Mobile/lib/main.dart +++ b/Mobile/lib/main.dart @@ -4,8 +4,10 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:mobile/createreview.dart'; import 'package:mobile/favorites.dart'; import 'package:mobile/register.dart'; +import 'package:mobile/reviewlist.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'login.dart'; import 'base/sidemenu.dart'; @@ -45,6 +47,8 @@ class MyApp extends StatelessWidget { '/favorites': (context) => const FavoritesPage(), '/login': (context) => const LoginPage(), '/register': (context) => const RegisterPage(), + '/reviews': (context) => const ReviewListPage(), + '/create-review': (context) => const CreateReviewPage(), }, ); } @@ -60,6 +64,7 @@ class MyHomePage extends StatefulWidget { class _MyHomePageState extends State { final GlobalKey _scaffoldKey = GlobalKey(); List _favorites = []; + List _reviews = []; LatLng? _selectedPoint; LatLng _currentPosition = LatLng(55.656707, 10.563214); LatLng? _userPosition; @@ -81,6 +86,7 @@ class _MyHomePageState extends State { if (!isLoggedIn || !mounted) return; _fetchFavorites(); + _fetchReviews(); }); } @@ -126,6 +132,7 @@ class _MyHomePageState extends State { setState(() => _selectedPoint = null); } + // Open location bottom menu Future _showLocation(LatLng point, String name, String description) async { await showModalBottomSheet( barrierColor: Colors.black.withOpacity(0.3), @@ -138,6 +145,7 @@ class _MyHomePageState extends State { padding: const EdgeInsets.all(20), width: MediaQuery.of(context).size.width, child: Row(children: [ + // Location information Expanded(child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -146,14 +154,28 @@ class _MyHomePageState extends State { Text(description), ], )), + Column(children: [ + // Toggle favorite button IconButton( - icon: const Icon(Icons.star), - iconSize: 32, - color: _favorites.where((fav) => fav.lat == point.latitude && fav.lng == point.longitude).isEmpty ? Colors.grey : Colors.yellow, - onPressed: () => _toggleFavorite(point, name, description, setModalState, context) + icon: const Icon(Icons.star), + iconSize: 32, + color: _favorites.where((fav) => fav.lat == point.latitude && fav.lng == point.longitude).isEmpty ? Colors.grey : Colors.yellow, + onPressed: () => _toggleFavorite(point, name, description, setModalState, context) + ), + + // View reviews button + IconButton( + icon: const Icon(Icons.rate_review), + iconSize: 32, + color: Colors.grey, + onPressed: () => + Navigator.pushReplacementNamed( + context, + '/reviews', + arguments: ReviewList(_reviews.where((review) => review.lat == point.latitude && review.lng == point.longitude).toList(), Place(name, description, point)) + ), ), - const IconButton(icon: Icon(Icons.rate_review), iconSize: 32, onPressed: null), ]), ]), ), @@ -216,7 +238,16 @@ class _MyHomePageState extends State { }); } - + Future _fetchReviews() async { + final response = await api.request(context, api.ApiService.app, 'GET', '/reviews', null); + if (response == null) return; + + final List reviews = jsonDecode(response); + setState(() { + _reviews = reviews.map((review) => Review.fromJson(review)).toList(); + debugPrint(_reviews.length.toString()); + }); + } Future _getOpenStreetMapArea(LatLng fromGetLocation) async { final dynamic location; @@ -345,7 +376,31 @@ class _MyHomePageState extends State { ) ], )), - ..._favorites.map((favorite) => MarkerLayer( + ..._reviews.map((review) => MarkerLayer( + markers: [ + Marker( + point: LatLng(review.lat, review.lng), + width: 30, + height: 50, + alignment: Alignment.center, + child: Stack( + children: [ + IconButton( + padding: const EdgeInsets.only(bottom: 10), + icon: const Icon(Icons.location_pin, size: 30, color:Colors.purpleAccent), + onPressed: () => _showLocation(LatLng(review.lat, review.lng), review.place_name, review.place_description), + ), + IconButton( + padding: const EdgeInsets.only(bottom: 10), + icon: const Icon(Icons.location_on_outlined, size: 30, color: Colors.purple), + onPressed: () => _showLocation(LatLng(review.lat, review.lng), review.place_name, review.place_description), + ), + ], + ) + ) + ], + )), + ..._favorites.map((favorite) => MarkerLayer( markers: [ Marker( point: LatLng(favorite.lat, favorite.lng), diff --git a/Mobile/lib/models.dart b/Mobile/lib/models.dart index ce3c203..1cd0407 100644 --- a/Mobile/lib/models.dart +++ b/Mobile/lib/models.dart @@ -24,6 +24,49 @@ class Favorite { } } +class Review { + int id; + String userId; + double lat; + double lng; + String place_name; + String place_description; + String title; + String content; + int rating; + + Review(this.id, this.userId, this.lat, this.lng, this.place_name, this.place_description, this.title, this.content, this.rating); + + factory Review.fromJson(Map json) { + return Review( + json['id'], + json['user_id'], + json['lat'], + json['lng'], + json['place_name'], + json['place_description'], + json['title'], + json['content'], + json['rating'], + ); + } +} + +class Place { + String name; + String description; + LatLng point; + + Place(this.name, this.description, this.point); +} + +class ReviewList { + List reviews; + Place place; + + ReviewList(this.reviews, this.place); +} + class Login { String token; String id; @@ -60,10 +103,10 @@ class User { } } -class SearchResults{ +class SearchResults { LatLng location; String name; String description; -SearchResults(this.location, this.name, this.description); + SearchResults(this.location, this.name, this.description); } diff --git a/Mobile/lib/reviewlist.dart b/Mobile/lib/reviewlist.dart new file mode 100644 index 0000000..6c21f05 --- /dev/null +++ b/Mobile/lib/reviewlist.dart @@ -0,0 +1,83 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile/base/sidemenu.dart'; +import 'models.dart'; + +class ReviewListPage extends StatefulWidget { + const ReviewListPage({super.key}); + + @override + State createState() => _ReviewListState(); +} + +class _ReviewListState extends State { + @override + Widget build(BuildContext context) { + final arg = ModalRoute.of(context)!.settings.arguments as ReviewList; + final reviews = arg.reviews; + final place = arg.place; + + return SideMenu( + selectedIndex: -1, + body: Scaffold( + backgroundColor: Color(0xFFF9F9F9), + body: SingleChildScrollView(child: Container( + decoration: const BoxDecoration(color: Color(0xFFF9F9F9)), + width: MediaQuery.of(context).size.width, + padding: const EdgeInsets.all(20.0), + child: Column(children: + reviews.map((review) => Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + margin: const EdgeInsets.only(bottom: 10), + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: Color(0x20000000), + offset: Offset(0,1), + blurRadius: 4, + ), + ], + color: Colors.white, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 3), + child: Icon(Icons.radio, color: Colors.purple, size: 36), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(review.title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24)), + Text(review.content), + const SizedBox(height: 10), + Row(children: [ + for (var i = 0; i < review.rating; i++) const Icon(Icons.star, color: Colors.yellow), + for (var i = review.rating; i < 5; i++) const Icon(Icons.star_border), + ]), + ], + ), + ), + ], + ), + )).toList(), + ), + )), + floatingActionButton: FloatingActionButton( + onPressed: () async { + final review = await Navigator.pushNamed(context, '/create-review', arguments: place) as Review?; + if (review != null) reviews.add(review); + }, + backgroundColor: Colors.blue, + focusColor: Colors.blueGrey, + tooltip: "Write a Review", + child: const Icon(CupertinoIcons.plus), + ), + ), + ); + } +} \ No newline at end of file diff --git a/Mobile/pubspec.lock b/Mobile/pubspec.lock index 5ffc63c..a5843ce 100644 --- a/Mobile/pubspec.lock +++ b/Mobile/pubspec.lock @@ -388,18 +388,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: @@ -444,18 +444,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.12.0" mgrs_dart: dependency: transitive description: @@ -572,10 +572,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: a7e8467e9181cef109f601e3f65765685786c1a738a83d7fbbde377589c0d974 + sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_foundation: dependency: transitive description: @@ -673,10 +673,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.0" typed_data: dependency: transitive description: @@ -697,10 +697,10 @@ packages: dependency: transitive description: name: uuid - sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" + sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 url: "https://pub.dev" source: hosted - version: "4.4.2" + version: "4.5.0" vector_math: dependency: transitive description: @@ -713,10 +713,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.1" web: dependency: transitive description: diff --git a/rust-backend/migrations/V3__create_reviews_table.sql b/rust-backend/migrations/V3__create_reviews_table.sql index 6ab31b4..6b76a9f 100644 --- a/rust-backend/migrations/V3__create_reviews_table.sql +++ b/rust-backend/migrations/V3__create_reviews_table.sql @@ -7,6 +7,6 @@ CREATE TABLE reviews ( place_description TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, - rating REAL NOT NULL + rating INTEGER NOT NULL );