/* * Copyright (C) 2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../android/state.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; import '../../app/views/app_page.dart'; import '../../core/models.dart'; import '../../core/state.dart'; import '../../management/models.dart'; import '../../widgets/choice_filter_chip.dart'; import '../../widgets/product_image.dart'; import 'key_actions.dart'; import 'manage_label_dialog.dart'; class HomeScreen extends ConsumerStatefulWidget { final YubiKeyData deviceData; const HomeScreen(this.deviceData, {super.key}); @override ConsumerState createState() => _HomeScreenState(); } class _HomeScreenState extends ConsumerState { bool hide = true; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final serial = widget.deviceData.info.serial; final keyCustomization = ref.watch(keyCustomizationManagerProvider)[serial]; final enabledCapabilities = widget.deviceData.info.config .enabledCapabilities[widget.deviceData.node.transport] ?? 0; final primaryColor = ref.watch(primaryColorProvider); // We need this to avoid unwanted app switch animation if (hide) { Timer.run(() { setState(() { hide = false; }); }); } return AppPage( title: hide ? null : l10n.s_home, delayedContent: hide, keyActionsBuilder: (context) => homeBuildActions(context, widget.deviceData, ref), builder: (context, expanded) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _DeviceContent(widget.deviceData, keyCustomization), const SizedBox(height: 16.0), Row( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( flex: 8, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 4, runSpacing: 8, children: Capability.values .where((c) => enabledCapabilities & c.value != 0) .map((c) => CapabilityBadge(c)) .toList(), ), if (widget.deviceData.info.fipsCapable != 0) _FipsLegend(), if (serial != null) ...[ const SizedBox(height: 32.0), _DeviceColor( deviceData: widget.deviceData, initialCustomization: keyCustomization ?? KeyCustomization(serial: serial)) ] ], ), ), if (widget.deviceData.info.version != const Version(0, 0, 0)) Flexible( flex: 6, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 200), child: _HeroAvatar( color: keyCustomization?.color ?? primaryColor, child: ProductImage( name: widget.deviceData.name, formFactor: widget.deviceData.info.formFactor, isNfc: widget.deviceData.info.supportedCapabilities .containsKey(Transport.nfc), ), ), ), ) ], ) ], ), ); }, ); } } class _FipsLegend extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.only(top: 16), child: Opacity( opacity: 0.6, child: RichText( text: TextSpan( children: [ const WidgetSpan( alignment: PlaceholderAlignment.middle, child: Padding( padding: EdgeInsets.only(right: 4), child: Icon( Symbols.shield, size: 12, fill: 0.0, ), ), ), TextSpan( text: l10n.l_fips_capable, style: Theme.of(context).textTheme.bodySmall, ), const WidgetSpan( alignment: PlaceholderAlignment.middle, child: Padding( padding: EdgeInsets.only(left: 16, right: 4), child: Icon( Symbols.shield, size: 12, fill: 1.0, ), ), ), TextSpan( text: l10n.l_fips_approved, style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), ); } } class _DeviceContent extends ConsumerWidget { final YubiKeyData deviceData; final KeyCustomization? initialCustomization; const _DeviceContent(this.deviceData, this.initialCustomization); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; final name = deviceData.name; final serial = deviceData.info.serial; final version = deviceData.info.version; final label = initialCustomization?.name; String displayName = label != null ? '$label ($name)' : name; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( displayName, style: Theme.of(context).textTheme.titleLarge, ), ), if (serial != null) Padding( padding: const EdgeInsets.only(left: 8.0), child: IconButton( icon: const Icon(Symbols.edit), onPressed: () async { await ref.read(withContextProvider)((context) async { await _showManageLabelDialog( initialCustomization ?? KeyCustomization(serial: serial), context, ); }); }, ), ) ], ), const SizedBox( height: 12, ), if (serial != null) Text( l10n.l_serial_number(serial), style: Theme.of(context) .textTheme .titleSmall ?.apply(color: Theme.of(context).colorScheme.onSurfaceVariant), ), if (version != const Version(0, 0, 0)) Text( l10n.l_firmware_version(version), style: Theme.of(context) .textTheme .titleSmall ?.apply(color: Theme.of(context).colorScheme.onSurfaceVariant), ), if (deviceData.info.pinComplexity) Padding( padding: const EdgeInsets.only(top: 12), child: Text( l10n.l_pin_complexity, style: Theme.of(context).textTheme.titleSmall?.apply( color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ], ); } Future _showManageLabelDialog( KeyCustomization keyCustomization, BuildContext context) async { await showBlurDialog( context: context, builder: (context) => ManageLabelDialog( initialCustomization: keyCustomization, ), ); } } class _DeviceColor extends ConsumerWidget { final YubiKeyData deviceData; final KeyCustomization initialCustomization; const _DeviceColor( {required this.deviceData, required this.initialCustomization}); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; final defaultColor = ref.watch(defaultColorProvider); final customColor = initialCustomization.color; return ChoiceFilterChip( disableHover: true, value: customColor, items: const [null], selected: customColor != null && customColor.value != defaultColor.value, itemBuilder: (e) => Wrap( alignment: WrapAlignment.center, runSpacing: 8, spacing: 16, children: [ ...[ Colors.teal, Colors.cyan, Colors.blueAccent, Colors.deepPurple, Colors.red, Colors.orange, Colors.yellow, // add nice color to devices with dynamic color if (isAndroid && ref.read(androidSdkVersionProvider) >= 31) Colors.lightGreen ].map((e) => _ColorButton( color: e, isSelected: customColor?.value == e.value, onPressed: () { _updateColor(e, ref); Navigator.of(context).pop(); }, )), // "use default color" button RawMaterialButton( onPressed: () { _updateColor(null, ref); Navigator.of(context).pop(); }, constraints: const BoxConstraints(minWidth: 26.0, minHeight: 26.0), fillColor: defaultColor, hoverColor: Colors.black12, shape: const CircleBorder(), child: Icon(customColor == null ? Symbols.circle : Symbols.clear, fill: 1, size: 16, weight: 700, opticalSize: 20, color: defaultColor.computeLuminance() > 0.7 ? Colors.grey // for bright colors : Colors.white), ), ], ), labelBuilder: (e) => Row( mainAxisSize: MainAxisSize.min, children: [ Container( constraints: const BoxConstraints(minWidth: 22.0, minHeight: 22.0), decoration: BoxDecoration( color: customColor ?? defaultColor, shape: BoxShape.circle), ), const SizedBox( width: 12, ), Flexible(child: Text(l10n.s_color)) ], ), onChanged: (e) {}, ); } void _updateColor(Color? color, WidgetRef ref) async { final manager = ref.read(keyCustomizationManagerProvider.notifier); await manager.set( serial: initialCustomization.serial, name: initialCustomization.name, color: color, ); } } class _ColorButton extends StatefulWidget { final Color? color; final bool isSelected; final Function()? onPressed; const _ColorButton({ required this.color, required this.isSelected, required this.onPressed, }); @override State<_ColorButton> createState() => _ColorButtonState(); } class _ColorButtonState extends State<_ColorButton> { @override Widget build(BuildContext context) { return RawMaterialButton( onPressed: widget.onPressed, constraints: const BoxConstraints(minWidth: 26.0, minHeight: 26.0), fillColor: widget.color, hoverColor: Colors.black12, shape: const CircleBorder(), child: Icon( Symbols.circle, fill: 1, size: 16, color: widget.isSelected ? Colors.white : Colors.transparent, ), ); } } class _HeroAvatar extends StatelessWidget { final Widget child; final Color color; const _HeroAvatar({required this.color, required this.child}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ color.withOpacity(0.6), color.withOpacity(0.25), (DialogTheme.of(context).backgroundColor ?? theme.dialogBackgroundColor) .withOpacity(0), ], ), ), padding: const EdgeInsets.all(12), child: child, ); } }