diff --git a/i18n/app_en.arb b/i18n/app_en.arb index 98ef048f..ed8326bc 100644 --- a/i18n/app_en.arb +++ b/i18n/app_en.arb @@ -2,6 +2,7 @@ "@@locale": "en", "aboutYou": "About You", "acceptGuidelines": "Accept Guidelines", + "addMoreTime": "Add more time", "addProfilePicture": "Add a profile picture", "admin": "Admin", "ageMissing": "Age verification", @@ -112,6 +113,7 @@ "endDateRequired": "End date is required", "endDateMustBeAfterStartDate": "End date must be after start date", "ending": "ending", + "endNow": "End Now", "endSession": "End Circle", "endSessionPrompt": "End Circle?", "endSessionPromptMessage": "Are you sure you want to end this session?", @@ -204,6 +206,7 @@ "introScreenTitle4": "Let's Begin!", "introScreenMessage4": "Please find a quiet place where you are unlikely to be distracted.\\\nAlways speak from your own **experience**, try to not offer advice.\\\nIt helps to disable all notifications and put everything on silent.\\\n**Enjoy** this rare opportunity to be heard without interruption.", "instantSession": "Instant Session", + "invalidNumber": "Invalid number", "joinCircle": "Join Circle", "joinCircleMessage": "Welcome! Before you enter the waiting room, please check that your audio and video are working as intended.", "joiningCircle": "Joining Circle", @@ -231,6 +234,7 @@ "mins": {} } }, + "modifyTime": "Modify Time", "month": "Month", "monthly": "Monthly", "months": "Months", diff --git a/lib/app/circle/components/circle_live_video_session.dart b/lib/app/circle/components/circle_live_video_session.dart index c9abb955..b2991cfe 100644 --- a/lib/app/circle/components/circle_live_video_session.dart +++ b/lib/app/circle/components/circle_live_video_session.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:keybinder/keybinder.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:slide_to_act/slide_to_act.dart'; +import 'package:totem/app/circle/components/circle_session_timer.dart'; import 'package:totem/app/circle/index.dart'; import 'package:totem/components/widgets/index.dart'; import 'package:totem/models/index.dart'; @@ -104,10 +105,7 @@ class _CircleLiveVideoSessionState ), ), ), - if (activeSession.expiresOn != null) ...[ - _countdownTimer(activeSession), - const SizedBox(width: 40) - ], + const CircleSessionTimer(), ], ), const SizedBox(height: 10), @@ -194,53 +192,83 @@ class _CircleLiveVideoSessionState return Container(); } - Widget _countdownTimer(ActiveSession activeSession) { - final t = AppLocalizations.of(context)!; - final themeData = Theme.of(context); - final themeColors = themeData.themeColors; + // List _countdownTimer(ActiveSession activeSession) { + // final t = AppLocalizations.of(context)!; + // final themeData = Theme.of(context); + // final themeColors = themeData.themeColors; - SessionParticipant? participant = activeSession.me(); - if (participant != null) { - return CountdownTimer( - startTime: activeSession.startedOn!, - endTime: activeSession.expiresOn!, - defaultState: CountdownState( - displayValue: participant.role == Role.keeper, - displayFormat: CountdownDisplayFormat.hoursAndMinutes, - color: themeColors.primary, - backgroundColor: themeColors.secondaryText, - valueLabel: t.remaining, - ), - stateTransitions: [ - CountdownState( - minutesRemaining: 5, - displayValue: true, - displayFormat: CountdownDisplayFormat.minutes, - color: themeColors.reversedText, - valueLabel: t.endsIn, - ), - CountdownState( - minutesRemaining: 0, - displayValue: true, - displayFormat: CountdownDisplayFormat.override, - color: themeColors.alertBackground, - backgroundColor: themeColors.alertBackground, - valueLabel: t.ending, - valueOverride: t.now, - ), - CountdownState( - minutesRemaining: -1, - displayValue: true, - displayFormat: CountdownDisplayFormat.hoursAndMinutes, - color: themeColors.alertBackground, - valueLabel: t.overtime, - ), - ], - ); - } else { - return Container(); - } - } + // SessionParticipant? participant = activeSession.me(); + // if (participant != null) { + // return [ + // CountdownTimer( + // startTime: activeSession.startedOn!, + // endTime: activeSession.expiresOn!, + // defaultState: CountdownState( + // displayValue: participant.role == Role.keeper, + // displayFormat: CountdownDisplayFormat.hoursAndMinutes, + // color: themeColors.primary, + // backgroundColor: themeColors.secondaryText, + // valueLabel: t.remaining, + // ), + // stateTransitions: [ + // CountdownState( + // minutesRemaining: 5, + // displayValue: true, + // displayFormat: CountdownDisplayFormat.minutes, + // color: themeColors.reversedText, + // valueLabel: t.endsIn, + // ), + // CountdownState( + // minutesRemaining: 0, + // displayValue: true, + // displayFormat: CountdownDisplayFormat.override, + // color: themeColors.alertBackground, + // backgroundColor: themeColors.alertBackground, + // valueLabel: t.ending, + // valueOverride: t.now, + // ), + // CountdownState( + // minutesRemaining: -1, + // displayValue: true, + // displayFormat: CountdownDisplayFormat.hoursAndMinutes, + // color: themeColors.alertBackground, + // valueLabel: t.overtime, + // ), + // ], + // ), + // if (participant.role == Role.keeper) ...[ + // const SizedBox( + // width: 10, + // ), + // PopupMenuButton( + // itemBuilder: (context) => [ + // if (DateTime.now().compareTo(activeSession.expiresOn!) <= 0) + // PopupMenuItem( + // value: 0, + // child: Text(t.endSession), + // ), + // if (DateTime.now().compareTo(activeSession.expiresOn!) > 0) + // PopupMenuItem( + // value: 1, + // child: Text(t.modifyTime), + // ), + // ], + // onSelected: (value) { + // if (value == 0) { + // _endSession(context); + // } else if (value == 1) { + // _modifyTime(context); + // } + // }, + // }, + // ), + // ], + // const SizedBox(width: 40) + // ]; + // } else { + // return []; + // } + // } Widget _speakerUserView(BuildContext context, {required ActiveSession activeSession, required bool isPhoneLayout}) { diff --git a/lib/app/circle/components/circle_session_timer.dart b/lib/app/circle/components/circle_session_timer.dart new file mode 100644 index 00000000..830753c3 --- /dev/null +++ b/lib/app/circle/components/circle_session_timer.dart @@ -0,0 +1,253 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_spinbox/flutter_spinbox.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:totem/app/circle/index.dart'; +import 'package:totem/components/index.dart'; +import 'package:totem/models/index.dart'; +import 'package:totem/services/index.dart'; +import 'package:totem/theme/index.dart'; + +class CircleSessionTimer extends ConsumerWidget { + const CircleSessionTimer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeSession = ref.watch(activeSessionProvider); + final t = AppLocalizations.of(context)!; + final themeData = Theme.of(context); + final themeColors = themeData.themeColors; + SessionParticipant? participant = activeSession.me(); + + if (activeSession.expiresOn != null && participant != null) { + return Row( + children: [ + CountdownTimer( + startTime: activeSession.startedOn!, + endTime: activeSession.expiresOn!, + defaultState: CountdownState( + displayValue: participant.role == Role.keeper, + displayFormat: CountdownDisplayFormat.hoursAndMinutes, + color: themeColors.primary, + backgroundColor: themeColors.secondaryText, + valueLabel: t.remaining, + ), + stateTransitions: [ + CountdownState( + minutesRemaining: 5, + displayValue: true, + displayFormat: CountdownDisplayFormat.minutes, + color: themeColors.reversedText, + valueLabel: t.endsIn, + ), + CountdownState( + minutesRemaining: 0, + displayValue: true, + displayFormat: CountdownDisplayFormat.override, + color: themeColors.alertBackground, + backgroundColor: themeColors.alertBackground, + valueLabel: t.ending, + valueOverride: t.now, + ), + CountdownState( + minutesRemaining: -1, + displayValue: true, + displayFormat: CountdownDisplayFormat.hoursAndMinutes, + color: themeColors.alertBackground, + valueLabel: t.overtime, + ), + ], + ), + if (participant.role == Role.keeper) ...[ + const SizedBox( + width: 10, + ), + PopupMenuButton( + child: Icon( + LucideIcons.moreVertical, + color: themeColors.iconNext, + ), + itemBuilder: (context) => [ + if (DateTime.now().compareTo(activeSession.expiresOn!) <= 0) + PopupMenuItem( + value: 1, + child: Text(t.modifyTime), + ), + PopupMenuItem( + value: 2, + child: Text(t.endNow), + ), + ], + onSelected: (value) { + if (value == 1) { + Duration remainingTime = + activeSession.expiresOn!.difference(DateTime.now()); + _modifyTimeDialog(context, remainingTime.inMinutes, + activeSession.circle.maxMinutes); + } else if (value == 2) { + _endSessionPrompt(context, ref); + } + }, + ), + ], + const SizedBox(width: 40) + ], + ); + } else { + return Container(); + } + } + + void _endSessionPrompt(BuildContext context, WidgetRef ref) async { + FocusScope.of(context).unfocus(); + final t = AppLocalizations.of(context)!; + // set up the AlertDialog + AlertDialog alert = AlertDialog( + title: Text(t.endSessionPrompt), + content: Text(t.endSessionPromptMessage), + actions: [ + TextButton( + child: Text(t.endSession), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + TextButton( + child: Text(t.cancel), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ], + ); + // show the dialog + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return alert; + }, + ); + if (result) { + final commProvider = ref.read(communicationsProvider); + await commProvider.endSession(); + } + } + + void _modifyTimeDialog( + BuildContext context, int remainingMinutes, int maxMinutes) async { + return showDialog( + context: context, + barrierDismissible: true, + useRootNavigator: true, + barrierColor: Theme.of(context).themeColors.blurBackground, + builder: (_) => Material( + color: Colors.transparent, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5), + child: Center( + child: SingleChildScrollView( + child: DialogContainer( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: Theme.of(context).maxRenderWidth), + child: AddTimeDialog( + min: -1 * remainingMinutes.toDouble(), + max: maxMinutes.toDouble(), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class AddTimeDialog extends ConsumerStatefulWidget { + const AddTimeDialog({Key? key, required this.min, required this.max}) + : super(key: key); + final double min, max; + + @override + AddTimeDialogState createState() => AddTimeDialogState(); +} + +class AddTimeDialogState extends ConsumerState { + late double _minutes; + + @override + void initState() { + super.initState(); + _minutes = 0; + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context)!; + final themeData = Theme.of(context); + final themeColors = themeData.themeColors; + final textStyles = themeData.textStyles; + return Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(width: themeData.pageHorizontalPadding), + Expanded( + child: Text( + t.addMoreTime, + style: textStyles.headline2, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: Icon( + LucideIcons.x, + color: themeColors.primaryText, + ), + ), + const SizedBox(width: 8), + ], + ), + const SizedBox(height: 20), + SizedBox( + width: 150, + child: SpinBox( + keyboardType: const TextInputType.numberWithOptions(decimal: false), + min: widget.min, + max: widget.max, + value: _minutes, + onChanged: (value) { + setState(() { + _minutes = value; + }); + }, + ), + ), + const SizedBox(height: 40), + ThemedRaisedButton( + label: t.done, + onPressed: () { + _modifySession(); + Navigator.of(context).pop(); + }), + ], + ); + } + + void _modifySession() async { + var repo = ref.read(repositoryProvider); + int minutes = _minutes.floor(); + if (minutes != 0) { + await repo.addTimeToActiveSession(minutes: minutes); + } + } +} diff --git a/lib/components/widgets/countdown_timer.dart b/lib/components/widgets/countdown_timer.dart index 0f8b09f2..01891d17 100644 --- a/lib/components/widgets/countdown_timer.dart +++ b/lib/components/widgets/countdown_timer.dart @@ -123,6 +123,7 @@ class CountdownTimerState extends ConsumerState { void initState() { _currentState = CountdownState.from(widget.defaultState, null); _updateInitialValues(); + _displayValue = _currentState.displayValue ?? false; _timer = Timer.periodic(const Duration(seconds: 1), _updateTimerValue); super.initState(); } @@ -150,7 +151,6 @@ class CountdownTimerState extends ConsumerState { 59, ); _totalTime = _endTime.difference(widget.startTime); - _displayValue = _currentState.displayValue ?? false; _updateTimeAndState(); } diff --git a/lib/services/firebase_providers/firebase_session_provider.dart b/lib/services/firebase_providers/firebase_session_provider.dart index 4fbfff11..0b06654f 100644 --- a/lib/services/firebase_providers/firebase_session_provider.dart +++ b/lib/services/firebase_providers/firebase_session_provider.dart @@ -202,6 +202,29 @@ class FirebaseSessionProvider extends SessionProvider { } } + @override + Future addTimeToSession({required int minutes}) async { + if (_activeSession != null && _activeSession!.live) { + try { + HttpsCallable callable = + FirebaseFunctions.instance.httpsCallable('addMinutesToSession'); + final result = await callable({ + "circleId": _activeSession!.circle.id, + "minutes": minutes, + }); + debugPrint('completed addTimeToSnapSession($minutes) with result ${result.data}'); + } on FirebaseException catch (ex, stack) { + await reportError(ex, stack); + throw ServiceException( + code: ex.code, + reference: _activeSession!.circle.ref, + message: ex.message, + ); + } + } + return; + } + @override Future cancelPendingSession({required Session session}) async { if (session.state == SessionState.waiting) { diff --git a/lib/services/session_provider.dart b/lib/services/session_provider.dart index 8754d221..0950559e 100644 --- a/lib/services/session_provider.dart +++ b/lib/services/session_provider.dart @@ -16,6 +16,7 @@ abstract class SessionProvider extends ChangeNotifier { {required Circle circle, required String uid}); Future startActiveSession(); Future endActiveSession(); + Future addTimeToSession({required int minutes}); Future cancelPendingSession({required Session session}); void clear(); ActiveSession? get activeSession; diff --git a/lib/services/totem_repository.dart b/lib/services/totem_repository.dart index 1eb54dc6..2d69663e 100644 --- a/lib/services/totem_repository.dart +++ b/lib/services/totem_repository.dart @@ -134,6 +134,8 @@ class TotemRepository { _sessionProvider.updateActiveSession(sessionData); Future cancelPendingSession({required Circle circle}) => _sessionProvider.cancelPendingSession(session: circle.session); + Future addTimeToActiveSession({required int minutes}) => + _sessionProvider.addTimeToSession(minutes: minutes); // Analytics AnalyticsProvider get analyticsProvider { diff --git a/server/functions/src/common-types.ts b/server/functions/src/common-types.ts index 263e15f1..b5f7b348 100644 --- a/server/functions/src/common-types.ts +++ b/server/functions/src/common-types.ts @@ -48,7 +48,7 @@ export interface SnapCircleData { createdBy: DocumentReference; createdOn: Timestamp; updatedOn: Timestamp; - exipresOn?: Timestamp; + expiresOn?: Timestamp; participantCount?: number; maxParticipants?: number; maxMinutes?: number; diff --git a/server/functions/src/session.ts b/server/functions/src/session.ts index 18ebe251..35168717 100644 --- a/server/functions/src/session.ts +++ b/server/functions/src/session.ts @@ -112,6 +112,53 @@ export async function endSessionFor( return true; } +export const addMinutesToSession = functions.https.onCall(async ({circleId, minutes}, {auth}) => { + auth = isAuthenticated(auth); + if (circleId) { + const circleRef = admin.firestore().collection("snapCircles").doc(circleId); + const circleSnapshot = await circleRef.get(); + if (circleSnapshot.exists) { + const circleData = (circleSnapshot.data() as SnapCircleData) ?? {}; + const {keeper, expiresOn, state} = circleData; + if (expiresOn == null) { + // Just ignore this request if there is no expiresOn date to add time to + return; + } + if (auth.uid !== keeper && !hasAnyRole(auth, [Role.ADMIN])) { + throw new functions.https.HttpsError( + "permission-denied", + "The function can only be called by the keeper of the circle or an admin." + ); + } + if (![SessionState.live, SessionState.expiring].includes(state)) { + throw new functions.https.HttpsError( + "failed-precondition", + "The session must be live or expiring to add minutes." + ); + } + const endTime = expiresOn.toDate(); + const now = new Date(); + if (endTime <= now) { + throw new functions.https.HttpsError("failed-precondition", "The session has already expired."); + } + let newState = state; + let newEndTime = add(endTime, {minutes}); + console.log(`Adding ${minutes} minutes to session ${circleId} to ${newEndTime}`); + if (newEndTime < now) { + newEndTime = now; + // If it wasn't expiring then it is now + newState = SessionState.expiring; + } else { + // otherwise just set it live and let the scheduler handle it if necessary + newState = SessionState.live; + } + await circleRef.update({state: newState, expiresOn: Timestamp.fromDate(newEndTime)}); + return true; + } + } + return false; +}); + export const endSnapSession = functions.https.onCall(async ({circleId}, {auth}): Promise => { auth = isAuthenticated(auth); if (circleId) {