Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 112 additions & 4 deletions lib/screens/meetings/create_meeting_screen.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../services/supabase_service.dart';
import '../../services/google_meet_service.dart';

class CreateMeetingScreen extends StatefulWidget {
const CreateMeetingScreen({super.key});
Expand All @@ -14,13 +15,15 @@ class _CreateMeetingScreenState extends State<CreateMeetingScreen> {
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _urlController = TextEditingController();
final _durationController =
TextEditingController(text: '60'); // Default to 60 minutes
final _durationController = TextEditingController(text: '60'); // Default to 60 minutes

final _supabaseService = SupabaseService();

final _googleMeetService = GoogleMeetService();

DateTime? _selectedDate;
TimeOfDay? _selectedTime;
bool _isLoading = false;
bool _isCreatingMeetLink = false;
bool _isGoogleMeetUrl = true;

@override
Expand All @@ -44,7 +47,86 @@ class _CreateMeetingScreenState extends State<CreateMeetingScreen> {
_isGoogleMeetUrl = _validateGoogleMeetUrl(url);
});
}

Future<void> _createGoogleMeetLink() async {
if (_selectedDate == null || _selectedTime == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select date and time first'),
backgroundColor: Colors.red,
),
);
return;
}

// Combine date and time
final meetingDateTime = DateTime(
_selectedDate!.year,
_selectedDate!.month,
_selectedDate!.day,
_selectedTime!.hour,
_selectedTime!.minute,
);

// Validate meeting is in the future
if (meetingDateTime.isBefore(DateTime.now())) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Meeting time must be in the future'),
backgroundColor: Colors.red,
),
);
return;
}

// Parse duration
int duration = 60;
try {
duration = int.parse(_durationController.text.trim());
if (duration <= 0) duration = 60;
} catch (e) {
// Default to 60 if parsing fails
duration = 60;
}

setState(() => _isCreatingMeetLink = true);

final link = await _googleMeetService.createMeetLink(
start: meetingDateTime,
durationMinutes: duration,
title: _titleController.text.trim().isNotEmpty
? _titleController.text.trim()
: 'Meeting',
description: _descriptionController.text.trim(),
);

if (!mounted) return;

setState(() => _isCreatingMeetLink = false);

