Add Dialog with Pageview to select new house, and then drop a pin where the house is, then zoom map to that area.

This commit is contained in:
2025-04-20 20:36:13 -07:00
parent 9170fffdf8
commit dd9d1a67ab
19 changed files with 1581 additions and 6 deletions

75
lib/chatgpt/test.dart Normal file
View File

@@ -0,0 +1,75 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Represents a busy intersection.
class Intersection {
final String name;
final double latitude;
final double longitude;
Intersection(this.name, this.latitude, this.longitude);
}
/// A service that communicates with the OpenAI ChatGPT API.
class ChatGPTService {
final String apiKey = 'YOUR_API_KEY'; // Replace with your actual API key
final String apiUrl = 'https://api.openai.com/v1/chat/completions';
/// Sends a prompt to ChatGPT to find the busiest intersections around the address.
Future<List<Intersection>> getBusyIntersections({
required String address,
required int count,
required double radiusMiles,
required String timeOfDay,
}) async {
final prompt = '''
Given the address "$address", find the $count busiest intersections within $radiusMiles miles during $timeOfDay on a weekday. Return the results in CSV format with columns: intersection_name, latitude, longitude.
''';
final response = await http.post(
Uri.parse(apiUrl),
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
body: jsonEncode({
"model": "gpt-4",
"messages": [
{
"role": "system",
"content": "You are a traffic and mapping expert.",
},
{"role": "user", "content": prompt},
],
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final csvString = data['choices'][0]['message']['content'];
return _parseCsv(csvString);
} else {
throw Exception('Failed to get response from ChatGPT: ${response.body}');
}
}
/// Parses the CSV response into a list of [Intersection] objects.
List<Intersection> _parseCsv(String csv) {
final lines = LineSplitter().convert(csv.trim());
final intersections = <Intersection>[];
for (var i = 1; i < lines.length; i++) {
final parts = lines[i].split(',');
if (parts.length >= 3) {
final name = parts[0].trim();
final lat = double.tryParse(parts[1].trim());
final lng = double.tryParse(parts[2].trim());
if (lat != null && lng != null) {
intersections.add(Intersection(name, lat, lng));
}
}
}
return intersections;
}
}

View File

@@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
class Constants {
Constants._();
static const double padding = 20;
static const double avatarRadius = 45;
}

19
lib/common/functions.dart Normal file
View File

@@ -0,0 +1,19 @@
import 'dart:async';
import 'package:flutter/material.dart';
class Debouncer {
final int milliseconds;
Timer? _timer;
Debouncer({required this.milliseconds});
void run(VoidCallback action) {
_timer?.cancel();
_timer = Timer(Duration(milliseconds: milliseconds), action);
}
void dispose() {
_timer?.cancel();
}
}

29
lib/database/schema.dart Normal file
View File

@@ -0,0 +1,29 @@
import 'package:powersync/powersync.dart';
const propertiesTable = 'properties';
const markersTable = 'markers';
Schema schema = Schema(([
const Table(
propertiesTable,
[
Column.text('list_id'),
Column.text('photo_id'),
Column.text('created_at'),
Column.text('completed_at'),
Column.text('description'),
Column.integer('completed'),
Column.text('created_by'),
Column.text('completed_by'),
],
indexes: [
// Index to allow efficient lookup within a list
Index('list', [IndexedColumn('list_id')]),
],
),
const Table('markers', [
Column.text('created_at'),
Column.text('name'),
Column.text('owner_id'),
]),
]));

40
lib/dialogs/dialogs.dart Normal file
View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:wheres_my_sign/widgets/custom_dialog_box.dart';
class Dialogs extends StatefulWidget {
@override
_DialogsState createState() => _DialogsState();
}
class _DialogsState extends State<Dialogs> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Custom Dialog In Flutter"),
centerTitle: true,
automaticallyImplyLeading: false,
),
body: Container(
child: Center(
child: ElevatedButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return CustomDialogBox(
title: "Custom Dialog Demo",
descriptions:
"Hii all this is a custom dialog in flutter and you will be use in your flutter applications",
text: "Yes",
);
},
);
},
child: Text("Custom Dialog"),
),
),
),
);
}
}

View File

