diff --git a/client/android/app/src/main/res/values-night-v31/styles.xml b/client/android/app/src/main/res/values-night-v31/styles.xml index c2eb1a2d..15edd042 100644 --- a/client/android/app/src/main/res/values-night-v31/styles.xml +++ b/client/android/app/src/main/res/values-night-v31/styles.xml @@ -4,7 +4,11 @@ diff --git a/client/android/app/src/main/res/values-night/styles.xml b/client/android/app/src/main/res/values-night/styles.xml index dbc9ea9f..a58bd353 100644 --- a/client/android/app/src/main/res/values-night/styles.xml +++ b/client/android/app/src/main/res/values-night/styles.xml @@ -7,7 +7,11 @@ @drawable/launch_background false false - false + true + @android:color/transparent + @android:color/transparent + false + false shortEdges diff --git a/client/android/app/src/main/res/values-v31/styles.xml b/client/android/app/src/main/res/values-v31/styles.xml index 937de5fc..447fbea3 100644 --- a/client/android/app/src/main/res/values-v31/styles.xml +++ b/client/android/app/src/main/res/values-v31/styles.xml @@ -4,7 +4,11 @@ diff --git a/client/android/app/src/main/res/values/styles.xml b/client/android/app/src/main/res/values/styles.xml index 0d1fa8fc..2562bf60 100644 --- a/client/android/app/src/main/res/values/styles.xml +++ b/client/android/app/src/main/res/values/styles.xml @@ -7,7 +7,11 @@ @drawable/launch_background false false - false + true + @android:color/transparent + @android:color/transparent + false + false shortEdges diff --git a/client/assets/next/rive/add_instance.riv b/client/assets/next/rive/add_instance.riv index 5c40ea27..adfc50c3 100644 Binary files a/client/assets/next/rive/add_instance.riv and b/client/assets/next/rive/add_instance.riv differ diff --git a/client/assets/next/rive/biometric.riv b/client/assets/next/rive/biometric.riv new file mode 100644 index 00000000..e0f41373 Binary files /dev/null and b/client/assets/next/rive/biometric.riv differ diff --git a/client/assets/next/rive/biometric_sad.riv b/client/assets/next/rive/biometric_sad.riv new file mode 100644 index 00000000..4cbb260f Binary files /dev/null and b/client/assets/next/rive/biometric_sad.riv differ diff --git a/client/assets/next/rive/biometric_unavailable.riv b/client/assets/next/rive/biometric_unavailable.riv new file mode 100644 index 00000000..cbecc633 Binary files /dev/null and b/client/assets/next/rive/biometric_unavailable.riv differ diff --git a/client/ios/VPNExtension/Shared.swift b/client/ios/VPNExtension/Shared.swift index 43fb683c..dc691b4a 100644 --- a/client/ios/VPNExtension/Shared.swift +++ b/client/ios/VPNExtension/Shared.swift @@ -48,7 +48,7 @@ public struct ActiveTunnelData: Codable { var locationId: Int var instanceId: Int var traffic: TunnelTraffic - + init(fromConfig: TunnelStartData) { self.locationId = fromConfig.locationId self.instanceId = fromConfig.instanceId diff --git a/client/lib/open/screens/instance/instance_screen.dart b/client/lib/open/screens/instance/instance_screen.dart index 75a88885..9afcabd7 100644 --- a/client/lib/open/screens/instance/instance_screen.dart +++ b/client/lib/open/screens/instance/instance_screen.dart @@ -584,6 +584,7 @@ class _LocationItem extends HookConsumerWidget { location: location, wireguardPlugin: wireguardPlugin, biometricsStatus: biometricStatus, + db: ref.read(databaseProvider), ); talker.debug( "Location ${location.name} (${location.id}) connected", diff --git a/client/lib/open/screens/instance/next_instance_screen.dart b/client/lib/open/screens/instance/next_instance_screen.dart index 1854ae08..e6531a28 100644 --- a/client/lib/open/screens/instance/next_instance_screen.dart +++ b/client/lib/open/screens/instance/next_instance_screen.dart @@ -4,16 +4,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mobile/data/db/database.dart'; +import 'package:mobile/data/db/enums.dart'; import 'package:mobile/data/plugin/plugin.dart'; import 'package:mobile/open/api.dart'; import 'package:mobile/open/riverpod/biometrics_state.dart'; import 'package:mobile/open/riverpod/plugin/plugin.dart'; import 'package:mobile/open/screens/instance/services/tunnel_service.dart'; +import 'package:mobile/open/screens/instance/widgets/connection_conflict_dialog.dart'; import 'package:mobile/open/screens/instance/widgets/delete_instance_dialog.dart'; +import 'package:mobile/open/screens/instance/widgets/next_connect_dialog.dart'; import 'package:mobile/open/screens/instance/widgets/next_refresh_instance_dialog.dart'; import 'package:mobile/open/screens/mfa/remote_mfa_qr_screen.dart'; import 'package:mobile/open/widgets/next/icons/next_icon.dart'; import 'package:mobile/open/widgets/next/next_app_bar.dart'; +import 'package:mobile/open/widgets/next/next_bottom_sheet.dart'; import 'package:mobile/open/widgets/next/next_drawer.dart'; import 'package:mobile/open/widgets/next/next_location_card.dart'; import 'package:mobile/open/widgets/next/next_menu.dart'; @@ -380,7 +384,7 @@ class _InstanceAppBar extends StatelessWidget implements PreferredSizeWidget { Size get preferredSize => Size.fromHeight(NextAppBar.baseHeight + topPadding); } -class _LocationList extends StatelessWidget { +class _LocationList extends HookConsumerWidget { final _ScreenData data; final PluginTunnelEventData? activeTunnel; final WireguardPlugin wireguardPlugin; @@ -393,39 +397,81 @@ class _LocationList extends StatelessWidget { required this.biometricStatus, }); - Future _onDisconnect(Location location) async { - try { - await wireguardPlugin.closeTunnel(); - talker.debug("Disconnected from ${location.name}"); - } catch (e) { - talker.error("Failed to disconnect", e); - SnackbarService.showError("Failed to disconnect"); + @override + Widget build(BuildContext context, WidgetRef ref) { + final loadingLocationId = useState(null); + + Future onDisconnect(Location location) async { + try { + await wireguardPlugin.closeTunnel(); + talker.debug("Disconnected from ${location.name}"); + } catch (e) { + talker.error("Failed to disconnect", e); + SnackbarService.showError("Failed to disconnect"); + } } - } - Future _onConnect(BuildContext context, Location location) async { - try { - final permissionsGranted = await wireguardPlugin.requestPermissions(); - if (permissionsGranted) { + Future onConnect(BuildContext context, Location location) async { + loadingLocationId.value = location.id; + try { + if (activeTunnel != null) { + final bool? changeConnection = await showDialog( + context: context, + builder: (BuildContext context) { + return const ConnectionConflictDialog(); + }, + ); + + if (changeConnection != true) { + loadingLocationId.value = null; + return; + } + + await wireguardPlugin.closeTunnel(); + } + if (context.mounted) { - await TunnelService.connect( + final result = await showNextBottomSheet>( context: context, - instance: data.instance, - location: location, - wireguardPlugin: wireguardPlugin, - biometricsStatus: biometricStatus, + child: NextConnectDialog( + instance: data.instance, + location: location, + ), ); - talker.debug("Connected to ${location.name}"); + + if (result == null) { + loadingLocationId.value = null; + return; + } + + final permissionsGranted = await wireguardPlugin.requestPermissions(); + if (permissionsGranted) { + if (context.mounted) { + // both preferences are passed explicitly - TunnelService persists + // them once the tunnel is up, so the legacy dialogs are never + // reachable from this path + await TunnelService.connect( + context: context, + instance: data.instance, + location: location, + wireguardPlugin: wireguardPlugin, + biometricsStatus: biometricStatus, + db: ref.read(databaseProvider), + trafficMethod: result['traffic'] as RoutingMethod, + mfaMethod: result['mfa'] as MfaMethod?, + ); + talker.debug("Connected to ${location.name}"); + } + } } + } catch (e) { + talker.error("Failed to connect", e); + SnackbarService.showError("Failed to connect"); + } finally { + loadingLocationId.value = null; } - } catch (e) { - talker.error("Failed to connect", e); - SnackbarService.showError("Failed to connect"); } - } - @override - Widget build(BuildContext context) { final locations = data.locations; final connectedLocation = locations.firstWhereOrNull( (l) => @@ -461,7 +507,12 @@ class _LocationList extends StatelessWidget { location: connectedLocation, isConnected: true, routingMethod: activeTunnel?.traffic, - onDisconnectTap: () => _onDisconnect(connectedLocation), + // gated so a stored preference never renders an MFA chip on a + // location whose MFA has since been disabled server-side + mfaMethod: TunnelService.checkMfaEnabled(connectedLocation) + ? connectedLocation.mfaMethod + : null, + onDisconnectTap: () => onDisconnect(connectedLocation), ), const SizedBox(height: NextSpacing.xl), ], @@ -477,7 +528,11 @@ class _LocationList extends StatelessWidget { child: NextLocationCard( location: location, isConnected: false, - onConnectTap: () => _onConnect(context, location), + loading: loadingLocationId.value == location.id, + mfaMethod: TunnelService.checkMfaEnabled(location) + ? location.mfaMethod + : null, + onConnectTap: () => onConnect(context, location), ), ), ), diff --git a/client/lib/open/screens/instance/services/tunnel_service.dart b/client/lib/open/screens/instance/services/tunnel_service.dart index 705bacae..ef7f0ed8 100644 --- a/client/lib/open/screens/instance/services/tunnel_service.dart +++ b/client/lib/open/screens/instance/services/tunnel_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:mobile/data/db/database.dart'; import 'package:mobile/data/plugin/plugin.dart'; @@ -30,23 +31,29 @@ class TunnelService { required Location location, required dynamic wireguardPlugin, required BiometricsState biometricsStatus, + required AppDatabase db, + RoutingMethod? trafficMethod, + MfaMethod? mfaMethod, }) async { // prepare navigator to avoid "context use across async gaps" final navigator = Navigator.of(context); // handle traffic type selection if necessary - late RoutingMethod trafficMethod; - if (instance.clientTrafficPolicy == ClientTrafficPolicy.disableAllTraffic) { + late RoutingMethod selectedTrafficMethod; + if (trafficMethod != null) { + selectedTrafficMethod = trafficMethod; + } else if (instance.clientTrafficPolicy == + ClientTrafficPolicy.disableAllTraffic) { // instance enforces predefined traffic - trafficMethod = RoutingMethod.predefined; + selectedTrafficMethod = RoutingMethod.predefined; } else if (instance.clientTrafficPolicy == ClientTrafficPolicy.forceAllTraffic) { // instance enforces all traffic - trafficMethod = RoutingMethod.all; + selectedTrafficMethod = RoutingMethod.all; } else { // instance allows traffic type selection - use stored method or display selection dialog if (location.trafficMethod != null) { - trafficMethod = location.trafficMethod!; + selectedTrafficMethod = location.trafficMethod!; } else { // no pre selected traffic choice available, ask user RoutingMethodDialogIntention dialogIntention = checkMfaEnabled(location) @@ -64,7 +71,7 @@ class TunnelService { if (userSelection == null) { return; } - trafficMethod = userSelection; + selectedTrafficMethod = userSelection; } } @@ -72,17 +79,28 @@ class TunnelService { PluginConnectPayload payload = _makePayload( instance, location, - trafficMethod, + selectedTrafficMethod, ); + // the method that actually authorized this connection - stays null when no + // MFA was performed, which is what keeps non-MFA locations from having a + // phantom method remembered for them + MfaMethod? authorizedWith; + // handle MFA if configured if (checkMfaEnabled(location)) { // Request notification permissions for MFA session expiry alerts await requestNotificationPermissions(); - MfaMethod mfaMethod; + late MfaMethod selectedMfaMethod; if (location.locationMfaMode == LocationMfaMode.external) { - // location setup for openid mfa login - mfaMethod = MfaMethod.openid; + // location setup for openid mfa login - the server dictates the method, + // so this stays ahead of any caller-supplied choice + selectedMfaMethod = MfaMethod.openid; + } else if (mfaMethod != null && + !(mfaMethod == MfaMethod.biometric && + !biometricsStatus.canOpenStorage)) { + // caller already collected the choice from the user + selectedMfaMethod = mfaMethod; } else { // non-openid mfa setup, use stored method or show method choice dialog if (location.mfaMethod == null || @@ -100,9 +118,9 @@ class TunnelService { // dialog dismissed return; } - mfaMethod = userSelection; + selectedMfaMethod = userSelection; } else { - mfaMethod = location.mfaMethod!; + selectedMfaMethod = location.mfaMethod!; } } @@ -111,7 +129,7 @@ class TunnelService { navigator: navigator, proxyUrl: instance.proxyUrl, payload: payload, - method: mfaMethod, + method: selectedMfaMethod, secureStorageKey: instance.secureStorageKey, openidDisplayName: instance.openidDisplayName, ); @@ -120,6 +138,7 @@ class TunnelService { return; } payload.presharedKey = presharedKey; + authorizedWith = selectedMfaMethod; } else if (payload.postureCheckRequired) { final presharedKey = await _performPostureCheck( navigator: navigator, @@ -135,6 +154,55 @@ class TunnelService { // start the tunnel await wireguardPlugin.startTunnel(jsonEncode(payload.toJson())); + + // only remember what the caller explicitly collected from the user - the + // legacy dialogs keep owning their own "Remember my choice" checkbox + await _rememberPreferences( + db, + instance, + location, + trafficMethod: trafficMethod, + mfaMethod: mfaMethod != null ? authorizedWith : null, + ); + } + + /// Stores the connection preferences on the location row so the next connect + /// can pre-select them, and so the UI can show what authorized the tunnel. + /// + /// Runs only after the tunnel actually started, so a cancelled or failed + /// connect never overwrites a working preference. Fields left absent keep + /// their stored value - passing `Value(null)` would clear them instead. + static Future _rememberPreferences( + AppDatabase db, + DefguardInstance instance, + Location location, { + RoutingMethod? trafficMethod, + MfaMethod? mfaMethod, + }) async { + // only the "none" policy leaves the routing choice to the user - under an + // enforced policy the value is the server's, not a preference + final traffic = + instance.clientTrafficPolicy == ClientTrafficPolicy.none && + trafficMethod != null + ? drift.Value(trafficMethod) + : const drift.Value.absent(); + final mfa = mfaMethod != null + ? drift.Value(mfaMethod) + : const drift.Value.absent(); + + if (!traffic.present && !mfa.present) { + return; + } + + try { + await (db.update(db.locations)..where((t) => t.id.equals(location.id))) + .write(LocationsCompanion(trafficMethod: traffic, mfaMethod: mfa)); + } catch (e) { + talker.error( + "Failed to remember connection preferences for location ${location.id}", + e, + ); + } } /// Checks if MFA is enabled for specified location taking into account diff --git a/client/lib/open/screens/instance/widgets/next_connect_dialog.dart b/client/lib/open/screens/instance/widgets/next_connect_dialog.dart new file mode 100644 index 00000000..0ce31ed3 --- /dev/null +++ b/client/lib/open/screens/instance/widgets/next_connect_dialog.dart @@ -0,0 +1,197 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mobile/data/db/database.dart'; +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/open/riverpod/biometrics_state.dart'; +import 'package:mobile/open/screens/instance/services/tunnel_service.dart'; +import 'package:mobile/open/widgets/next/icons/next_icon.dart'; +import 'package:mobile/open/widgets/next/next_button.dart'; +import 'package:mobile/open/widgets/next/next_toggle.dart'; +import 'package:mobile/open/widgets/next_mfa_selector.dart'; +import 'package:mobile/theme/next/color.dart'; +import 'package:mobile/theme/next/spacing.dart'; +import 'package:mobile/theme/next/text.dart'; + +class NextConnectDialog extends HookConsumerWidget { + final DefguardInstance instance; + final Location location; + + const NextConnectDialog({ + super.key, + required this.instance, + required this.location, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final biometricsStatus = ref.watch(biometricsCapabilityProvider); + // must stay the same predicate TunnelService branches on, otherwise the + // sheet offers a factor the connect flow never asks for + final isMfaEnabled = TunnelService.checkMfaEnabled(location); + + final bool canChangeTraffic = + instance.clientTrafficPolicy == ClientTrafficPolicy.none; + final bool initialAllTraffic = + instance.clientTrafficPolicy == ClientTrafficPolicy.forceAllTraffic || + (canChangeTraffic && location.trafficMethod == RoutingMethod.all); + + final allTraffic = useState(initialAllTraffic); + + final availableMfaMethods = useMemoized(() { + if (location.locationMfaMode == LocationMfaMode.external) { + return [MfaMethod.openid]; + } + final methods = [MfaMethod.totp, MfaMethod.email]; + if (instance.mfaKeysStored && biometricsStatus.canOpenStorage) { + methods.insert(0, MfaMethod.biometric); + } + return methods; + }, [instance, location, biometricsStatus]); + + final selectedMfaMethod = useState( + location.mfaMethod ?? availableMfaMethods.first, + ); + + final mfaController = useMemoized(() => ExpansibleController(), []); + useEffect(() => mfaController.dispose, [mfaController]); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Connect ${location.name} location", + style: NextText.bodyPrimary600.copyWith(color: NextColor.fgWhite100), + textAlign: TextAlign.left, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const Padding( + padding: EdgeInsets.only(top: 20, bottom: 16), + child: Divider(height: 1, color: NextColor.bgWhite10), + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: canChangeTraffic + ? () => allTraffic.value = !allTraffic.value + : null, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + allTraffic.value ? "All traffic" : "Predefined traffic only", + style: NextText.bodySm400.copyWith( + color: NextColor.fgWhite100, + ), + ), + NextToggle(value: allTraffic.value), + ], + ), + ), + ), + if (isMfaEnabled) ...[ + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Divider(color: NextColor.bgWhite10, height: 1), + ), + Expansible( + controller: mfaController, + expansibleBuilder: (context, header, body, animation) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [header, body], + ), + headerBuilder: (context, animation) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => mfaController.toggle(), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: NextColor.bgWhite100, + ), + child: Text( + "MFA", + style: NextText.bodyXs500.copyWith( + color: const Color(0xff061a74), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + selectedMfaMethod.value.toUiString( + openidDisplayName: instance.openidDisplayName, + ), + style: NextText.bodySm400.copyWith( + color: NextColor.fgWhite100, + ), + ), + ), + const SizedBox(width: 4), + NextIcon( + "arrow_small", + size: 20, + color: NextColor.fgWhite100, + rotation: animation.value * (math.pi / 2), + ), + ], + ), + ), + ), + bodyBuilder: (context, animation) => Padding( + padding: const EdgeInsets.only(top: NextSpacing.md), + child: Column( + spacing: NextSpacing.md, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: availableMfaMethods + .map( + (method) => NextMfaSelector( + active: selectedMfaMethod.value == method, + factor: method, + onTap: () { + selectedMfaMethod.value = method; + }, + ), + ) + .toList(), + ), + ), + ), + ], + const Padding( + padding: EdgeInsets.symmetric(vertical: NextSpacing.xl2), + child: Divider(height: 1, color: NextColor.bgWhite10), + ), + NextButton( + text: "Connect VPN", + size: NextButtonSize.big, + style: NextButtonStyle.primary, + disabled: isMfaEnabled, + onTap: () { + Navigator.pop(context, { + 'traffic': allTraffic.value + ? RoutingMethod.all + : RoutingMethod.predefined, + 'mfa': selectedMfaMethod.value, + }); + }, + ), + ], + ); + } +} diff --git a/client/lib/open/widgets/next/next_bottom_sheet.dart b/client/lib/open/widgets/next/next_bottom_sheet.dart index 4c23acd5..84b1c32b 100644 --- a/client/lib/open/widgets/next/next_bottom_sheet.dart +++ b/client/lib/open/widgets/next/next_bottom_sheet.dart @@ -9,12 +9,6 @@ class NextBottomSheet extends StatelessWidget { final WidgetBuilder? builder; - final VoidCallback? onClosing; - - final AnimationController? animationController; - - final bool enableDrag; - final bool showDragHandle; final Color? backgroundColor; @@ -27,9 +21,6 @@ class NextBottomSheet extends StatelessWidget { super.key, this.child, this.builder, - this.onClosing, - this.animationController, - this.enableDrag = true, this.showDragHandle = true, this.backgroundColor, this.elevation, @@ -44,50 +35,47 @@ class NextBottomSheet extends StatelessWidget { final bottomSafeArea = MediaQuery.paddingOf(context).bottom; final effectivePadding = contentPadding != null - ? contentPadding!.add(EdgeInsets.only(bottom: bottomSafeArea)) + ? contentPadding!.add( + EdgeInsets.only(bottom: bottomSafeArea + NextSpacing.xl), + ) : EdgeInsets.only( left: NextSpacing.xl, right: NextSpacing.xl, top: NextSpacing.sm, - bottom: NextSpacing.xs + bottomSafeArea, + bottom: NextSpacing.xl + bottomSafeArea, ); - return BottomSheet( - onClosing: onClosing ?? () {}, - animationController: animationController, - enableDrag: enableDrag, - backgroundColor: backgroundColor ?? NextColor.bgDarkBlue80, + return Material( + color: backgroundColor ?? NextColor.bgDarkBlue80, elevation: elevation ?? 0, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), clipBehavior: Clip.antiAlias, - builder: (BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showDragHandle) - SizedBox( - height: 37, - child: Center( - child: Container( - width: 50, - height: 5, - decoration: BoxDecoration( - color: NextColor.fgDisabled, - borderRadius: BorderRadius.circular(100), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showDragHandle) + SizedBox( + height: 37, + child: Center( + child: Container( + width: 50, + height: 5, + decoration: BoxDecoration( + color: NextColor.fgDisabled, + borderRadius: BorderRadius.circular(100), ), ), ), - Padding( - padding: effectivePadding, - child: builder?.call(context) ?? child!, ), - ], - ); - }, + Padding( + padding: effectivePadding, + child: builder?.call(context) ?? child!, + ), + ], + ), ); } } @@ -119,20 +107,14 @@ Future showNextBottomSheet({ isDismissible: isDismissible, enableDrag: enableDrag, backgroundColor: Colors.transparent, - barrierColor: barrierColor, + barrierColor: barrierColor ?? const Color(0x80000000), routeSettings: routeSettings, transitionAnimationController: transitionAnimationController, builder: (modalContext) { return NextBottomSheet( - enableDrag: enableDrag, showDragHandle: showDragHandle, backgroundColor: backgroundColor, contentPadding: contentPadding, - onClosing: () { - if (Navigator.canPop(modalContext)) { - Navigator.pop(modalContext); - } - }, builder: builder, child: child, ); diff --git a/client/lib/open/widgets/next/next_button.dart b/client/lib/open/widgets/next/next_button.dart index 8375cfbe..63d5fc9b 100644 --- a/client/lib/open/widgets/next/next_button.dart +++ b/client/lib/open/widgets/next/next_button.dart @@ -164,22 +164,37 @@ class NextButton extends StatelessWidget { child: InkWell( onTap: isInteractive ? onTap : null, borderRadius: borderRadius, - child: AnimatedPadding( - duration: duration, - curve: curve, - padding: EdgeInsets.symmetric(horizontal: padding, vertical: 0), - child: AnimatedDefaultTextStyle( - duration: duration, - curve: curve, - style: textStyle, - child: Row( - spacing: spacing, - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: _getRow(), + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedOpacity( + duration: duration, + curve: curve, + opacity: loading ? 0.0 : 1.0, + child: AnimatedPadding( + duration: duration, + curve: curve, + padding: EdgeInsets.symmetric( + horizontal: padding, + vertical: 0, + ), + child: AnimatedDefaultTextStyle( + duration: duration, + curve: curve, + style: textStyle, + child: Row( + spacing: spacing, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: _getRow(), + ), + ), + ), ), - ), + if (loading) + NextCircularProgress(color: textStyle.color, size: 16), + ], ), ), ), @@ -189,10 +204,7 @@ class NextButton extends StatelessWidget { List _getRow() { final List children = []; - if (loading) { - children.add(NextCircularProgress(color: textStyle.color, size: 16)); - return children; - } else if (icon != null) { + if (icon != null) { children.add(icon!); } diff --git a/client/lib/open/widgets/next/next_location_card.dart b/client/lib/open/widgets/next/next_location_card.dart index e128c58d..91a01eb3 100644 --- a/client/lib/open/widgets/next/next_location_card.dart +++ b/client/lib/open/widgets/next/next_location_card.dart @@ -182,6 +182,7 @@ class NextLocationCard extends StatelessWidget { NextButton( text: "Connect", onTap: onConnectTap, + loading: loading, size: NextButtonSize.big, style: NextButtonStyle.secondary, height: 36, diff --git a/client/lib/open/widgets/next/next_radio_indicator.dart b/client/lib/open/widgets/next/next_radio_indicator.dart new file mode 100644 index 00000000..d12efef2 --- /dev/null +++ b/client/lib/open/widgets/next/next_radio_indicator.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; +import 'package:mobile/open/widgets/next/next_preview_wrapper.dart'; +import 'package:mobile/theme/next/color.dart'; + +class NextRadioIndicator extends StatelessWidget { + final int? size; + final bool value; + + const NextRadioIndicator({super.key, this.size, required this.value}); + + @override + Widget build(BuildContext context) { + final double boxSize = (size ?? 20).toDouble(); + final double circleSize = boxSize - 4; + const duration = Duration(milliseconds: 200); + const curve = Curves.easeInOut; + + return SizedBox( + width: boxSize, + height: boxSize, + child: Center( + child: AnimatedContainer( + duration: duration, + curve: curve, + width: circleSize, + height: circleSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: value ? NextColor.bgWhite100 : Colors.transparent, + border: Border.all( + color: value ? NextColor.bgWhite100 : NextColor.borderAction, + width: 2, + ), + ), + child: Center( + child: AnimatedScale( + duration: duration, + curve: curve, + scale: value ? 1.0 : 0.0, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: NextColor.fgAction, + ), + ), + ), + ), + ), + ), + ); + } +} + +@Preview(name: 'Off') +Widget previewNextRadioIndicatorOff() { + return const NextPreviewWrapper(child: NextRadioIndicator(value: false)); +} + +@Preview(name: 'On') +Widget previewNextRadioIndicatorOn() { + return const NextPreviewWrapper(child: NextRadioIndicator(value: true)); +} + +@Preview(name: 'Big Off') +Widget previewNextRadioIndicatorBigOff() { + return const NextPreviewWrapper( + child: NextRadioIndicator(value: false, size: 40), + ); +} + +@Preview(name: 'Big On') +Widget previewNextRadioIndicatorBigOn() { + return const NextPreviewWrapper( + child: NextRadioIndicator(value: true, size: 40), + ); +} diff --git a/client/lib/open/widgets/next/next_toggle.dart b/client/lib/open/widgets/next/next_toggle.dart index f4cd6fa3..4246500c 100644 --- a/client/lib/open/widgets/next/next_toggle.dart +++ b/client/lib/open/widgets/next/next_toggle.dart @@ -5,9 +5,9 @@ import 'package:mobile/theme/next/color.dart'; class NextToggle extends StatelessWidget { final bool value; - final ValueChanged onTap; + final ValueChanged? onTap; - const NextToggle({super.key, required this.value, required this.onTap}); + const NextToggle({super.key, required this.value, this.onTap}); @override Widget build(BuildContext context) { @@ -15,7 +15,7 @@ class NextToggle extends StatelessWidget { const curve = Curves.easeOut; return GestureDetector( - onTap: () => onTap(!value), + onTap: onTap != null ? () => onTap!(!value) : null, child: AnimatedContainer( duration: duration, curve: curve, @@ -50,10 +50,10 @@ class NextToggle extends StatelessWidget { @Preview(name: 'Toggle Off') Widget previewNextToggleOff() { - return NextPreviewWrapper(child: NextToggle(value: false, onTap: (_) {})); + return NextPreviewWrapper(child: NextToggle(value: false)); } @Preview(name: 'Toggle On') Widget previewNextToggleOn() { - return NextPreviewWrapper(child: NextToggle(value: true, onTap: (_) {})); + return NextPreviewWrapper(child: NextToggle(value: true)); } diff --git a/client/lib/open/widgets/next_mfa_selector.dart b/client/lib/open/widgets/next_mfa_selector.dart new file mode 100644 index 00000000..ebabed4b --- /dev/null +++ b/client/lib/open/widgets/next_mfa_selector.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/open/widgets/next/icons/next_icon.dart'; +import 'package:mobile/open/widgets/next/next_preview_wrapper.dart'; +import 'package:mobile/open/widgets/next/next_radio_indicator.dart'; +import 'package:mobile/theme/next/color.dart'; +import 'package:mobile/theme/next/spacing.dart'; +import 'package:mobile/theme/next/text.dart'; + +class NextMfaSelector extends StatelessWidget { + final bool active; + final MfaMethod factor; + final VoidCallback? onTap; + final bool disabled; + + const NextMfaSelector({ + super.key, + required this.active, + required this.factor, + this.onTap, + this.disabled = false, + }); + + String get getIcon { + switch (factor) { + case MfaMethod.totp: + return 'mobile_lock'; + case MfaMethod.email: + return 'mail'; + case MfaMethod.biometric: + return 'biometric'; + case MfaMethod.openid: + return 'globe'; + } + } + + Color get getIconColor { + if (disabled) return NextColor.fgDisabled; + return active ? NextColor.fgWhite100 : NextColor.fgWhite80; + } + + String get getLabel => factor.toUiString(); + + Color get getLabelColor { + if (disabled) return NextColor.fgDisabled; + return active ? NextColor.fgWhite100 : NextColor.fgWhite80; + } + + @override + Widget build(BuildContext context) { + final contentColor = getLabelColor; + const duration = Duration(milliseconds: 200); + const curve = Curves.easeOut; + + return GestureDetector( + onTap: disabled ? null : onTap, + child: AnimatedContainer( + duration: duration, + curve: curve, + constraints: BoxConstraints(minHeight: 44), + padding: const EdgeInsets.fromLTRB(16, 8, 12, 8), + decoration: BoxDecoration( + color: active ? NextColor.bgWhite10 : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: NextColor.bgWhite10, width: 1), + ), + child: TweenAnimationBuilder( + duration: duration, + curve: curve, + tween: ColorTween(end: contentColor), + builder: (context, color, child) { + final effectiveColor = color ?? contentColor; + return Row( + mainAxisSize: MainAxisSize.min, + spacing: NextSpacing.md, + children: [ + NextIcon(getIcon, size: 20, color: effectiveColor), + Expanded( + child: Text( + getLabel, + style: NextText.bodySm400.copyWith(color: effectiveColor), + ), + ), + NextRadioIndicator(value: active, size: 20), + ], + ); + }, + ), + ), + ); + } +} + +@Preview(name: 'NextMfaSelector States', group: 'NextMfaSelector') +Widget previewNextMfaSelector() { + return NextPreviewWrapper( + child: Padding( + padding: const EdgeInsets.all(NextSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: NextSpacing.md, + children: [ + NextMfaSelector(active: true, factor: MfaMethod.totp, onTap: () {}), + NextMfaSelector(active: false, factor: MfaMethod.totp, onTap: () {}), + NextMfaSelector(active: false, factor: MfaMethod.email, onTap: () {}), + NextMfaSelector( + active: false, + factor: MfaMethod.biometric, + onTap: () {}, + ), + NextMfaSelector( + active: false, + factor: MfaMethod.openid, + onTap: () {}, + ), + NextMfaSelector( + active: false, + factor: MfaMethod.totp, + disabled: true, + onTap: () {}, + ), + ], + ), + ), + ); +}