diff --git a/lib/daos/media_items_dao.dart b/lib/daos/media_items_dao.dart index 4e204f5..dc098a5 100644 --- a/lib/daos/media_items_dao.dart +++ b/lib/daos/media_items_dao.dart @@ -4,15 +4,21 @@ import 'package:sendtrain/database/database.dart'; part 'media_items_dao.g.dart'; @DriftAccessor(tables: [MediaItems]) -class MediaItemsDao extends DatabaseAccessor with _$MediaItemsDaoMixin { +class MediaItemsDao extends DatabaseAccessor + with _$MediaItemsDaoMixin { MediaItemsDao(super.db); + Future createOrUpdate(MediaItemsCompanion mediaItem) => + into(mediaItems).insertOnConflictUpdate(mediaItem); + Future> all() async { return await select(mediaItems).get(); } Future find(int id) async { - return await (select(mediaItems)..where((mediaItem) => mediaItem.id.equals(id) )).getSingle(); + return await (select(mediaItems) + ..where((mediaItem) => mediaItem.id.equals(id))) + .getSingle(); } Future> fromActivity(Activity activity) async { @@ -24,13 +30,11 @@ class MediaItemsDao extends DatabaseAccessor with _$MediaItemsDaoMi ), ], ) - ..where( - db.objectMediaItems.objectType.equals(ObjectType.activities.name)) + ..where(db.objectMediaItems.objectType.equals(ObjectType.activities.name)) ..where(db.objectMediaItems.objectId.equals(activity.id)); - final mediaItems = (await result.get()) - .map((e) => e.readTable(db.mediaItems)) - .toList(); + final mediaItems = + (await result.get()).map((e) => e.readTable(db.mediaItems)).toList(); return mediaItems; } @@ -44,14 +48,31 @@ class MediaItemsDao extends DatabaseAccessor with _$MediaItemsDaoMi ), ], ) - ..where( - db.objectMediaItems.objectType.equals(ObjectType.sessions.name)) + ..where(db.objectMediaItems.objectType.equals(ObjectType.sessions.name)) ..where(db.objectMediaItems.objectId.equals(session.id)); - final mediaItems = (await result.get()) - .map((e) => e.readTable(db.mediaItems)) - .toList(); + final mediaItems = + (await result.get()).map((e) => e.readTable(db.mediaItems)).toList(); return mediaItems; } + + Stream> watchSessionMediaItems(int id) { + final query = select(db.objectMediaItems).join( + [ + innerJoin( + db.mediaItems, + db.mediaItems.id.equalsExp(db.objectMediaItems.mediaId), + ), + ], + ) + ..where(db.objectMediaItems.objectType.equals(ObjectType.sessions.name)) + ..where(db.objectMediaItems.objectId.equals(id)); + + return query.watch().map((rows) { + final mediaItems = (rows).map((e) => e.readTable(db.mediaItems)).toList(); + + return mediaItems; + }); + } } diff --git a/lib/daos/object_media_items_dao.dart b/lib/daos/object_media_items_dao.dart new file mode 100644 index 0000000..f82a38b --- /dev/null +++ b/lib/daos/object_media_items_dao.dart @@ -0,0 +1,19 @@ +import 'package:drift/drift.dart'; +import 'package:sendtrain/database/database.dart'; + +part 'object_media_items_dao.g.dart'; + +@DriftAccessor(tables: [ObjectMediaItems]) +class ObjectMediaItemsDao extends DatabaseAccessor with _$ObjectMediaItemsDaoMixin { + ObjectMediaItemsDao(super.db); + + Future createOrUpdate(ObjectMediaItemsCompanion objectMediaItem) => into(objectMediaItems).insertOnConflictUpdate(objectMediaItem); + + Future> all() async { + return await select(objectMediaItems).get(); + } + + Future find(int id) async { + return await (select(objectMediaItems)..where((objectMediaItem) => objectMediaItem.id.equals(id) )).getSingle(); + } +} diff --git a/lib/daos/object_media_items_dao.g.dart b/lib/daos/object_media_items_dao.g.dart new file mode 100644 index 0000000..43f26d8 --- /dev/null +++ b/lib/daos/object_media_items_dao.g.dart @@ -0,0 +1,10 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'object_media_items_dao.dart'; + +// ignore_for_file: type=lint +mixin _$ObjectMediaItemsDaoMixin on DatabaseAccessor { + $MediaItemsTable get mediaItems => attachedDatabase.mediaItems; + $ObjectMediaItemsTable get objectMediaItems => + attachedDatabase.objectMediaItems; +} diff --git a/lib/database/database.dart b/lib/database/database.dart index ef42790..18d74f5 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -4,6 +4,7 @@ import 'package:sendtrain/daos/actions_dao.dart'; import 'package:sendtrain/daos/activities_dao.dart'; import 'package:sendtrain/daos/activity_actions_dao.dart'; import 'package:sendtrain/daos/media_items_dao.dart'; +import 'package:sendtrain/daos/object_media_items_dao.dart'; import 'package:sendtrain/daos/session_activities_dao.dart'; import 'package:sendtrain/daos/sessions_dao.dart'; import 'package:sendtrain/database/seed.dart'; @@ -22,6 +23,7 @@ part 'database.g.dart'; SessionsDao, ActivitiesDao, MediaItemsDao, + ObjectMediaItemsDao, SessionActivitiesDao, ActivityActionsDao, ActionsDao diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index 55a80e2..8ff36d1 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -2398,14 +2398,6 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $MediaItemsTable mediaItems = $MediaItemsTable(this); late final $ObjectMediaItemsTable objectMediaItems = $ObjectMediaItemsTable(this); - late final SessionsDao sessionsDao = SessionsDao(this as AppDatabase); - late final ActivitiesDao activitiesDao = ActivitiesDao(this as AppDatabase); - late final MediaItemsDao mediaItemsDao = MediaItemsDao(this as AppDatabase); - late final SessionActivitiesDao sessionActivitiesDao = - SessionActivitiesDao(this as AppDatabase); - late final ActivityActionsDao activityActionsDao = - ActivityActionsDao(this as AppDatabase); - late final ActionsDao actionsDao = ActionsDao(this as AppDatabase); @override Iterable> get allTables => allSchemaEntities.whereType>(); diff --git a/lib/services/apis/google_places_service.dart b/lib/services/apis/google_places_service.dart new file mode 100644 index 0000000..9686922 --- /dev/null +++ b/lib/services/apis/google_places_service.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:uuid/uuid.dart'; + +class GooglePlacesService { + final sessionToken = Uuid().v4(); + final apiKey = "AIzaSyBCjMCEAyyNVpsnVYvZj6VL1mmB98Vd6AE"; + final client = Client(); + + void finish() { + client.close(); + } + + Future?> fetchSuggestions(String input, String lang) async { + var headers = { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + "Access-Control-Allow-Origin": "*", + 'X-Goog-FieldMask': + 'places.displayName,places.id,places.formattedAddress,places.photos' + }; + var request = Request('POST', + Uri.parse('https://places.googleapis.com/v1/places:searchText')); + request.body = json.encode({"textQuery": input}); + request.headers.addAll(headers); + + StreamedResponse response = await request.send(); + + if (response.statusCode == 200) { + final result = json.decode(await response.stream.bytesToString()); + + if (result.isNotEmpty) { + return result['places'] + .map((p) => Suggestion( + placeId: p['id'], + description: p['displayName']['text'], + address: p['formattedAddress'], + imageReferences: p['photos'])) + .toList(); + } else { + return null; + } + } else { + throw Exception(response.reasonPhrase); + } + } + + Future fetchPhoto(String name) async { + var headers = { + "Access-Control-Allow-Origin": "*", + }; + + var request = Request('GET', + Uri.parse('https://places.googleapis.com/v1/$name/media?key=$apiKey&maxWidthPx=800&skipHttpRedirect=true') + ); + request.headers.addAll(headers); + + StreamedResponse response = await request.send(); + + if (response.statusCode == 200) { + final result = json.decode(await response.stream.bytesToString()); + + if (result.isNotEmpty) { + return result; + } else { + return null; + } + } else { + throw Exception(response.reasonPhrase); + } + } +} + +class Suggestion { + final String placeId; + final String description; + final String address; + final List? imageReferences; + + Suggestion( + {required this.placeId, + required this.description, + required this.address, + this.imageReferences}); + + @override + String toString() { + return 'Suggestion(description: $description, placeId: $placeId)'; + } + + Map toJson() => { + 'placeId': placeId, + 'name': description, + 'address': address, + 'imageReferences': imageReferences + }; +} diff --git a/lib/services/functional/debouncer.dart b/lib/services/functional/debouncer.dart new file mode 100644 index 0000000..eb82180 --- /dev/null +++ b/lib/services/functional/debouncer.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +typedef _Debounceable = Future Function(T parameter); + +class Debouncer { + Debouncer(this._duration, this._callback); + + final Duration _duration; + final dynamic _callback; + late final _Debounceable _debouncedSearch = _debounce(_callback); + + /// Returns a new function that is a debounced version of the given function. + /// + /// This means that the original function will be called only after no calls + /// have been made for the given Duration. + _Debounceable _debounce(_Debounceable function) { + _DebounceTimer? debounceTimer; + + return (T parameter) async { + if (debounceTimer != null && !debounceTimer!.isCompleted) { + debounceTimer!.cancel(); + } + debounceTimer = _DebounceTimer(_duration); + try { + await debounceTimer!.future; + } on _CancelException { + return null; + } + return function(parameter); + }; + } + + process(data) { + return _debouncedSearch(data); + } +} + +// A wrapper around Timer used for debouncing. +class _DebounceTimer { + final Duration debounceDuration; + + _DebounceTimer( + this.debounceDuration + ) { + _timer = Timer(debounceDuration, _onComplete); + } + + late final Timer _timer; + final Completer _completer = Completer(); + + void _onComplete() { + _completer.complete(); + } + + Future get future => _completer.future; + + bool get isCompleted => _completer.isCompleted; + + void cancel() { + _timer.cancel(); + _completer.completeError(const _CancelException()); + } +} + +// An exception indicating that the timer was canceled. +class _CancelException implements Exception { + const _CancelException(); +} \ No newline at end of file diff --git a/lib/widgets/generic/elements/form_search_input.dart b/lib/widgets/generic/elements/form_search_input.dart index b8f9f38..090d257 100644 --- a/lib/widgets/generic/elements/form_search_input.dart +++ b/lib/widgets/generic/elements/form_search_input.dart @@ -1,34 +1,17 @@ import 'dart:async'; -import 'dart:convert'; +import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:http/http.dart'; +import 'package:sendtrain/services/apis/google_places_service.dart'; +import 'package:sendtrain/services/functional/debouncer.dart'; import 'package:sendtrain/widgets/generic/elements/form_text_input.dart'; -import 'package:uuid/uuid.dart'; - -class Suggestion { - final String placeId; - final String description; - final String address; - - Suggestion(this.placeId, this.description, this.address); - - @override - String toString() { - return 'Suggestion(description: $description, placeId: $placeId)'; - } - - Map toJson() => { - 'placeId': placeId, - 'name': description, - 'address': address, - }; -} class FormSearchInput extends StatefulWidget { - const FormSearchInput({super.key, required this.sessionController}); + const FormSearchInput( + {super.key, required this.sessionController, this.optionalPayload}); final TextEditingController sessionController; + final dynamic optionalPayload; @override State createState() => _FormSearchInputState(); @@ -37,21 +20,22 @@ class FormSearchInput extends StatefulWidget { class _FormSearchInputState extends State { String? _currentQuery; + final service = GooglePlacesService(); + // The most recent suggestions received from the API. late Iterable _lastOptions = []; - - late final _Debounceable?, String> _debouncedSearch; + late final Debouncer debouncer; // Calls the "remote" API to search with the given query. Returns null when // the call has been made obsolete. - Future?> _search(String query) async { + Future?> _search(String query) async { _currentQuery = query; // In a real application, there should be some error handling here. // final Iterable options = await _FakeAPI.search(_currentQuery!); if (query.isNotEmpty) { final List? suggestions = - await fetchSuggestions(_currentQuery!, 'en'); + await service.fetchSuggestions(_currentQuery!, 'en'); // If another search happened after this one, throw away these options. if (_currentQuery != query) { @@ -59,50 +43,16 @@ class _FormSearchInputState extends State { } _currentQuery = null; - return suggestions?.map((suggestion) => json.encode(suggestion)); + return suggestions?.map((suggestion) => suggestion); } else { return null; } } - final sessionToken = Uuid().v4(); - final apiKey = "AIzaSyBCjMCEAyyNVpsnVYvZj6VL1mmB98Vd6AE"; - final client = Client(); - - Future?> fetchSuggestions(String input, String lang) async { - var headers = { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': apiKey, - "Access-Control-Allow-Origin": "*", - 'X-Goog-FieldMask': 'places.displayName,places.id,places.formattedAddress' - }; - var request = Request('POST', - Uri.parse('https://places.googleapis.com/v1/places:searchText')); - request.body = json.encode({"textQuery": input}); - request.headers.addAll(headers); - - StreamedResponse response = await request.send(); - - if (response.statusCode == 200) { - final result = json.decode(await response.stream.bytesToString()); - - if (result.isNotEmpty) { - return result['places'] - .map((p) => Suggestion( - p['id'], p['displayName']['text'], p['formattedAddress'])) - .toList(); - } else { - return null; - } - } else { - throw Exception(response.reasonPhrase); - } - } - @override void initState() { super.initState(); - _debouncedSearch = _debounce?, String>(_search); + debouncer = Debouncer(Duration(milliseconds: 50), _search); } @override @@ -114,28 +64,37 @@ class _FormSearchInputState extends State { title: 'Location (optional)', icon: Icon(Icons.search_rounded), maxLines: 2, + requiresValidation: false, onTap: () { controller.openView(); }); }, suggestionsBuilder: (BuildContext context, SearchController controller) async { - final List? options = - (await _debouncedSearch(controller.text))?.toList(); + final List? options = + (await debouncer.process(controller.text))?.toList(); if (options == null) { return _lastOptions; } _lastOptions = List.generate(options.length, (int index) { - final String item = options[index]; + final Suggestion item = options[index]; return ListTile( - title: Text(json.decode(item)['name']), - onTap: () { - if (item.isNotEmpty) { - widget.sessionController.text = json.decode(item)['name']; + title: Text(item.description), + onTap: () async { + // widget.optionalPayload = service.fetchPhoto(json.decode(item.image)); + if (item.imageReferences != null) { + // get a random photo item from the returned result + Map photo = item.imageReferences![ + Random().nextInt(item.imageReferences!.length)]; + + await service.fetchPhoto(photo['name']).then((photoMap) { + widget.optionalPayload.photoUri = photoMap['photoUri']; + }); } - client.close(); - controller.closeView(item); - // debugPrint('You just selected $item'); - // Navigator.of(context).pop(); + + widget.optionalPayload.address = item.address; + widget.sessionController.text = item.description; + service.finish(); + controller.closeView(item.description); }, ); }); @@ -144,55 +103,3 @@ class _FormSearchInputState extends State { }); } } - -const Duration debounceDuration = Duration(milliseconds: 500); -typedef _Debounceable = Future Function(T parameter); - -/// Returns a new function that is a debounced version of the given function. -/// -/// This means that the original function will be called only after no calls -/// have been made for the given Duration. -_Debounceable _debounce(_Debounceable function) { - _DebounceTimer? debounceTimer; - - return (T parameter) async { - if (debounceTimer != null && !debounceTimer!.isCompleted) { - debounceTimer!.cancel(); - } - debounceTimer = _DebounceTimer(); - try { - await debounceTimer!.future; - } on _CancelException { - return null; - } - return function(parameter); - }; -} - -// A wrapper around Timer used for debouncing. -class _DebounceTimer { - _DebounceTimer() { - _timer = Timer(debounceDuration, _onComplete); - } - - late final Timer _timer; - final Completer _completer = Completer(); - - void _onComplete() { - _completer.complete(); - } - - Future get future => _completer.future; - - bool get isCompleted => _completer.isCompleted; - - void cancel() { - _timer.cancel(); - _completer.completeError(const _CancelException()); - } -} - -// An exception indicating that the timer was canceled. -class _CancelException implements Exception { - const _CancelException(); -} diff --git a/lib/widgets/generic/elements/form_text_input.dart b/lib/widgets/generic/elements/form_text_input.dart index 6ad9b05..f7c4490 100644 --- a/lib/widgets/generic/elements/form_text_input.dart +++ b/lib/widgets/generic/elements/form_text_input.dart @@ -8,7 +8,8 @@ class FormTextInput extends StatelessWidget { this.icon, this.maxLines, this.minLines, - this.onTap}); + this.onTap, + this.requiresValidation=true}); final TextEditingController controller; final String title; @@ -16,6 +17,7 @@ class FormTextInput extends StatelessWidget { final int? minLines; final Icon? icon; final dynamic onTap; + final bool requiresValidation; @override Widget build(BuildContext context) { @@ -34,12 +36,14 @@ class FormTextInput extends StatelessWidget { labelText: title, ), validator: (String? value) { - if (value == null || value.isEmpty) { - return 'Please enter some text'; - } + if (requiresValidation == true) { + if (value == null || value.isEmpty) { + return 'Please enter some text'; + } - if (value.length < 3) { - return 'Please enter a minimum of 3 characters'; + if (value.length < 3) { + return 'Please enter a minimum of 3 characters'; + } } return null; }, diff --git a/lib/widgets/sessions/session_card.dart b/lib/widgets/sessions/session_card.dart index ca86728..30102ce 100644 --- a/lib/widgets/sessions/session_card.dart +++ b/lib/widgets/sessions/session_card.dart @@ -24,9 +24,9 @@ class _SessionCardState extends State { initializeDateFormatting('en'); - return FutureBuilder>( - future: MediaItemsDao(Provider.of(context)) - .fromSession(session), + return StreamBuilder>( + stream: MediaItemsDao(Provider.of(context)) + .watchSessionMediaItems(session.id), builder: (context, snapshot) { if (snapshot.hasData) { List mediaItems = snapshot.data!; diff --git a/lib/widgets/sessions/session_creator.dart b/lib/widgets/sessions/session_creator.dart index f5ea4ea..a1e1c38 100644 --- a/lib/widgets/sessions/session_creator.dart +++ b/lib/widgets/sessions/session_creator.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'dart:math'; import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:sendtrain/daos/media_items_dao.dart'; +import 'package:sendtrain/daos/object_media_items_dao.dart'; import 'package:sendtrain/daos/sessions_dao.dart'; import 'package:sendtrain/database/database.dart'; import 'package:sendtrain/widgets/builders/dialogs.dart'; @@ -21,6 +24,12 @@ class SessionCreator extends StatefulWidget { State createState() => _SessionCreatorState(); } +// used to pass the result of the found image back to current context... +class SessionPayload { + String? photoUri; + String? address; +} + class _SessionCreatorState extends State { final GlobalKey _formKey = GlobalKey(); @@ -33,14 +42,48 @@ class _SessionCreatorState extends State { 'media': TextEditingController(), }; - Future createSession(context) { - return SessionsDao(Provider.of(context, listen: false)) - .createOrUpdate(SessionsCompanion( - title: Value(sessionCreateController['name']!.text), - content: Value(sessionCreateController['content']!.text), - status: Value(SessionStatus.pending), - date: Value(DateTime.parse(sessionCreateController['date']!.text)), - address: Value(sessionCreateController['address']!.text))); + final SessionPayload sessionPayload = SessionPayload(); + + Future createSession(context) async { + Map payload = { + Symbol('title'): Value(sessionCreateController['name']!.text), + Symbol('content'): + Value(sessionCreateController['content']!.text), + Symbol('status'): Value(SessionStatus.pending), + Symbol('date'): Value( + DateTime.parse(sessionCreateController['date']!.text)), + }; + + // optional params + if (sessionCreateController['address']!.text.isNotEmpty) { + payload[Symbol('address')] = + Value(sessionCreateController['address']!.text); + } + + return await SessionsDao(Provider.of(context, listen: false)) + .createOrUpdate(Function.apply(SessionsCompanion.new, [], payload)); + } + + Future createSessionMedia(context, sessionId) async { + if (sessionPayload.photoUri != null) { + MediaItemsCompanion mediaItem = MediaItemsCompanion( + title: Value('Location Image'), + description: Value(sessionPayload.address!), + reference: Value(sessionPayload.photoUri!), + type: Value(MediaType.image)); + + return await MediaItemsDao( + Provider.of(context, listen: false)) + .createOrUpdate(mediaItem).then((id) async { + ObjectMediaItemsCompanion omi = ObjectMediaItemsCompanion( + objectId: Value(sessionId), + objectType: Value(ObjectType.sessions), + mediaId: Value(id), + ); + + await ObjectMediaItemsDao(Provider.of(context, listen: false)).createOrUpdate(omi); + }); + } } @override @@ -113,19 +156,23 @@ class _SessionCreatorState extends State { // } // })), FormSearchInput( - sessionController: sessionCreateController['address']!), + sessionController: sessionCreateController['address']!, + optionalPayload: sessionPayload), Row(mainAxisAlignment: MainAxisAlignment.end, children: [ Padding( padding: EdgeInsets.only(top: 10), child: FilledButton( - onPressed: () => { + onPressed: () async => { if (_formKey.currentState!.validate()) { - createSession(_formKey.currentContext) - .then((id) => { + await createSession(_formKey.currentContext) + .then((id) async => { + await createSessionMedia( + _formKey.currentContext, id), SessionsDao(db).find(id).then( (session) => showGenericDialog( - SessionView(session: session), + SessionView( + session: session), _formKey .currentContext!)), Navigator.pop(