if (link != null) {
_urlController.text = link;
_checkUrl(link);
Comment on lines +107 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against empty-string Meet link from the service.

As noted in the service review, createMeetLink can return "" instead of null. Even after fixing the service, a defensive check here prevents a broken link from silently populating the URL field.

Suggested fix
-    if (link != null) {
+    if (link != null && link.isNotEmpty) {
       _urlController.text = link;
       _checkUrl(link);
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (link != null) {
_urlController.text = link;
_checkUrl(link);
if (link != null && link.isNotEmpty) {
_urlController.text = link;
_checkUrl(link);
πŸ€– Prompt for AI Agents
In `@lib/screens/meetings/create_meeting_screen.dart` around lines 107 - 109, The
code currently treats a returned meet link `link` as valid when non-null, but
`createMeetLink` can return an empty string; update the guard in the block that
sets `_urlController.text` and calls `_checkUrl` to verify the link is both
non-null and non-empty (e.g., use `link != null && link.isNotEmpty`) before
assigning `_urlController.text = link` and invoking `_checkUrl(link)` so you
don’t populate the field with an empty string.

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Google Meet link created successfully'),
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Failed to create Google Meet link. Please sign in with Google.'),
backgroundColor: Colors.orange,
action: SnackBarAction(
label: 'Retry',
onPressed: () => _createGoogleMeetLink(),
),
),
);
}
}

Future<void> _createMeeting() async {
if (!_formKey.currentState!.validate()) return;

Expand Down Expand Up @@ -372,7 +454,33 @@ class _CreateMeetingScreenState extends State<CreateMeetingScreen> {
_checkUrl(value);
},
),


// Create Google Meet Link button
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isCreatingMeetLink ? null : _createGoogleMeetLink,
icon: _isCreatingMeetLink
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.video_call),
label: Text(
_isCreatingMeetLink ? 'Creating Meet Link...' : 'Create Google Meet Link',
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade800,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
Comment on lines +458 to +482

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Button UI and loading state look correct.

The button is properly disabled during link creation, shows a CircularProgressIndicator in-flight, and the label updates to indicate progress. This aligns with the issue requirement for loading states.

One minor UX note: the "Create Meeting" button (Line 512) is not disabled while _isCreatingMeetLink is true, so a user could submit the form mid-generation. Consider disabling it as well.

Suggested fix for the Create Meeting button
   SizedBox(
     width: double.infinity,
     child: ElevatedButton(
-      onPressed: _createMeeting,
+      onPressed: _isCreatingMeetLink ? null : _createMeeting,
πŸ€– Prompt for AI Agents
In `@lib/screens/meetings/create_meeting_screen.dart` around lines 470 - 494, The
"Create Meeting" button should be disabled while a Google Meet link is being
generated; update the Create Meeting button's onPressed handler (the widget
rendering the "Create Meeting" action in create_meeting_screen.dart) to check
_isCreatingMeetLink and set onPressed to null when true (same pattern used in
the Create Google Meet Link button), ensuring the button is non-interactive
during link creation and preventing form submission mid-generation; keep any
existing visual/loading behavior intact.


// Warning message for non-Google Meet URLs
if (_urlController.text.isNotEmpty && !_isGoogleMeetUrl)
Padding(
Expand Down
89 changes: 89 additions & 0 deletions lib/services/google_meet_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis/calendar/v3.dart' as cal;
import 'package:googleapis_auth/auth_io.dart';
import 'package:http/http.dart' as http;

class GoogleMeetService {
final GoogleSignIn _googleSignIn = GoogleSignIn(
scopes: [
cal.CalendarApi.calendarScope,
cal.CalendarApi.calendarEventsScope,
],
);

Future<String?> createMeetLink({
required DateTime start,
required int durationMinutes,
required String title,
String? description,
}) async {
try {
// Sign in with Google
final account = await _googleSignIn.signIn();
if (account == null) return null;

// Get authentication
final authentication = await account.authentication;
final accessToken = authentication.accessToken;

if (accessToken == null) {
print('No access token available');
return null;
}

// Create authenticated client using authenticatedClient function (FIXED)
final credentials = AccessCredentials(
AccessToken('Bearer', accessToken, DateTime.now().add(const Duration(hours: 1)).toUtc()),
Comment on lines +35 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Hardcoded token expiry is unreliable.

The actual access token lifespan is determined by Google's auth server and may differ from the assumed 1-hour window. google_sign_in does not directly expose the real expiry. If the token is already partially expired by the time this code runs, the AccessCredentials object will carry a stale estimate. This is unlikely to cause issues for a single short-lived API call, but be aware this is a best-guess.

πŸ€– Prompt for AI Agents
In `@lib/services/google_meet_service.dart` around lines 35 - 36, The code
currently hardcodes a 1-hour expiry when constructing
AccessCredentials/AccessToken; instead, obtain the real expiry if available from
the GoogleSignInAuthentication response (e.g., an expiresIn field) and use that
to compute the DateTime, and if no expiry is exposed fall back to a conservative
short TTL (e.g., a few minutes) or force a fresh token request; update the
AccessCredentials/AccessToken creation to use
DateTime.now().toUtc().add(Duration(seconds: expiresIn)) when expiresIn is
present (and use a short fallback when absent) so the token lifetime is not
incorrectly assumed.

null, // No refresh token needed for one-time use
_googleSignIn.scopes,
);

final authClient = authenticatedClient(http.Client(), credentials);

try {
final calendarApi = cal.CalendarApi(authClient);

final event = cal.Event(
summary: title,
description: description,
start: cal.EventDateTime(dateTime: start.toUtc()),
end: cal.EventDateTime(
dateTime: start.add(Duration(minutes: durationMinutes)).toUtc(),
),
conferenceData: cal.ConferenceData(
createRequest: cal.CreateConferenceRequest(
requestId: DateTime.now().millisecondsSinceEpoch.toString(),
conferenceSolutionKey: cal.ConferenceSolutionKey(
type: 'hangoutsMeet',
),
),
),
);

final createdEvent = await calendarApi.events.insert(
event,
'primary',
conferenceDataVersion: 1,
);

// Return the meet link
return createdEvent.hangoutLink ??
createdEvent.conferenceData?.entryPoints
?.firstWhere(
(e) => e.entryPointType == 'video',
orElse: () => cal.EntryPoint(uri: ''),
)
.uri;
Comment on lines +69 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fallback can return an empty string instead of null, breaking the caller's null-check.

When hangoutLink is null and entryPoints has no video entry, orElse returns EntryPoint(uri: ''). The caller (_createGoogleMeetLink) checks if (link != null) β€” an empty string passes that check, so the URL field gets set to "" and a "success" snackbar is shown with no usable link.

Suggested fix
-        return createdEvent.hangoutLink ??
-            createdEvent.conferenceData?.entryPoints
-                ?.firstWhere(
-                  (e) => e.entryPointType == 'video',
-                  orElse: () => cal.EntryPoint(uri: ''),
-                )
-                .uri;
+        final link = createdEvent.hangoutLink ??
+            createdEvent.conferenceData?.entryPoints
+                ?.firstWhere(
+                  (e) => e.entryPointType == 'video',
+                  orElse: () => cal.EntryPoint(),
+                )
+                .uri;
+        return (link != null && link.isNotEmpty) ? link : null;
πŸ€– Prompt for AI Agents
In `@lib/services/google_meet_service.dart` around lines 69 - 76, The current
return expression can yield an empty string (EntryPoint(uri: '')) which is
treated as non-null by the caller (_createGoogleMeetLink) and causes a
false-success; change the fallback so that when there is no hangoutLink and no
video entry in createdEvent.conferenceData?.entryPoints you return null instead
of an EntryPoint with an empty uri. Locate the expression using
createdEvent.hangoutLink and the firstWhere(orElse: () => cal.EntryPoint(uri:
'')) and replace the orElse/uri usage with logic that returns null when no video
entry exists (or check for existence before returning .uri) so callers see a
true null and can handle the missing link correctly.

} finally {
authClient.close();
}
} catch (e) {
print('Error creating Google Meet link: $e');
return null;
}
}

Future<void> signOut() async {
await _googleSignIn.signOut();
}
}
4 changes: 4 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ dependencies:

speech_to_text: ^7.3.0

google_sign_in: ^6.2.1
googleapis: ^13.1.0
googleapis_auth: ^1.4.1

dev_dependencies:
flutter_test:
sdk: flutter
Expand Down