diff --git a/.env_template b/.env_template new file mode 100644 index 00000000..b8d79805 --- /dev/null +++ b/.env_template @@ -0,0 +1 @@ +GOOGLE_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index b9089e00..75eb2dbf 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,7 @@ __pycache__ .pytest_cache pyenv platform_config.yaml -videos \ No newline at end of file +videos + +# Env files +.env \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 47c459b1..feef2216 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,6 +14,13 @@ if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } +// Load .env file +val envFile = file("../../.env") +val envProperties = Properties() +if (envFile.exists()) { + envProperties.load(FileInputStream(envFile)) +} + android { flavorDimensions += "store" @@ -49,18 +56,22 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + manifestPlaceholders.put("GOOGLE_API_KEY", envProperties.getProperty("GOOGLE_API_KEY") ?: "") } signingConfigs { create("release") { - keyAlias = keystoreProperties["keyAlias"] as String - keyPassword = keystoreProperties["keyPassword"] as String + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? storeFile = keystoreProperties["storeFile"]?.let { file(it) } - storePassword = keystoreProperties["storePassword"] as String + storePassword = keystoreProperties["storePassword"] as String? + } + getByName("debug") { + // Debug signing config uses default values } } - buildTypes { release { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2510a452..4ce9bc32 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -20,6 +20,8 @@ android:icon="@mipmap/ic_launcher" android:allowBackup="false" android:fullBackupOnly="false"> + Access to documents is needed to import and export CSV files UISupportsDocumentBrowser + GOOGLE_API_KEY + $(GOOGLE_API_KEY) \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index d92f447f..f1d97499 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -15,6 +16,8 @@ import 'services/notifications/notifications_service.dart'; late SharedPreferences _sharedPreferences; void main() async { + await dotenv.load(fileName: ".env"); + WidgetsFlutterBinding.ensureInitialized(); NotificationService().requestNotificationPermissions(); NotificationService().initializeNotifications(); diff --git a/lib/model/recurring_transaction.dart b/lib/model/recurring_transaction.dart index a36f253b..8ec97575 100644 --- a/lib/model/recurring_transaction.dart +++ b/lib/model/recurring_transaction.dart @@ -17,6 +17,9 @@ class RecurringTransactionFields extends BaseEntityFields { static String idCategory = 'idCategory'; static String idBankAccount = 'idBankAccount'; static String lastInsertion = 'lastInsertion'; + static String lat = 'lat'; + static String lon = 'lon'; + static String locationName = 'locationName'; static String createdAt = BaseEntityFields.getCreatedAt; static String updatedAt = BaseEntityFields.getUpdatedAt; @@ -30,6 +33,9 @@ class RecurringTransactionFields extends BaseEntityFields { idCategory, idBankAccount, lastInsertion, + lat, + lon, + locationName, BaseEntityFields.createdAt, BaseEntityFields.updatedAt, ]; @@ -55,72 +61,87 @@ class RecurringTransaction extends BaseEntity { final int idBankAccount; final TransactionType type; final DateTime? lastInsertion; - - const RecurringTransaction( - {super.id, - required this.fromDate, - this.toDate, - required this.amount, - required this.note, - required this.recurrency, - required this.idCategory, - required this.type, - required this.idBankAccount, - this.lastInsertion, - super.createdAt, - super.updatedAt}); - - RecurringTransaction copy( - {int? id, - DateTime? fromDate, - DateTime? toDate, - num? amount, - String? note, - String? recurrency, - int? idCategory, - TransactionType? type, - int? idBankAccount, - DateTime? lastInsertion, - DateTime? createdAt, - DateTime? updatedAt}) => + final num? lat; + final num? lon; + final String? locationName; + + const RecurringTransaction({ + super.id, + required this.fromDate, + this.toDate, + required this.amount, + required this.note, + required this.recurrency, + required this.idCategory, + required this.type, + required this.idBankAccount, + this.lat, + this.lon, + this.locationName, + this.lastInsertion, + super.createdAt, + super.updatedAt, + }); + + RecurringTransaction copy({ + int? id, + DateTime? fromDate, + DateTime? toDate, + num? amount, + String? note, + String? recurrency, + int? idCategory, + int? idBankAccount, + DateTime? lastInsertion, + num? lat, + num? lon, + String? locationName, + DateTime? createdAt, + DateTime? updatedAt, + }) => RecurringTransaction( - id: id ?? this.id, - fromDate: fromDate ?? this.fromDate, - toDate: toDate ?? this.toDate, - amount: amount ?? this.amount, - note: note ?? this.note, - recurrency: recurrency ?? this.recurrency, - idCategory: idCategory ?? this.idCategory, - type: type ?? this.type, - idBankAccount: idBankAccount ?? this.idBankAccount, - lastInsertion: lastInsertion ?? this.lastInsertion, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt); + id: id ?? this.id, + fromDate: fromDate ?? this.fromDate, + toDate: toDate ?? this.toDate, + amount: amount ?? this.amount, + note: note ?? this.note, + recurrency: recurrency ?? this.recurrency, + idCategory: idCategory ?? this.idCategory, + lat: lat ?? this.lat, + lon: lon ?? this.lon, + locationName: locationName ?? this.locationName, + type: type ?? this.type, + idBankAccount: idBankAccount ?? this.idBankAccount, + lastInsertion: lastInsertion ?? this.lastInsertion, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); static RecurringTransaction fromJson(Map json) => RecurringTransaction( - id: json[BaseEntityFields.id] as int?, - fromDate: DateTime.parse( - json[RecurringTransactionFields.fromDate] as String), - toDate: - json[RecurringTransactionFields.toDate] != null - ? DateTime.parse( - json[RecurringTransactionFields.toDate] as String) - : null, - amount: json[RecurringTransactionFields.amount] as num, - note: json[RecurringTransactionFields.note] as String, - recurrency: json[RecurringTransactionFields.recurrency] as String, - idCategory: json[RecurringTransactionFields.idCategory] as int, - type: TransactionType.fromJson( + id: json[BaseEntityFields.id] as int?, + fromDate: + DateTime.parse(json[RecurringTransactionFields.fromDate] as String), + toDate: json[RecurringTransactionFields.toDate] != null + ? DateTime.parse(json[RecurringTransactionFields.toDate] as String) + : null, + amount: json[RecurringTransactionFields.amount] as num, + note: json[RecurringTransactionFields.note] as String, + recurrency: json[RecurringTransactionFields.recurrency] as String, + idCategory: json[RecurringTransactionFields.idCategory] as int, + type: TransactionType.fromJson( json[RecurringTransactionFields.type] as String), - idBankAccount: json[RecurringTransactionFields.idBankAccount] as int, - lastInsertion: json[RecurringTransactionFields.lastInsertion] != null - ? DateTime.parse( - json[RecurringTransactionFields.lastInsertion] as String) - : null, - createdAt: DateTime.parse(json[BaseEntityFields.createdAt] as String), - updatedAt: - DateTime.parse(json[BaseEntityFields.updatedAt] as String)); + idBankAccount: json[RecurringTransactionFields.idBankAccount] as int, + lastInsertion: json[RecurringTransactionFields.lastInsertion] != null + ? DateTime.parse( + json[RecurringTransactionFields.lastInsertion] as String) + : null, + createdAt: DateTime.parse(json[BaseEntityFields.createdAt] as String), + updatedAt: DateTime.parse(json[BaseEntityFields.updatedAt] as String), + lat: json[TransactionFields.lat] as num?, + lon: json[TransactionFields.lon] as num?, + locationName: json[TransactionFields.locationName] as String?, + ); Map toJson() => { BaseEntityFields.id: id, @@ -132,6 +153,9 @@ class RecurringTransaction extends BaseEntity { RecurringTransactionFields.recurrency: recurrency, RecurringTransactionFields.idCategory: idCategory, RecurringTransactionFields.idBankAccount: idBankAccount, + RecurringTransactionFields.lat: lat, + RecurringTransactionFields.lon: lon, + RecurringTransactionFields.locationName: locationName, RecurringTransactionFields.lastInsertion: lastInsertion?.toIso8601String(), BaseEntityFields.createdAt: createdAt?.toIso8601String(), @@ -348,6 +372,9 @@ class RecurringTransactionMethods extends SossoldiDatabase { idBankAccount: transaction.idBankAccount, recurring: true, idRecurringTransaction: transaction.id, + locationName: transaction.locationName, + lat: transaction.lat, + lon: transaction.lon, createdAt: DateTime.now(), updatedAt: DateTime.now(), ); diff --git a/lib/model/transaction.dart b/lib/model/transaction.dart index 69c418c3..323f464e 100644 --- a/lib/model/transaction.dart +++ b/lib/model/transaction.dart @@ -20,6 +20,9 @@ class TransactionFields extends BaseEntityFields { static String idBankAccountTransfer = 'idBankAccountTransfer'; static String bankAccountTransferName = 'bankAccountTransferName'; static String recurring = 'recurring'; + static String lat = 'lat'; + static String lon = 'lon'; + static String locationName = 'locationName'; static String idRecurringTransaction = 'idRecurringTransaction'; static String createdAt = BaseEntityFields.getCreatedAt; static String updatedAt = BaseEntityFields.getUpdatedAt; @@ -34,6 +37,9 @@ class TransactionFields extends BaseEntityFields { idBankAccount, idBankAccountTransfer, recurring, + lat, + lon, + locationName, idRecurringTransaction, BaseEntityFields.createdAt, BaseEntityFields.updatedAt @@ -128,6 +134,9 @@ class Transaction extends BaseEntity { final int? idBankAccountTransfer; final String? bankAccountTransferName; final bool recurring; + final num? lat; + final num? lon; + final String? locationName; final int? idRecurringTransaction; const Transaction( @@ -145,6 +154,9 @@ class Transaction extends BaseEntity { this.idBankAccountTransfer, this.bankAccountTransferName, required this.recurring, + this.lat, + this.lon, + this.locationName, this.idRecurringTransaction, super.createdAt, super.updatedAt}); @@ -159,6 +171,9 @@ class Transaction extends BaseEntity { int? idBankAccount, int? idBankAccountTransfer, bool? recurring, + num? lat, + num? lon, + String? locationName, int? idRecurringTransaction, DateTime? createdAt, DateTime? updatedAt}) => @@ -173,6 +188,9 @@ class Transaction extends BaseEntity { idBankAccountTransfer: idBankAccountTransfer ?? this.idBankAccountTransfer, recurring: recurring ?? this.recurring, + lat: lat ?? this.lat, + lon: lon ?? this.lon, + locationName: locationName ?? this.locationName, idRecurringTransaction: idRecurringTransaction ?? this.idRecurringTransaction, createdAt: createdAt ?? this.createdAt, @@ -196,6 +214,9 @@ class Transaction extends BaseEntity { bankAccountTransferName: json[TransactionFields.bankAccountTransferName] as String?, recurring: json[TransactionFields.recurring] == 1, + lat: json[TransactionFields.lat] as num?, + lon: json[TransactionFields.lon] as num?, + locationName: json[TransactionFields.locationName] as String?, idRecurringTransaction: json[TransactionFields.idRecurringTransaction] as int?, createdAt: DateTime.parse(json[BaseEntityFields.createdAt] as String), @@ -218,6 +239,9 @@ class Transaction extends BaseEntity { TransactionFields.idBankAccount: idBankAccount, TransactionFields.idBankAccountTransfer: idBankAccountTransfer, TransactionFields.recurring: recurring ? 1 : 0, + TransactionFields.lat: lat, + TransactionFields.lon: lon, + TransactionFields.locationName: locationName, TransactionFields.idRecurringTransaction: idRecurringTransaction, BaseEntityFields.createdAt: createdAtDate, BaseEntityFields.updatedAt: DateTime.now().toIso8601String(), diff --git a/lib/pages/transactions/create_transaction/create_transaction_page.dart b/lib/pages/transactions/create_transaction/create_transaction_page.dart index a7bc44fd..477dcd2e 100644 --- a/lib/pages/transactions/create_transaction/create_transaction_page.dart +++ b/lib/pages/transactions/create_transaction/create_transaction_page.dart @@ -3,6 +3,7 @@ import 'dart:io' show Platform; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; import '../../../constants/style.dart'; import '../../../model/transaction.dart'; @@ -16,6 +17,7 @@ import "widgets/category_selector.dart"; import 'widgets/details_list_tile.dart'; import 'widgets/duplicate_transaction_dialog.dart'; import 'widgets/label_list_tile.dart'; +import 'widgets/location_selector.dart'; import 'widgets/recurrence_list_tile.dart'; class CreateTransactionPage extends ConsumerStatefulWidget { @@ -379,7 +381,42 @@ class _CreateTransactionPage extends ConsumerState { widget.recurrencyEditingPermitted, selectedTransaction: ref.read(selectedTransactionUpdateProvider), - ) + ), + if (selectedType != TransactionType.transfer) ...[ + const Divider(), + DetailsListTile( + title: "Location", + icon: Icons.location_pin, + value: ref.watch(locationTransactionProvider)?.searchText, + callback: () { + FocusManager.instance.primaryFocus?.unfocus(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => DraggableScrollableSheet( + expand: false, + minChildSize: 0.5, + initialChildSize: 0.8, + maxChildSize: 0.95, + builder: (_, scrollController) { + return LocationSelector(scrollController: scrollController); + }, + ), + ); + }, + ), + ], + if (selectedType == TransactionType.expense) ...[ + RecurrenceListTile( + recurrencyEditingPermitted: + widget.recurrencyEditingPermitted, + selectedTransaction: + ref.read(selectedTransactionUpdateProvider), + ) + ], ], ), ), diff --git a/lib/pages/transactions/create_transaction/widgets/details_list_tile.dart b/lib/pages/transactions/create_transaction/widgets/details_list_tile.dart index cf9631b8..4c7c47d7 100644 --- a/lib/pages/transactions/create_transaction/widgets/details_list_tile.dart +++ b/lib/pages/transactions/create_transaction/widgets/details_list_tile.dart @@ -41,20 +41,33 @@ class DetailsListTile extends ConsumerWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - value ?? '', - style: Theme.of(context).textTheme.bodySmall!.copyWith( - color: isDarkMode - ? grey3 - : Theme.of(context).colorScheme.secondary), + SizedBox( + width: 130, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: Text( + value ?? '', + overflow: TextOverflow.visible, + softWrap: false, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: isDarkMode + ? grey3 + : Theme.of(context).colorScheme.secondary, + ), + ), + ), ), const SizedBox(width: Sizes.sm), Icon( Icons.chevron_right, - color: isDarkMode ? grey3 : Theme.of(context).colorScheme.secondary, + color: isDarkMode + ? grey3 + : Theme.of(context).colorScheme.secondary, ), ], ), + ); } } diff --git a/lib/pages/transactions/create_transaction/widgets/location_selector.dart b/lib/pages/transactions/create_transaction/widgets/location_selector.dart new file mode 100644 index 00000000..dc6bc78d --- /dev/null +++ b/lib/pages/transactions/create_transaction/widgets/location_selector.dart @@ -0,0 +1,379 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../../../constants/style.dart'; +import '../../../../providers/transactions_provider.dart'; +import '../../../../ui/device.dart'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; + +import '../../../../utils/location_bag.dart'; +import '../../../../ui/snack_bars/snack_bar.dart'; + +class LocationSelector extends ConsumerStatefulWidget { + final ScrollController scrollController; + const LocationSelector({required this.scrollController, super.key}); + + @override + ConsumerState createState() => _LocationSelectorState(); +} + +class _LocationSelectorState extends ConsumerState { + GoogleMapController? mapController; + TextEditingController searchController = TextEditingController(); + + LatLng? selectedPosition; + Set markers = {}; + + LatLng initialPosition = LatLng(0, 0); + final String _googleApiKey = dotenv.env['GOOGLE_API_KEY'] ?? ''; + List> _suggestions = []; + bool _isLoadingSuggestions = false; + + @override + Widget build(BuildContext context) { + LocationBag? location = ref.read(locationTransactionProvider); + double lat = location?.latitude ?? 45.4642; // Default to Milan if no location is set + double lng = location?.longitude ?? 9.1900; // Default to Milan if + initialPosition = LatLng(lat, lng); + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Column( + children: [ + TextField( + controller: searchController, + decoration: InputDecoration( + hintText: 'Search for a place', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + filled: true, + fillColor: Colors.white, + ), + onChanged: (value) { + if (value.isNotEmpty) { + _fetchSuggestions(value); + } else { + setState(() { + _suggestions = []; + }); + } + }, + onSubmitted: (value) { + if (value.isNotEmpty) { + _searchPlace(value); + setState(() { + _suggestions = []; + }); + } + }, + ), + if (_suggestions.isNotEmpty) + Container( + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: _suggestions.length, + itemBuilder: (context, index) { + final suggestion = _suggestions[index]; + return ListTile( + title: Text(suggestion['text']), + onTap: () { + searchController.text = suggestion['text']; + _selectSuggestion( + suggestion['id'], + suggestion['text'] + ); + setState(() { + _suggestions = []; + }); + }, + ); + }, + ), + ), + ], + ), + ), + Expanded( + child: GoogleMap( + initialCameraPosition: CameraPosition( + target: initialPosition, + zoom: 12, + ), + onMapCreated: (controller) { + mapController = controller; + }, + markers: markers, + mapType: MapType.normal, + myLocationEnabled: false, + zoomControlsEnabled: true, + mapToolbarEnabled: false, + compassEnabled: true, + rotateGesturesEnabled: true, + scrollGesturesEnabled: true, + tiltGesturesEnabled: true, + zoomGesturesEnabled: true, + trafficEnabled: false, + buildingsEnabled: true, + indoorViewEnabled: true, + liteModeEnabled: false, + ), + ), + SafeArea( + child: Container( + alignment: Alignment.bottomCenter, + width: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.15), + blurRadius: 5.0, + offset: const Offset(0, -1.0), + ) + ], + ), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ) + ], + borderRadius: BorderRadius.circular(8), + ), + child: ElevatedButton( + onPressed: selectedPosition != null + ? () async { + ref.read(locationTransactionProvider.notifier).state = LocationBag(latitude: selectedPosition!.latitude, longitude: selectedPosition!.longitude, searchText: searchController.text); + Navigator.pop(context); + } + : null, + child: const Text('ADD LOCATION'), + ), + ), + ), + ), + ], + ); + } + + @override + void initState() { + super.initState(); + _initializeLocation(); + } + + Future _initializeLocation() async { + LocationBag? location = ref.read(locationTransactionProvider); + if (location != null) { + await _moveToLocationWithZoom(location.latitude, location.longitude); + } + } + + Future _searchPlace(String query) async { + final String url = 'https://places.googleapis.com/v1/places:searchText'; + + final Map headers = { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': _googleApiKey, + 'X-Goog-FieldMask': 'places.displayName,places.location' + }; + + final body = jsonEncode({ + 'textQuery': query, + 'maxResultCount': 10, + }); + + try { + final response = await http.post( + Uri.parse(url), + headers: headers, + body: body, + ).timeout( + const Duration(seconds: 10), + onTimeout: () => http.Response('{"error": "timeout"}', 408), + ); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + + if (data['places'] != null && data['places'].isNotEmpty) { + final place = data['places'][0]; + final location = place['location']; + final LatLng position = LatLng(location['latitude'], location['longitude']); + + await _moveToPosition(position, place['displayName']['text'] ?? query); + } + } else { + showErrorMessage("Error during search"); + } + } catch (e) { + showErrorMessage("Error during search"); + } + } + + Future _fetchSuggestions(String input) async { + setState(() { + _isLoadingSuggestions = true; + }); + + final url = Uri.parse('https://places.googleapis.com/v1/places:autocomplete'); + final headers = { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': _googleApiKey, + 'X-Goog-FieldMask': 'suggestions.placePrediction.text,suggestions.placePrediction.placeId', + }; + final body = jsonEncode({ + 'input': input, + }); + + try { + final response = await http.post(url, headers: headers, body: body).timeout( + const Duration(seconds: 10), + onTimeout: () => http.Response('{"error": "timeout"}', 408), + ); + if (response.statusCode == 200) { + final data = json.decode(response.body); + final suggestions = (data['suggestions'] as List?) + ?.where((suggestion) => suggestion['placePrediction'] != null) + .map((suggestion) => { + 'text': suggestion['placePrediction']['text']['text'], + 'id': suggestion['placePrediction']['placeId'], + }) + .toList(); + + setState(() { + _suggestions = suggestions ?? []; + }); + } else { + setState(() { + _suggestions = []; + }); + + showErrorMessage('Error fetching suggestions'); + } + } catch (e) { + setState(() { + _suggestions = []; + }); + + showErrorMessage("Error fetching suggestions"); + } finally { + setState(() { + _isLoadingSuggestions = false; + }); + } + } + + Future _moveToPosition(LatLng position, String title) async { + if (mapController != null) { + await mapController!.animateCamera( + CameraUpdate.newLatLng(position), + ); + + setState(() { + selectedPosition = position; + }); + + _addMarker(position, title); + } + } + + void _addMarker(LatLng position, String title) { + setState(() { + markers.clear(); + markers.add( + Marker( + markerId: const MarkerId('selected_location'), + position: position, + infoWindow: InfoWindow(title: title), + icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed), + ), + ); + }); + } + + Future _selectSuggestion(String placeId, String text) async { + setState(() { + _suggestions = []; + searchController.text = text; + }); + + final placeDetails = await _getPlaceDetails(placeId); + if (placeDetails != null) { + final lat = placeDetails['location']['latitude']; + final lng = placeDetails['location']['longitude']; + + await _moveToLocationWithZoom(lat, lng); + } + } + + Future?> _getPlaceDetails(String placeId) async { + final url = Uri.parse('https://places.googleapis.com/v1/places/$placeId'); + final headers = { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': _googleApiKey, + 'X-Goog-FieldMask': 'location', + }; + + try { + final response = await http.get(url, headers: headers).timeout( + const Duration(seconds: 10), + onTimeout: () => http.Response('{"error": "timeout"}', 408), + ); + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + showErrorMessage("Error fetching suggestions"); + return null; + } + } catch (e) { + showErrorMessage("Error fetching suggestions"); + return null; + } + } + + Future _moveToLocationWithZoom(double lat, double lng) async { + final CameraPosition newPosition = CameraPosition( + target: LatLng(lat, lng), + zoom: 15.0, + ); + + await mapController?.animateCamera( + CameraUpdate.newCameraPosition(newPosition), + ); + + _addMarker(LatLng(lat, lng), 'Selected Location'); + } + + void showErrorMessage(String message) { + Navigator.of(context).pop(); + if (!mounted) return; + showSnackBar(context, message: message); + } + + @override + void dispose() { + searchController.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/lib/providers/transactions_provider.dart b/lib/providers/transactions_provider.dart index a843f4de..4d0edad9 100644 --- a/lib/providers/transactions_provider.dart +++ b/lib/providers/transactions_provider.dart @@ -4,6 +4,7 @@ import '../model/bank_account.dart'; import '../model/category_transaction.dart'; import '../model/recurring_transaction.dart'; import '../model/transaction.dart'; +import '../utils/location_bag.dart'; import 'accounts_provider.dart'; import 'budgets_provider.dart'; import 'dashboard_provider.dart'; @@ -28,6 +29,7 @@ final bankAccountProvider = StateProvider((ref) => ref.read(mainAccountProvider)); final dateProvider = StateProvider((ref) => DateTime.now()); final categoryProvider = StateProvider((ref) => null); +final locationTransactionProvider = StateProvider((ref) => null); // Recurring Payment final selectedRecurringPayProvider = StateProvider((ref) => false); @@ -129,8 +131,11 @@ class AsyncTransactionsNotifier : ref.read(categoryProvider)?.id, recurring: account != null ? false : ref.read(selectedRecurringPayProvider), + locationName: ref.read(locationTransactionProvider)?.searchText, + lat: ref.read(locationTransactionProvider)?.latitude, + lon: ref.read(locationTransactionProvider)?.longitude, ); - + final a = 1; state = await AsyncValue.guard(() async { await TransactionMethods().insert(transaction); return _getTransactions(update: true); @@ -163,6 +168,7 @@ class AsyncTransactionsNotifier final bankAccount = ref.read(bankAccountProvider)!; final category = ref.read(categoryProvider); final recurrency = ref.read(intervalProvider.notifier).state; + final location = ref.read(locationTransactionProvider); RecurringTransaction transaction = RecurringTransaction( amount: amount, @@ -175,6 +181,9 @@ class AsyncTransactionsNotifier recurrency: recurrency.name.toUpperCase(), createdAt: date, updatedAt: date, + locationName: location?.searchText, + lat: location?.latitude, + lon: location?.longitude, lastInsertion: date); // Here we need the recurringTransaction just inserted, to get and return a model with also his ID @@ -200,6 +209,9 @@ class AsyncTransactionsNotifier idCategory: category.id!, idRecurringTransaction: insertedTransaction!.id, recurring: true, + locationName: location?.searchText, + lat: location?.latitude, + lon: location?.longitude, ); await TransactionMethods().insert(transaction); } @@ -214,6 +226,7 @@ class AsyncTransactionsNotifier final bankAccount = ref.read(bankAccountProvider)!; final bankAccountTransfer = ref.read(bankAccountTransferProvider); final category = ref.read(categoryProvider); + final location = ref.read(locationTransactionProvider); Transaction transaction = ref.read(selectedTransactionUpdateProvider)!.copy( date: date, @@ -224,6 +237,9 @@ class AsyncTransactionsNotifier idBankAccountTransfer: bankAccountTransfer?.id, idCategory: category?.id, idRecurringTransaction: recurringTransactionId, + locationName: location?.searchText, + lat: location?.latitude, + lon: location?.longitude, recurring: recurringTransactionId != null ? true : false); state = const AsyncValue.loading(); @@ -237,6 +253,7 @@ class AsyncTransactionsNotifier final bankAccount = ref.read(bankAccountProvider)!; final recurrency = ref.read(intervalProvider.notifier).state; final category = ref.read(categoryProvider); + final location = ref.read(locationTransactionProvider); final RecurringTransaction transaction = ref .read(selectedRecurringTransactionUpdateProvider)! @@ -248,6 +265,9 @@ class AsyncTransactionsNotifier note: label, idBankAccount: bankAccount.id!, idCategory: category?.id, + locationName: location?.searchText, + lat: location?.latitude, + lon: location?.longitude, updatedAt: DateTime.now()); state = const AsyncValue.loading(); @@ -261,6 +281,13 @@ class AsyncTransactionsNotifier num idTransaction, num idRecurringTransaction) async {} Future transactionUpdateState(dynamic transaction) async { + ref.read(locationTransactionProvider.notifier).state = + transaction.locationName != null + ? LocationBag( + searchText: transaction.locationName ?? '', + latitude: transaction.lat?.toDouble() ?? 0.0, + longitude: transaction.lon?.toDouble() ?? 0.0) + : null; if (transaction is Transaction) { ref.read(selectedTransactionUpdateProvider.notifier).state = transaction; ref.read(selectedRecurringPayProvider.notifier).state = @@ -331,6 +358,7 @@ class AsyncTransactionsNotifier ref.invalidate(bankAccountTransferProvider); ref.invalidate(dateProvider); ref.invalidate(categoryProvider); + ref.invalidate(locationTransactionProvider); ref.invalidate(selectedRecurringPayProvider); ref.invalidate(intervalProvider); ref.invalidate(transactionTypeProvider); diff --git a/lib/services/database/migrations/0003_transaction_location.dart b/lib/services/database/migrations/0003_transaction_location.dart new file mode 100644 index 00000000..5d74e551 --- /dev/null +++ b/lib/services/database/migrations/0003_transaction_location.dart @@ -0,0 +1,30 @@ +// ignore_for_file: file_names + +import 'package:sqflite/sqflite.dart'; +import '../migration_base.dart'; + +// Model +import '/model/transaction.dart'; + +class TransactionLocation extends Migration { + TransactionLocation() + : super( + version: 3, + description: 'Add lat, lon, and locationName to transaction table', + ); + + @override + Future up(Database db) async { + await db.execute(''' + ALTER TABLE `$transactionTable` ADD COLUMN `${TransactionFields.lat}` REAL; + '''); + + await db.execute(''' + ALTER TABLE `$transactionTable` ADD COLUMN `${TransactionFields.lon}` REAL; + '''); + + await db.execute(''' + ALTER TABLE `$transactionTable` ADD COLUMN `${TransactionFields.locationName}` TEXT; + '''); + } +} diff --git a/lib/services/database/migrations/0004_recurrent_transaction_location.dart b/lib/services/database/migrations/0004_recurrent_transaction_location.dart new file mode 100644 index 00000000..520dcb94 --- /dev/null +++ b/lib/services/database/migrations/0004_recurrent_transaction_location.dart @@ -0,0 +1,31 @@ +// ignore_for_file: file_names + +import 'package:sqflite/sqflite.dart'; +import '../../../model/recurring_transaction.dart'; +import '../migration_base.dart'; + +// Model +import '../../../model/transaction.dart'; + +class RecurrentTransactionLocation extends Migration { + RecurrentTransactionLocation() + : super( + version: 3, + description: 'Add lat, lon, and locationName to recurrent transaction table', + ); + + @override + Future up(Database db) async { + await db.execute(''' + ALTER TABLE `$recurringTransactionTable` ADD COLUMN `${TransactionFields.lat}` REAL; + '''); + + await db.execute(''' + ALTER TABLE `$recurringTransactionTable` ADD COLUMN `${TransactionFields.lon}` REAL; + '''); + + await db.execute(''' + ALTER TABLE `$recurringTransactionTable` ADD COLUMN `${TransactionFields.locationName}` TEXT; + '''); + } +} diff --git a/lib/services/database/migrations/migration_registry.dart b/lib/services/database/migrations/migration_registry.dart index ca6ba1cf..92a8d7ed 100644 --- a/lib/services/database/migrations/migration_registry.dart +++ b/lib/services/database/migrations/migration_registry.dart @@ -15,6 +15,8 @@ import '0001_initial_schema.dart'; import '0002_account_net_worth.dart'; import '0003_recurring_transaction_type.dart'; import '../migration_base.dart'; +import '0003_transaction_location.dart'; +import '0004_recurrent_transaction_location.dart'; /// Returns all available migrations in execution order. /// @@ -26,7 +28,8 @@ List getMigrations() { return [ InitialSchema(), AccountNetWorth(), - RecurringTransactionType(), + TransactionLocation(), + RecurrentTransactionLocation() // Add future migrations here ]; } diff --git a/lib/utils/location_bag.dart b/lib/utils/location_bag.dart new file mode 100644 index 00000000..4cedd12e --- /dev/null +++ b/lib/utils/location_bag.dart @@ -0,0 +1,11 @@ +class LocationBag { + final double latitude; + final double longitude; + final String searchText; + + const LocationBag({ + required this.latitude, + required this.longitude, + required this.searchText, + }); +} diff --git a/pubspec.lock b/pubspec.lock index 8ca1ab43..79f9ce6f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -246,6 +246,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_launcher_icons: dependency: "direct dev" description: @@ -344,6 +352,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_maps: + dependency: transitive + description: + name: google_maps + sha256: "4d6e199c561ca06792c964fa24b2bac7197bf4b401c2e1d23e345e5f9939f531" + url: "https://pub.dev" + source: hosted + version: "8.1.1" + google_maps_flutter: + dependency: "direct main" + description: + name: google_maps_flutter + sha256: e1805e5a5885bd14a1c407c59229f478af169bf4d04388586b19f53145a5db3a + url: "https://pub.dev" + source: hosted + version: "2.12.3" + google_maps_flutter_android: + dependency: transitive + description: + name: google_maps_flutter_android + sha256: "356ee9c65f38a104f7c4988e6952e52addb3b6cb1601839dd2010d7a502afcf0" + url: "https://pub.dev" + source: hosted + version: "2.16.2" + google_maps_flutter_ios: + dependency: transitive + description: + name: google_maps_flutter_ios + sha256: d03678415da9de8ce7208c674b264fc75946f326e696b4b7f84c80920fc58df6 + url: "https://pub.dev" + source: hosted + version: "2.15.4" + google_maps_flutter_platform_interface: + dependency: transitive + description: + name: google_maps_flutter_platform_interface + sha256: f8293f072ed8b068b092920a72da6693aa8b3d62dc6b5c5f0bc44c969a8a776c + url: "https://pub.dev" + source: hosted + version: "2.12.1" + google_maps_flutter_web: + dependency: transitive + description: + name: google_maps_flutter_web + sha256: ce2cac714e5462bf761ff2fdfc3564c7e5d7ed0578268dccb0a54dbdb1e6214e + url: "https://pub.dev" + source: hosted + version: "0.5.12+2" html: dependency: transitive description: @@ -680,6 +736,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + sanitize_html: + dependency: transitive + description: + name: sanitize_html + sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" + url: "https://pub.dev" + source: hosted + version: "2.1.0" shared_preferences: dependency: "direct main" description: @@ -885,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9e51b42e..4bc41f1d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,8 @@ dependencies: sqlite3_flutter_libs: timezone: ^0.10.0 url_launcher: ^6.3.1 + google_maps_flutter: ^2.12.2 + flutter_dotenv: ^5.1.0 dev_dependencies: flutter_test: @@ -48,6 +50,7 @@ flutter: assets: - assets/ - assets/images/ + - .env fonts: - family: NunitoSans fonts: