import 'dart:html'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Models/menu.dart'; import 'package:manager_app/Models/menuSection.dart'; import 'package:manager_app/Screens/ApiKeys/api_keys_screen.dart'; import 'package:manager_app/Screens/Configurations/configurations_screen.dart'; import 'package:manager_app/Screens/Kiosk_devices/kiosk_screen.dart'; import 'package:manager_app/Screens/Resources/resources_screen.dart'; import 'package:manager_app/Screens/Statistics/statistics_screen.dart'; import 'package:manager_app/Screens/Applications/app_configuration_link_screen.dart'; import 'package:manager_app/Screens/Notifications/notifications_screen.dart'; import 'package:manager_app/Screens/Users/users_screen.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/Components/quota_bars_widget.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_app/main.dart'; import 'package:manager_api_new/api.dart'; import 'package:provider/provider.dart'; class MainScreen extends StatefulWidget { final InstanceDTO instance; final String? view; MainScreen({Key? key, required this.instance, required this.view}) : super(key: key); @override _MainScreenState createState() => _MainScreenState(); } class _MainScreenState extends State { late MenuSection devices; late MenuSection configurations; late MenuSection resources; Menu menu = Menu(title: "MyInfoMate"); Widget? selectedElement = null; final ValueNotifier currentPosition = ValueNotifier(null); // Keys for nudge animation — one per section type final _nudgeKeys = >{}; GlobalKey<_NudgeIconState> _nudgeKey(String type) => _nudgeKeys.putIfAbsent(type, () => GlobalKey<_NudgeIconState>()); @override void initState() { super.initState(); devices = MenuSection(name: "Applications", type: "devices", menuId: 0, subMenu: []); if (widget.instance.isMobile!) devices.subMenu.add(MenuSection(name: "Mobile", type: "mobile", menuId: 1, subMenu: [])); if (widget.instance.isTablet!) devices.subMenu.add(MenuSection(name: "Kiosk", type: "kiosk", menuId: 2, subMenu: [])); if (widget.instance.isWeb!) devices.subMenu.add(MenuSection(name: "Web", type: "web", menuId: 3, subMenu: [])); if (widget.instance.isVR!) devices.subMenu.add(MenuSection(name: "VR", type: "vr", menuId: 4, subMenu: [])); configurations = MenuSection(name: "Configurations", type: "configurations", menuId: 5, subMenu: []); resources = MenuSection(name: "Ressources", type: "resources", menuId: 6, subMenu: []); menu.sections = [devices, configurations, resources]; if (widget.instance.isStatistic == true) { menu.sections!.add(MenuSection(name: "Statistiques", type: "statistics", menuId: 7, subMenu: [])); } if(currentPosition.value == null) { if (widget.instance.isMobile!) { currentPosition.value = 1; } else if (widget.instance.isTablet!) { currentPosition.value = 2; } else if (widget.instance.isWeb!) { currentPosition.value = 3; } else { currentPosition.value = 4; } } else { //currentPosition = managerAppContext.currentPositionMenu!; } selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance); } Future _showAssignPlanDialog(BuildContext context, ManagerAppContext managerCtx, Instance inst, {void Function()? onSaved}) async { List? plans; InstanceDTO? instanceDetail; try { plans = await managerCtx.clientAPI!.subscriptionPlanApi!.subscriptionPlanGet(); instanceDetail = await managerCtx.clientAPI!.instanceApi!.instanceGetDetail(inst.id); } catch (_) {} if (!context.mounted) return; final planList = plans ?? []; String? selectedPlanId = instanceDetail?.subscriptionPlanId; showDialog( context: context, builder: (dialogContext) => StatefulBuilder( builder: (dialogContext, setDialogState) => AlertDialog( title: Text('Plan — ${inst.name}'), content: SizedBox( width: 360, child: planList.isEmpty ? Text(AppLocalizations.of(context)!.noPlansAvailable) : Column( mainAxisSize: MainAxisSize.min, children: [ RadioListTile( title: Text(AppLocalizations.of(context)!.noPlan), value: null, groupValue: selectedPlanId, onChanged: (v) => setDialogState(() => selectedPlanId = v), ), ...planList.map((plan) => RadioListTile( title: Text(plan.name), subtitle: Text( '${_formatQuotaBytes(plan.storageQuotaBytes, unlimitedLabel: AppLocalizations.of(dialogContext)!.unlimitedStorage)} · ${plan.aiRequestsPerMonth == 0 ? AppLocalizations.of(dialogContext)!.unlimitedAI : AppLocalizations.of(dialogContext)!.aiRequestsPerMonth(plan.aiRequestsPerMonth ?? 0)}', style: const TextStyle(fontSize: 11), ), value: plan.id, groupValue: selectedPlanId, onChanged: (v) => setDialogState(() => selectedPlanId = v), )), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext, rootNavigator: true).pop(), child: Text(AppLocalizations.of(dialogContext)!.cancel), ), TextButton( onPressed: () async { Navigator.of(dialogContext, rootNavigator: true).pop(); try { await managerCtx.clientAPI!.instanceApi!.instanceUpdateinstance( InstanceDTO( id: instanceDetail?.id, name: instanceDetail?.name, pinCode: instanceDetail?.pinCode, isPushNotification: instanceDetail?.isPushNotification, isStatistic: instanceDetail?.isStatistic, isMobile: instanceDetail?.isMobile, isTablet: instanceDetail?.isTablet, isWeb: instanceDetail?.isWeb, isVR: instanceDetail?.isVR, isAssistant: instanceDetail?.isAssistant, aiRequestsThisMonth: instanceDetail?.aiRequestsThisMonth, aiUsageMonthKey: instanceDetail?.aiUsageMonthKey, subscriptionPlanId: selectedPlanId ?? '', ), ); onSaved?.call(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.planUpdated))); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.errorMessage(e.toString())))); } } }, child: Text(AppLocalizations.of(dialogContext)!.save), ), ], ), ), ); } static String _formatQuotaBytes(int? bytes, {String? unlimitedLabel}) { if (bytes == null || bytes == 0) return unlimitedLabel ?? 'Stockage illimité'; if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} MB'; return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; } Future _showSwitchInstanceDialog(BuildContext context, ManagerAppContext managerCtx) async { List? instances; try { instances = await managerCtx.clientAPI!.instanceApi!.instanceGet(); } catch (_) {} if (!context.mounted) return; final instanceList = instances ?? []; showDialog( context: context, builder: (_) => AlertDialog( title: Text(AppLocalizations.of(context)!.switchInstance), content: SizedBox( width: 400, child: instanceList.isEmpty ? Text(AppLocalizations.of(context)!.noInstanceFound) : ListView.builder( shrinkWrap: true, itemCount: instanceList.length, itemBuilder: (_, i) { final inst = instanceList[i]; final isCurrent = inst.id == managerCtx.instanceId; return ListTile( leading: Icon( Icons.business, color: isCurrent ? kPrimaryColor : kBodyTextColor, ), title: Text( inst.name, style: TextStyle( color: isCurrent ? kPrimaryColor : kBodyTextColor, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, ), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (isCurrent) const Icon(Icons.check, color: Colors.green), IconButton( icon: const Icon(Icons.edit_outlined, size: 18), tooltip: AppLocalizations.of(context)!.configurePlan, onPressed: () => _showAssignPlanDialog(context, managerCtx, inst, onSaved: () { Navigator.of(context, rootNavigator: true).pop(); }), ), ], ), onTap: isCurrent ? null : () async { Navigator.of(context, rootNavigator: true).pop(); final newInstance = await managerCtx.clientAPI!.instanceApi!.instanceGetDetail(inst.id); if (newInstance != null && context.mounted) { setState(() { managerCtx.instanceId = newInstance.id; managerCtx.instanceDTO = newInstance; menu.sections = []; }); final view = newInstance.isMobile! ? 'mobile' : newInstance.isTablet! ? 'kiosk' : newInstance.isWeb! ? 'web' : 'vr'; context.go('/main/$view'); } }, ); }, ), ), actions: [ TextButton(onPressed: () => Navigator.of(context, rootNavigator: true).pop(), child: Text(AppLocalizations.of(context)!.close)), ], ), ); } static String _localizedSectionName(BuildContext context, String type) { final l = AppLocalizations.of(context)!; switch (type) { case 'devices': return l.menuApplications; case 'configurations': return l.menuConfigurations; case 'resources': return l.menuResources; case 'statistics': return l.menuStatistics; case 'notifications': return l.menuNotifications; case 'users': return l.menuUsers; case 'apikeys': return l.menuApiKeys; default: return type; } } static IconData _sectionIcon(String type) { switch (type) { case 'devices': return Icons.apps; case 'configurations': return Icons.settings_outlined; case 'resources': return Icons.folder_open_outlined; case 'statistics': return Icons.bar_chart; case 'notifications': return Icons.notifications_none; case 'users': return Icons.people_outline; case 'apikeys': return Icons.vpn_key_outlined; default: return Icons.circle_outlined; } } Widget buildMenu(BuildContext context, AppContext appContext, ManagerAppContext managerAppContext, bool isDrawer) { return Container( width: isDrawer ? null : 250, // fixed width on sidebar, null on drawer for full width child: Card( color: kWhite, margin: const EdgeInsets.symmetric(vertical: 8), elevation: 0, child: Column( children: [ Padding( padding: const EdgeInsets.all(16.0), child: Container( height: 150, width: 250, child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ Container( constraints: BoxConstraints(maxHeight: 60, minHeight: 40), child: SvgPicture.asset('assets/images/MyInfoMate_logo_only.svg') ), SizedBox(height: 16), Text( menu.title, style: TextStyle( color: kPrimaryColor, fontSize: 25, fontWeight: FontWeight.w400, fontFamily: "Helvetica", ), ), ], ), ), ), SizedBox(height: 25), Expanded( child: Theme( data: Theme.of(context).copyWith(dividerColor: Colors.transparent), child: ListView( padding: EdgeInsets.zero, children: menu.sections!.map((section) { final router = GoRouter.of(context); final routeMatchList = router.routerDelegate.currentConfiguration; var currentPath = routeMatchList.isNotEmpty ? routeMatchList.matches.first.matchedLocation : null; if (section.subMenu.isEmpty) { return Container( key: ValueKey(section.type), decoration: currentPath!.contains(section.type) ? BoxDecoration( border: Border( right: BorderSide( color: kPrimaryColor, width: 2, ), ), ) : null, child: ListTile( leading: _NudgeIcon( key: _nudgeKey(section.type), icon: _sectionIcon(section.type), isActive: currentPath.contains(section.type), color: currentPath.contains(section.type) ? kPrimaryColor : kBodyTextColor, size: 22, ), title: Text(_localizedSectionName(context, section.type), style: TextStyle(color: currentPath.contains(section.type) ? kPrimaryColor : kBodyTextColor, fontSize: 22, fontWeight: currentPath.contains(section.type) ? FontWeight.w500 : FontWeight.w100)), selected: currentPosition == section.menuId, onTap: () { WidgetsBinding.instance.addPostFrameCallback((_) => _nudgeKey(section.type).currentState?.nudge()); context.go('/main/${section.type}'); if (isDrawer) Navigator.of(context).pop(); }, ), ); } else { final isAppActive = currentPath!.contains("mobile") || currentPath.contains("kiosk") || currentPath.contains("web") || currentPath.contains("vr"); return Container( key: ValueKey(section.type), child: ExpansionTile( leading: _NudgeIcon( key: _nudgeKey(section.type), icon: _sectionIcon(section.type), isActive: isAppActive, color: isAppActive ? kPrimaryColor : kBodyTextColor, size: 22, ), iconColor: isAppActive ? kPrimaryColor : kBodyTextColor, collapsedIconColor: isAppActive ? kPrimaryColor : kBodyTextColor, title: Text(_localizedSectionName(context, section.type), style: TextStyle(color: isAppActive ? kPrimaryColor : kBodyTextColor, fontSize: 22, fontWeight: isAppActive ? FontWeight.w500 : FontWeight.w100)), children: section.subMenu.map((subSection) { return Container( decoration: currentPath.contains(subSection.type) ? BoxDecoration( border: Border( right: BorderSide( color: kPrimaryColor, width: 2, ), ), ) : null, child: ListTile( title: Padding( padding: const EdgeInsets.only(left: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ subSection.type == "mobile" ? Icon(Icons.phone_iphone, color: currentPath.contains(subSection.type) ? kPrimaryColor : kBodyTextColor, size: 20) : subSection.type == "kiosk" ? Icon(Icons.tablet_rounded, color: currentPath.contains(subSection.type) ? kPrimaryColor : kBodyTextColor, size: 20) : subSection.type == "web" ? Icon(Icons.public_outlined, color: currentPath.contains(subSection.type) ? kPrimaryColor : kBodyTextColor, size: 20) : subSection.type == "vr" ? Icon(Icons.panorama_photosphere, color: currentPath.contains(subSection.type)? kPrimaryColor : kBodyTextColor, size: 20) : SizedBox(), Padding( padding: const EdgeInsets.all(8.0), child: Text(_localizedSectionName(context, subSection.type), style: TextStyle(color: currentPath.contains(subSection.type) ? kPrimaryColor : kBodyTextColor, fontSize: 18)), ) ], ), ), selected: currentPosition.value == subSection.menuId, onTap: () { if (currentPath != null && currentPath.contains(subSection.type)) { // already on this page } else { WidgetsBinding.instance.addPostFrameCallback((_) => _nudgeKey(section.type).currentState?.nudge()); context.go('/main/${subSection.type}'); } if (isDrawer) Navigator.of(context).pop(); }, ), ); }).toList(), ), ); } }).toList(), ), ), ), // Footer: Quota + Language + Email + Switch instance (SuperAdmin) + Logout Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ const QuotaBarsWidget(), DropdownButton( value: managerAppContext.locale, underline: const SizedBox(), isDense: true, items: const [ DropdownMenuItem(value: Locale('fr'), child: Text('🇫🇷 FR')), DropdownMenuItem(value: Locale('en'), child: Text('🇬🇧 EN')), DropdownMenuItem(value: Locale('nl'), child: Text('🇧🇪 NL')), ], onChanged: (locale) { if (locale != null) appContext.setLocale(locale); }, ), const SizedBox(height: 4), AutoSizeText( (appContext.getContext() as ManagerAppContext).email ?? "", style: TextStyle(color: kBodyTextColor, fontSize: 16, fontWeight: FontWeight.w300, fontFamily: "Helvetica"), maxLines: 1, ), if ((appContext.getContext() as ManagerAppContext).role == UserRole.SuperAdmin) IconButton( icon: Icon(Icons.swap_horiz, color: kPrimaryColor), tooltip: AppLocalizations.of(context)!.tooltipSwitchInstance, onPressed: () => _showSwitchInstanceDialog(context, appContext.getContext() as ManagerAppContext), ), IconButton( icon: Icon(Icons.logout, color: kPrimaryColor), onPressed: () async { var session = await loadJsonSessionFile(); setState(() { Storage localStorage = window.localStorage; localStorage.clear(); ManagerAppContext managerAppContext = appContext.getContext(); managerAppContext.accessToken = null; managerAppContext.instanceId = null; managerAppContext.instanceDTO = null; appContext.setContext(managerAppContext); context.go('/login'); }); }, ) ], ), ), ], ), ), ); } @override Widget build(BuildContext context) { final appContext = Provider.of(context); ManagerAppContext managerAppContext = appContext.getContext(); // Synchronise les items de menu sensibles au rôle à chaque rebuild final role = managerAppContext.role; final hasAdminItems = menu.sections!.any((s) => s.menuId == 8); final hasNotifItem = menu.sections!.any((s) => s.menuId == 10); if (role != null && role.value <= 1 && managerAppContext.instanceDTO?.isPushNotification == true && !hasNotifItem) { menu.sections!.add(MenuSection(name: "Notifications", type: "notifications", menuId: 10, subMenu: [])); } else if ((role == null || role.value > 1 || managerAppContext.instanceDTO?.isPushNotification != true) && hasNotifItem) { menu.sections!.removeWhere((s) => s.menuId == 10); } if (role != null && role.value <= 1 && !hasAdminItems) { menu.sections!.add(MenuSection(name: "Utilisateurs", type: "users", menuId: 8, subMenu: [])); menu.sections!.add(MenuSection(name: "Clés API", type: "apikeys", menuId: 9, subMenu: [])); } else if ((role == null || role.value > 1) && hasAdminItems) { menu.sections!.removeWhere((s) => s.menuId == 8 || s.menuId == 9); } Size size = MediaQuery.of(context).size; bool isMobile = size.width < 850; return Scaffold( appBar: isMobile ? AppBar( title: Text(menu.title, style: TextStyle(color: kWhite)), backgroundColor: kPrimaryColor, leading: Builder( builder: (context) => IconButton( icon: Icon(Icons.menu, color: kWhite), onPressed: () => Scaffold.of(context).openDrawer(), ), ), ) : null, drawer: isMobile ? ValueListenableBuilder( valueListenable: currentPosition, builder: (context, value, _) { return Drawer(child: buildMenu(context, appContext, managerAppContext, true)); } ) : null, body: Row( children: [ if (!isMobile) ValueListenableBuilder( valueListenable: currentPosition, builder: (context, value, _) { return buildMenu(context, appContext, managerAppContext, false); } ), Expanded( child: ValueListenableBuilder( valueListenable: currentPosition, builder: (context, value, _) { selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance); return Padding( padding: const EdgeInsets.all(8.0), child: selectedElement, ); } ), ), ], ), ); } } initElementToShow(BuildContext context, String? view, int currentPosition, Menu menu, InstanceDTO instanceDTO) { if(view != null) { switch (view) { case "mobile": currentPosition = 1; break; case "kiosk": currentPosition = 2; break; case "web": currentPosition = 3; break; case "vr": currentPosition = 4; break; case "configurations": currentPosition = 5; break; case "resources": currentPosition = 6; break; case "statistics": currentPosition = 7; break; case "users": currentPosition = 8; break; case "apikeys": currentPosition = 9; break; case "notifications": currentPosition = 10; break; } } MenuSection? elementToShow = menu.sections!.firstWhereOrNull((s) => s.menuId == currentPosition); if(elementToShow == null) { elementToShow = menu.sections![0].subMenu.where((s) => s.menuId == currentPosition).first; } switch (elementToShow.type) { case 'mobile' : var applicationInstanceMobile = instanceDTO.applicationInstanceDTOs!.firstWhere((ai) => ai.appType == AppType.Mobile); return Padding( padding: const EdgeInsets.all(8.0), child: AppConfigurationLinkScreen(applicationInstanceDTO: applicationInstanceMobile) ); case 'kiosk' : var applicationInstanceTablet = instanceDTO.applicationInstanceDTOs!.firstWhere((ai) => ai.appType == AppType.Tablet); return Padding( padding: const EdgeInsets.all(8.0), child: KioskScreen(applicationInstanceDTO: applicationInstanceTablet) ); case 'web' : var applicationInstanceWeb = instanceDTO.applicationInstanceDTOs!.firstWhere((ai) => ai.appType == AppType.Web); return Padding( padding: const EdgeInsets.all(8.0), child: Text("TODO web") ); case 'vr' : var applicationInstanceVR = instanceDTO.applicationInstanceDTOs!.firstWhere((ai) => ai.appType == AppType.VR); return Padding( padding: const EdgeInsets.all(8.0), child: Text("TODO vr") ); case 'configurations' : return Padding( padding: const EdgeInsets.all(8.0), child: ConfigurationsScreen() ); case 'resources' : return Padding( padding: const EdgeInsets.all(8.0), child: ResourcesScreen( resourceTypes: [ ResourceType.Audio, ResourceType.Image, ResourceType.ImageUrl, ResourceType.Video, ResourceType.VideoUrl, ResourceType.Pdf, ResourceType.Json, ResourceType.JsonUrl ] ) ); case 'statistics' : return const Padding( padding: EdgeInsets.all(8.0), child: StatisticsScreen() ); case 'users': return const Padding( padding: EdgeInsets.all(8.0), child: UsersScreen() ); case 'apikeys': return const Padding( padding: EdgeInsets.all(8.0), child: ApiKeysScreen() ); case 'notifications': return const Padding( padding: EdgeInsets.all(8.0), child: NotificationsScreen() ); default: return Text('Hellow default'); } } class _NudgeIcon extends StatefulWidget { final IconData icon; final bool isActive; final Color color; final double size; const _NudgeIcon({Key? key, required this.icon, required this.isActive, required this.color, required this.size}) : super(key: key); @override State<_NudgeIcon> createState() => _NudgeIconState(); } class _NudgeIconState extends State<_NudgeIcon> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation _dx; @override void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400)); _dx = TweenSequence([ TweenSequenceItem(tween: Tween(begin: 0.0, end: 5.0), weight: 35), TweenSequenceItem(tween: Tween(begin: 5.0, end: 0.0), weight: 65), ]).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); } void nudge() => _ctrl.forward(from: 0); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _dx, builder: (_, child) => Transform.translate( offset: Offset(_dx.value, 0), child: child, ), child: Icon(widget.icon, color: widget.color, size: widget.size), ); } }