@@ -3,6 +3,8 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wheres_my_sign/models/property.dart';
import 'package:wheres_my_sign/widgets/custom_dialog_box.dart';
Future<void> main() async {
await dotenv.load(fileName: ".env");
@@ -88,6 +90,81 @@ class SignMapScreenState extends State<SignMapScreen> {
});
}
// return something like this:
// Property {
// address: "1234 Smith St",
// latitude: 37.7749,
// longitude: -122.4194,
// signLocations: [LatLng(37.77, -122.42), LatLng(37.775, -122.41)],
// }
void _addProperty() async {
final Property? newProperty = await showDialog<Property>(
context: context,
builder: (BuildContext context) {
return CustomDialogBox(
title: "Show A Property",
descriptions: "Let's show a new property!",
text: "Yes",
);
},
);
if (newProperty != null) {
setState(() {
// 🟢 Main property marker
markers.add(
Marker(
markerId: MarkerId(
'property_${newProperty.latitude}_${newProperty.longitude}',
),
position: LatLng(newProperty.latitude, newProperty.longitude),
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueGreen,
),
infoWindow: InfoWindow(title: newProperty.address),
),
);
// 🔴 Sign location markers
for (int i = 0; i < newProperty.signLocations.length; i++) {
final LatLng signLocation = newProperty.signLocations[i];
markers.add(
Marker(
markerId: MarkerId('sign_$i'),
position: signLocation,
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueRed,
),
infoWindow: InfoWindow(title: 'Sign ${i + 1}'),
),
);
}
});
// Optionally: Move camera to property location
mapController?.animateCamera(
CameraUpdate.newLatLngZoom(
LatLng(newProperty.latitude, newProperty.longitude),
14.0,
),
);
}
}
// void _addProperty() async {
// showDialog(
// context: context,
// builder: (BuildContext context) {
// return CustomDialogBox(
// title: "Show A Property",
// descriptions: "Let's show a new property!",
// text: "Yes",
// );
// },
// );
// }
void _clearMarkers() {
setState(() {
markers.clear();
@@ -125,6 +202,13 @@ class SignMapScreenState extends State<SignMapScreen> {
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: _addProperty,
backgroundColor: Colors.green,
tooltip: 'Add new property',
child: Icon(Icons.add_home),
),
SizedBox(height: 12),
FloatingActionButton(
onPressed: _addMarker,
tooltip: 'Drop Pin',

15
lib/models/property.dart Normal file
View File

@@ -0,0 +1,15 @@
import 'package:google_maps_flutter/google_maps_flutter.dart';
class Property {
final String address;
final double latitude;
final double longitude;
final List<LatLng> signLocations;
Property({
required this.address,
required this.latitude,
required this.longitude,
required this.signLocations,
});
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
Widget buildFirstPage(void Function() nextPage) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Let's add a new property"),
const SizedBox(height: 20),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [TextButton(onPressed: nextPage, child: const Text("Next"))],
),
],
);
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
Widget buildSecondPage(
TextEditingController addressController,
void Function() nextPage,
) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: addressController,
decoration: const InputDecoration(labelText: 'Property Address'),
),
const SizedBox(height: 20),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [TextButton(onPressed: nextPage, child: const Text("Next"))],
),
],
);
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
Widget buildSummaryPage(
TextEditingController addressController,
TextEditingController signsController,
String? selectedRadius,
void Function() nextPage,
) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Summary", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
Text("Address: ${addressController.text}"),
Text("Signs: ${signsController.text}"),
Text("Radius: ${selectedRadius ?? 'Not selected'}"),
const SizedBox(height: 20),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(onPressed: nextPage, child: const Text("Finish")),
],
),
],
);
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
Widget buildThirdPage(
TextEditingController signsController,
void Function() nextPage,
DropdownButtonFormField<String> radiusField,
) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: signsController,
decoration: const InputDecoration(labelText: 'Number of Signs'),
keyboardType: TextInputType.number,
),
const SizedBox(height: 10),
radiusField,
const SizedBox(height: 20),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [TextButton(onPressed: nextPage, child: const Text("Next"))],
),
],
);
}

View File

