Skip to content
Open
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
111 changes: 110 additions & 1 deletion 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 @@ -15,11 +16,14 @@ class _CreateMeetingScreenState extends State<CreateMeetingScreen> {
final _descriptionController = TextEditingController();
final _urlController = TextEditingController();
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,6 +48,85 @@ class _CreateMeetingScreenState extends State<CreateMeetingScreen> {
});
}

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
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 @@ -384,6 +467,32 @@ class _CreateMeetingScreenState extends State<CreateMeetingScreen> {
},
),

// 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 +470 to +494
Copy link
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 Expand Up @@ -417,4 +526,4 @@ class _CreateMeetingScreenState extends State<CreateMeetingScreen> {
),
);
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for test files and GoogleMeetService references
echo "=== Searching for test files with Google Meet related patterns ==="
find . -path './test*' -name '*.dart' -type f | head -50

echo -e "\n=== Searching for GoogleMeetService references in test files ==="
rg -i 'googlemeet|google_meet|createmeetlink' --type=dart -g '*test*' --max-count=20

echo -e "\n=== Checking if GoogleMeetService exists in lib ==="
rg -l 'class GoogleMeetService|GoogleMeetService' --type=dart -g '!test'

echo -e "\n=== Looking for test directory structure ==="
find . -type d -name test | head -20

Repository: AOSSIE-Org/Ell-ena

Length of output: 410


🏁 Script executed:

#!/bin/bash
# Search for any meet-related test files
echo "=== Looking for meet-related test files ==="
fd -e dart . test -i meet

echo -e "\n=== Checking all test files ==="
find . -path '*/test/*' -name '*test.dart' -o -name '*_test.dart' | wc -l

echo -e "\n=== Searching for GoogleMeetService in all dart files ==="
rg 'GoogleMeetService' --type=dart

Repository: AOSSIE-Org/Ell-ena

Length of output: 451


🏁 Script executed:

#!/bin/bash
echo "=== Contents of the only test file ==="
cat ./test/widget_test.dart

Repository: AOSSIE-Org/Ell-ena

Length of output: 1187


Add unit and widget tests for Google Meet functionality.

The codebase currently has only a basic counter smoke test. Tests for GoogleMeetService (mocking Calendar API interactions) and widget tests verifying button states and snackbar feedback for the _createGoogleMeetLink flow are missing and required per the PR objectives.

πŸ€– Prompt for AI Agents
In `@lib/screens/meetings/create_meeting_screen.dart` at line 529, Add unit tests
for GoogleMeetService and widget tests for the CreateMeetingScreen's
_createGoogleMeetLink flow: write unit tests that mock Calendar API interactions
used by GoogleMeetService (mock successful createConference and error paths) and
assert returned meeting link or thrown errors; write widget tests that pump
CreateMeetingScreen (or the widget containing _createGoogleMeetLink), inject a
mocked GoogleMeetService, simulate button taps to trigger the create flow, and
verify button enabled/disabled states and SnackBar messages for success and
failure cases. Use the service class name GoogleMeetService and the private flow
method name _createGoogleMeetLink (or the public handler that calls it) to
locate code, and employ mocking libraries (mockito or mocktail) and flutter_test
widget testers to assert UI feedback and interactions.

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
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
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 @@ -65,6 +65,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