@@ -0,0 +1,88 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_google_places_sdk/flutter_google_places_sdk.dart';
import 'package:wheres_my_sign/common/functions.dart';
class GooglePlacesHelper {
final FlutterGooglePlacesSdk _places;
bool _isHandlingSelection = false;
GooglePlacesHelper(String apiKey) : _places = FlutterGooglePlacesSdk(apiKey);
void attachAddressListener({
required TextEditingController controller,
required FocusNode focusNode,
required void Function(List<AutocompletePrediction>) onPredictions,
required VoidCallback onOverlayHide,
required Debouncer debouncer,
}) {
controller.addListener(() {
_onAddressChanged(
controller: controller,
debouncer: debouncer,
onPredictions: onPredictions,
onOverlayHide: onOverlayHide,
);
});
focusNode.addListener(() {
if (!focusNode.hasFocus) {
Future.delayed(const Duration(milliseconds: 100), onOverlayHide);
}
});
}
Future<void> _onAddressChanged({
required TextEditingController controller,
required Debouncer debouncer,
required void Function(List<AutocompletePrediction>) onPredictions,
required VoidCallback onOverlayHide,
}) async {
if (_isHandlingSelection || controller.text.length < 3) return;
debouncer.run(() async {
try {
final result = await _places.findAutocompletePredictions(
controller.text,
countries: ['us'],
);
if (result.predictions.isNotEmpty) {
onPredictions(result.predictions);
} else {
onOverlayHide();
}
} catch (e) {
debugPrint('Prediction error: $e');
onOverlayHide();
}
});
}
Future<void> handlePlaceSelection({
required AutocompletePrediction prediction,
required TextEditingController addressController,
required void Function(double? lat, double? lng) onLatLngRetrieved,
required VoidCallback onOverlayHide,
}) async {
try {
_isHandlingSelection = true;
onOverlayHide();
final result = await _places.fetchPlace(
prediction.placeId,
fields: [PlaceField.Address, PlaceField.Location],
);
final place = result.place;
if (place != null) {
addressController.text = place.address ?? prediction.fullText;
onLatLngRetrieved(place.latLng?.lat, place.latLng?.lng);
}
} catch (e) {
debugPrint('Place fetch error: $e');
} finally {
_isHandlingSelection = false;
}
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:wheres_my_sign/common/constants.dart';
class CustomDialogBox extends StatefulWidget {
final String title, descriptions, text;
final Image? img;
const CustomDialogBox({
super.key,
required this.title,
required this.descriptions,
required this.text,
this.img,
});
@override
CustomDialogBoxState createState() => CustomDialogBoxState();
}
class CustomDialogBoxState extends State<CustomDialogBox> {
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Constants.padding),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: contentBox(context),
);
}
contentBox(context) {
return Stack(
children: <Widget>[
Container(
padding: EdgeInsets.only(
left: Constants.padding,
top: Constants.avatarRadius + Constants.padding,
right: Constants.padding,
bottom: Constants.padding,
),
margin: EdgeInsets.only(top: Constants.avatarRadius),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Colors.white,
borderRadius: BorderRadius.circular(Constants.padding),
boxShadow: [
BoxShadow(
color: Colors.black,
offset: Offset(0, 10),
blurRadius: 10,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
widget.title,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
),
SizedBox(height: 15),
Text(
widget.descriptions,
style: TextStyle(fontSize: 14),
textAlign: TextAlign.center,
),
SizedBox(height: 22),
Align(
alignment: Alignment.bottomRight,
child: TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(widget.text, style: TextStyle(fontSize: 18)),
),
),
],
),
),
Positioned(
left: Constants.padding,
right: Constants.padding,
child: CircleAvatar(
backgroundColor: Colors.transparent,
radius: Constants.avatarRadius,
child: ClipRRect(
borderRadius: BorderRadius.all(
Radius.circular(Constants.avatarRadius),
),
child: Image.asset(
"assets/icons/house.png",
width: Constants.avatarRadius * 2,
height: Constants.avatarRadius * 2,
fit: BoxFit.cover,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,336 @@
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps;
import 'package:flutter_google_places_sdk/flutter_google_places_sdk.dart'
as places;
import 'package:wheres_my_sign/common/constants.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import 'dart:async';
import 'package:wheres_my_sign/models/property.dart';
class CustomDialogBox extends StatefulWidget {
final String title, descriptions, text;
final Image? img;
const CustomDialogBox({
super.key,
required this.title,
required this.descriptions,
required this.text,
this.img,
});
@override
CustomDialogBoxState createState() => CustomDialogBoxState();
}
class CustomDialogBoxState extends State<CustomDialogBox> {
final PageController _pageController = PageController();
int _currentPage = 0;
final _addressController = TextEditingController();
final _signsController = TextEditingController();
String? _selectedRadius;
final _addressFocusNode = FocusNode();
Timer? _debouncer;
List<places.AutocompletePrediction> _predictions = [];
final places.FlutterGooglePlacesSdk _places = places.FlutterGooglePlacesSdk(
'AIzaSyBLSUk32a5qGm3M_n9Yii66I7wi0rmA8oM',
);
late Property property; // Declare property to store final details
@override
void dispose() {
_addressController.dispose();
_signsController.dispose();
_debouncer?.cancel();
super.dispose();
}
void _onAddressChanged(String value) {
if (_debouncer?.isActive ?? false) _debouncer!.cancel();
_debouncer = Timer(const Duration(milliseconds: 300), () async {
if (value.isNotEmpty) {
final result = await _places.findAutocompletePredictions(value);
setState(() => _predictions = result.predictions);
} else {
setState(() => _predictions = []);
}
});
}
void _selectPrediction(places.AutocompletePrediction prediction) async {
// Fetch place details
final placeDetails = await _places.fetchPlace(
prediction.placeId,
fields: [
places.PlaceField.Location,
places.PlaceField.AddressComponents,
places.PlaceField.Name,
],
);
final place = placeDetails.place;
// Null checks for place and latLng
if (place != null && place.latLng != null) {
final latLng =
place
.latLng!; // Use the null check operator, since we've already verified it's not null
setState(() {
_addressController.text = prediction.fullText;
_predictions = [];
property = Property(
latitude: latLng.lat,
longitude: latLng.lng,
address:
place.name ?? 'Unknown', // Default to 'Unknown' if name is null
signLocations: [],
);
});
}
FocusScope.of(context).unfocus(); // Close keyboard
}
void _nextPage() {
if (_currentPage < 3) {
setState(() => _currentPage++);
_pageController.animateToPage(
_currentPage,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _previousPage() {
if (_currentPage > 0) {
setState(() => _currentPage--);
_pageController.animateToPage(
_currentPage,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
Widget _buildNavigationButtons() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (_currentPage > 0)
TextButton(onPressed: _previousPage, child: const Text("Previous")),
const Spacer(),
if (_currentPage < 3)
TextButton(onPressed: _nextPage, child: const Text("Next"))
else
TextButton(
onPressed: () {
Navigator.of(context).pop(property);
},
child: const Text("Show"),
),
],
);
}
Widget _buildPageContent() {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.25,
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
children: [
// Page 1
Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Let's show a property. Over the next few pages, we'll ask for the address, how many signs you want to place, and the radius you'd like to reach. The final page will confirm your input and show the best locations for your open house signs. Ready? Let's Begin!",
textAlign: TextAlign.justify,
),
const SizedBox(height: 20),
const Spacer(),
_buildNavigationButtons(),
],
),
// Page 2 - Address with Autocomplete
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _addressController,
focusNode: _addressFocusNode,
onChanged: _onAddressChanged,
decoration: const InputDecoration(
labelText: 'Property Address',
),
),
if (_predictions.isNotEmpty)
Container(
height: 150,
child: ListView.builder(
itemCount: _predictions.length,
itemBuilder: (context, index) {
final p = _predictions[index];
return ListTile(
title: Text(p.fullText),
onTap: () => _selectPrediction(p),
);
},
),
),
const SizedBox(height: 20),
Spacer(),
_buildNavigationButtons(),
],
),
// Page 3
Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _signsController,
decoration: const InputDecoration(labelText: 'Number of Signs'),
keyboardType: TextInputType.number,
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
value: _selectedRadius,
decoration: const InputDecoration(labelText: 'Search Radius'),
items:
['1 mile', '2 miles', '5 miles']
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (val) => setState(() => _selectedRadius = val),
),
const SizedBox(height: 20),
const Spacer(),
_buildNavigationButtons(),
],
),
// Page 4 - Summary
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Summary",
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Text("Address: ${_addressController.text}"),
Text("Signs: ${_signsController.text}"),
Text("Radius: ${_selectedRadius ?? 'Not selected'}"),
const SizedBox(height: 20),
const Spacer(),
_buildNavigationButtons(),
],
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Constants.padding),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: contentBox(context),
);
}
Widget contentBox(BuildContext context) {
return Stack(
children: <Widget>[
Container(
padding: EdgeInsets.only(
left: Constants.padding,
top: Constants.avatarRadius + Constants.padding,
right: Constants.padding,
bottom: Constants.padding,
),
margin: EdgeInsets.only(top: Constants.avatarRadius),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Colors.white,
borderRadius: BorderRadius.circular(Constants.padding),
boxShadow: const [
BoxShadow(
color: Colors.black,
offset: Offset(0, 10),
blurRadius: 10,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
widget.title,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 15),
Text(
widget.descriptions,
style: const TextStyle(fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 15),
SmoothPageIndicator(
controller: _pageController,
count: 4,
effect: WormEffect(
dotHeight: 10,
dotWidth: 10,
spacing: 8,
activeDotColor: Theme.of(context).primaryColor,
dotColor: Colors.grey.shade300,
),
),
const SizedBox(height: 15),
_buildPageContent(),
],
),
),
Positioned(
left: Constants.padding,
right: Constants.padding,
child: CircleAvatar(
backgroundColor: Colors.transparent,
radius: Constants.avatarRadius,
child: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(Constants.avatarRadius),
),
child:
widget.img ??
Image.asset(
"assets/icons/house.png",
width: Constants.avatarRadius * 2,
height: Constants.avatarRadius * 2,
fit: BoxFit.cover,
),
),
),
),
],
);
}
}