Regroupe la configuration de carte en deux blocs (Carte / Points d'interet), ajoute le filtre par categorie et l'editeur de point geographique, et passe les ecrans Users et les jauges de quota par AppLocalizations (FR/EN/NL). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
852 lines
37 KiB
Dart
852 lines
37 KiB
Dart
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/Audit/audit_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/GuideIa/guide_ia_screen.dart';
|
|
import 'package:manager_app/Screens/Applications/app_configuration_link_screen.dart';
|
|
import 'package:manager_app/Screens/Applications/web_app_screen.dart';
|
|
import 'package:manager_app/Screens/Billing/subscription_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<MainScreen> {
|
|
late MenuSection devices;
|
|
late MenuSection configurations;
|
|
late MenuSection resources;
|
|
|
|
Menu menu = Menu(title: "MyInfoMate");
|
|
Widget? selectedElement = null;
|
|
|
|
final ValueNotifier<int?> currentPosition = ValueNotifier<int?>(null);
|
|
|
|
// Keys for nudge animation — one per section type
|
|
final _nudgeKeys = <String, GlobalKey<_NudgeIconState>>{};
|
|
GlobalKey<_NudgeIconState> _nudgeKey(String type) =>
|
|
_nudgeKeys.putIfAbsent(type, () => GlobalKey<_NudgeIconState>());
|
|
|
|
/// Construit le menu à partir des applications déclarées sur l'instance.
|
|
///
|
|
/// Appelé à l'ouverture et à chaque changement d'instance : les entrées
|
|
/// Applications / Configurations / Ressources dépendent de l'instance, et une
|
|
/// instance sans application mobile n'a pas de sous-entrée « Mobile ».
|
|
void _buildMenu(InstanceDTO instance) {
|
|
devices = MenuSection(type: "devices", menuId: 0, subMenu: []);
|
|
if (instance.isMobile == true) devices.subMenu.add(MenuSection(type: "mobile", menuId: 1, subMenu: []));
|
|
if (instance.isTablet == true) devices.subMenu.add(MenuSection(type: "kiosk", menuId: 2, subMenu: []));
|
|
if (instance.isWeb == true) devices.subMenu.add(MenuSection(type: "web", menuId: 3, subMenu: []));
|
|
if (instance.isVR == true) devices.subMenu.add(MenuSection(type: "vr", menuId: 4, subMenu: []));
|
|
|
|
configurations = MenuSection(type: "configurations", menuId: 5, subMenu: []);
|
|
resources = MenuSection(type: "resources", menuId: 6, subMenu: []);
|
|
|
|
menu.sections = [devices, configurations, resources];
|
|
if (instance.hasStats == true) {
|
|
menu.sections!.add(MenuSection(type: "statistics", menuId: 7, subMenu: []));
|
|
}
|
|
if (instance.isAssistant == true) {
|
|
menu.sections!.add(MenuSection(type: "guide-ia", menuId: 12, subMenu: []));
|
|
}
|
|
}
|
|
|
|
/// Première application déclarée par l'instance, dans l'ordre du menu.
|
|
int _defaultPosition(InstanceDTO instance) {
|
|
if (instance.isMobile == true) return 1;
|
|
if (instance.isTablet == true) return 2;
|
|
if (instance.isWeb == true) return 3;
|
|
return 4;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_buildMenu(widget.instance);
|
|
_renderedInstanceId = widget.instance.id;
|
|
|
|
currentPosition.value ??= _defaultPosition(widget.instance);
|
|
|
|
selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance);
|
|
}
|
|
|
|
/// L'instance sur laquelle le menu et l'écran courant ont été construits.
|
|
String? _renderedInstanceId;
|
|
|
|
/// Reconstruit le menu et recale la position quand on a changé d'instance.
|
|
void _applyInstance(InstanceDTO instance) {
|
|
_buildMenu(instance);
|
|
_renderedInstanceId = instance.id;
|
|
|
|
final positions = menu.sections!
|
|
.expand((s) => [s.menuId, ...s.subMenu.map((sub) => sub.menuId)])
|
|
.toSet();
|
|
if (!positions.contains(currentPosition.value)) {
|
|
currentPosition.value = _defaultPosition(instance);
|
|
}
|
|
}
|
|
|
|
/// Le changement d'instance passe par la même route : Flutter réutilise cet
|
|
/// State et `initState` ne rejoue pas. Sans cela le menu reste celui de
|
|
/// l'instance précédente, et la position courante peut désigner une
|
|
/// application que la nouvelle instance ne déclare pas.
|
|
@override
|
|
void didUpdateWidget(covariant MainScreen oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.instance.id == widget.instance.id) return;
|
|
|
|
_applyInstance(widget.instance);
|
|
|
|
selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance);
|
|
}
|
|
|
|
Future<void> _showAssignPlanDialog(BuildContext context, ManagerAppContext managerCtx, Instance inst, {void Function()? onSaved}) async {
|
|
List<SubscriptionPlanDTO>? 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(AppLocalizations.of(context)!.planDialogTitle(inst.name ?? '')),
|
|
content: SizedBox(
|
|
width: 360,
|
|
child: planList.isEmpty
|
|
? Text(AppLocalizations.of(context)!.noPlansAvailable)
|
|
: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
RadioListTile<String?>(
|
|
title: Text(AppLocalizations.of(context)!.noPlan),
|
|
value: null,
|
|
groupValue: selectedPlanId,
|
|
onChanged: (v) => setDialogState(() => selectedPlanId = v),
|
|
),
|
|
...planList.map((plan) => RadioListTile<String?>(
|
|
title: Text(plan.name),
|
|
subtitle: Text(
|
|
'${_formatQuotaBytes(plan.storageQuotaBytes, unlimitedLabel: AppLocalizations.of(dialogContext)!.unlimitedStorage)} · ${plan.aiTokensPerMonth == 0 ? AppLocalizations.of(dialogContext)!.unlimitedAI : AppLocalizations.of(dialogContext)!.aiTokensPerMonth(_formatTokens(plan.aiTokensPerMonth))}',
|
|
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,
|
|
hasStats: instanceDetail?.hasStats,
|
|
hasAdvancedStats: instanceDetail?.hasAdvancedStats,
|
|
isMobile: instanceDetail?.isMobile,
|
|
isTablet: instanceDetail?.isTablet,
|
|
isWeb: instanceDetail?.isWeb,
|
|
isVR: instanceDetail?.isVR,
|
|
isAssistant: instanceDetail?.isAssistant,
|
|
aiTokensThisMonth: instanceDetail?.aiTokensThisMonth,
|
|
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, {required String unlimitedLabel}) {
|
|
if (bytes == null || bytes == 0) return unlimitedLabel;
|
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} MB';
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
}
|
|
|
|
static String _formatTokens(int? tokens) {
|
|
if (tokens == null || tokens == 0) return '0';
|
|
if (tokens < 1000) return '$tokens';
|
|
if (tokens < 1000000) return '${(tokens / 1000).toStringAsFixed(0)}K';
|
|
return '${(tokens / 1000000).toStringAsFixed(1)}M';
|
|
}
|
|
|
|
Future<void> _showSwitchInstanceDialog(BuildContext context, ManagerAppContext managerCtx) async {
|
|
List<Instance>? 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;
|
|
// Une configuration ou une section restée
|
|
// sélectionnée appartient à l'instance qu'on
|
|
// vient de quitter : l'écran de détail la
|
|
// réaffichait telle quelle.
|
|
managerCtx.selectedConfiguration = null;
|
|
managerCtx.selectedSection = null;
|
|
managerCtx.selectedSubSection = null;
|
|
managerCtx.selectedSubSectionRawData = null;
|
|
});
|
|
Provider.of<AppContext>(context, listen: false)
|
|
.setContext(managerCtx);
|
|
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 'guide-ia': return l.menuGuideIa;
|
|
case 'notifications': return l.menuNotifications;
|
|
case 'users': return l.menuUsers;
|
|
case 'apikeys': return l.menuApiKeys;
|
|
case 'audit': return l.menuAudit;
|
|
case 'subscription': return l.menuSubscription;
|
|
case 'mobile': return l.menuMobile;
|
|
case 'kiosk': return l.menuKiosk;
|
|
case 'web': return l.menuWeb;
|
|
case 'vr': return l.menuVr;
|
|
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 'guide-ia': return Icons.record_voice_over_outlined;
|
|
case 'notifications': return Icons.notifications_none;
|
|
case 'users': return Icons.people_outline;
|
|
case 'apikeys': return Icons.vpn_key_outlined;
|
|
case 'audit': return Icons.history;
|
|
case 'subscription': return Icons.workspace_premium_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(),
|
|
Row(
|
|
children: [
|
|
DropdownButton<Locale>(
|
|
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(width: 8),
|
|
Expanded(
|
|
child: AutoSizeText(
|
|
(appContext.getContext() as ManagerAppContext).email ?? "",
|
|
style: TextStyle(color: kBodyTextColor, fontSize: 14, 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),
|
|
tooltip: AppLocalizations.of(context)!.tooltipLogout,
|
|
onPressed: () async {
|
|
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<AppContext>(context);
|
|
ManagerAppContext managerAppContext = appContext.getContext();
|
|
|
|
// `widget.instance` est figée à la construction de la route. Or changer
|
|
// d'instance en restant sur la même vue ne change pas l'URL, donc GoRouter
|
|
// ne rejoue pas son builder et `didUpdateWidget` ne se déclenche jamais :
|
|
// l'écran continuait d'afficher l'instance précédente jusqu'à ce qu'on
|
|
// change d'onglet. C'est le contexte qui fait foi, pas le widget.
|
|
final instance = managerAppContext.instanceDTO ?? widget.instance;
|
|
if (instance.id != _renderedInstanceId) {
|
|
_applyInstance(instance);
|
|
}
|
|
|
|
// 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);
|
|
final hasSubscriptionItem = menu.sections!.any((s) => s.menuId == 11);
|
|
if (managerAppContext.instanceDTO?.subscriptionPlanId == "plan-essentiel" && !hasSubscriptionItem) {
|
|
menu.sections!.add(MenuSection(type: "subscription", menuId: 11, subMenu: []));
|
|
} else if (managerAppContext.instanceDTO?.subscriptionPlanId != "plan-essentiel" && hasSubscriptionItem) {
|
|
menu.sections!.removeWhere((s) => s.menuId == 11);
|
|
}
|
|
if (role != null && role.value <= 1 && managerAppContext.instanceDTO?.isPushNotification == true && !hasNotifItem) {
|
|
menu.sections!.add(MenuSection(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(type: "users", menuId: 8, subMenu: []));
|
|
menu.sections!.add(MenuSection(type: "apikeys", menuId: 9, subMenu: []));
|
|
} else if ((role == null || role.value > 1) && hasAdminItems) {
|
|
menu.sections!.removeWhere((s) => s.menuId == 8 || s.menuId == 9);
|
|
}
|
|
// Le journal d'activité couvre toutes les instances et expose les valeurs avant/après :
|
|
// l'endpoint est sous policy SuperAdmin, l'entrée de menu suit la même règle.
|
|
final hasAuditItem = menu.sections!.any((s) => s.menuId == 13);
|
|
if (role != null && role.value == 0 && !hasAuditItem) {
|
|
menu.sections!.add(MenuSection(type: "audit", menuId: 13, subMenu: []));
|
|
} else if ((role == null || role.value != 0) && hasAuditItem) {
|
|
menu.sections!.removeWhere((s) => s.menuId == 13);
|
|
}
|
|
|
|
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<int?>(
|
|
valueListenable: currentPosition,
|
|
builder: (context, value, _) {
|
|
return Drawer(child: buildMenu(context, appContext, managerAppContext, true));
|
|
}
|
|
) : null,
|
|
body: Row(
|
|
children: [
|
|
if (!isMobile)
|
|
ValueListenableBuilder<int?>(
|
|
valueListenable: currentPosition,
|
|
builder: (context, value, _) {
|
|
return buildMenu(context, appContext, managerAppContext, false);
|
|
}
|
|
),
|
|
Expanded(
|
|
child: ValueListenableBuilder<int?>(
|
|
valueListenable: currentPosition,
|
|
builder: (context, value, _) {
|
|
selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, instance);
|
|
// La clé porte l'instance : sans elle les écrans en dessous
|
|
// gardent leur State, donc leurs futures déjà résolues sur
|
|
// l'instance précédente.
|
|
return Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: KeyedSubtree(
|
|
key: ValueKey(instance.id),
|
|
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;
|
|
case "subscription":
|
|
currentPosition = 11;
|
|
break;
|
|
case "guide-ia":
|
|
currentPosition = 12;
|
|
break;
|
|
case "audit":
|
|
currentPosition = 13;
|
|
break;
|
|
}
|
|
}
|
|
|
|
MenuSection? elementToShow = menu.sections!.firstWhereOrNull((s) => s.menuId == currentPosition);
|
|
|
|
elementToShow ??= menu.sections!
|
|
.expand((s) => s.subMenu)
|
|
.firstWhereOrNull((s) => s.menuId == currentPosition);
|
|
|
|
// Position sans entrée correspondante : on retombe sur la première entrée
|
|
// disponible plutôt que de laisser l'écran planter.
|
|
elementToShow ??= menu.sections!.firstWhereOrNull((s) => s.subMenu.isNotEmpty)?.subMenu.first
|
|
?? menu.sections!.firstOrNull;
|
|
|
|
if (elementToShow == null) {
|
|
return Center(child: Text(AppLocalizations.of(context)!.noAppConfigured));
|
|
}
|
|
|
|
switch (elementToShow.type) {
|
|
case 'mobile' :
|
|
var applicationInstanceMobile = instanceDTO.applicationInstanceDTOs?.firstWhereOrNull((ai) => ai.appType == AppType.Mobile);
|
|
if (applicationInstanceMobile == null) return Center(child: Text(AppLocalizations.of(context)!.mobileAppNotConfigured));
|
|
return Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: AppConfigurationLinkScreen(applicationInstanceDTO: applicationInstanceMobile)
|
|
);
|
|
case 'kiosk' :
|
|
var applicationInstanceTablet = instanceDTO.applicationInstanceDTOs?.firstWhereOrNull((ai) => ai.appType == AppType.Tablet);
|
|
if (applicationInstanceTablet == null) return Center(child: Text(AppLocalizations.of(context)!.kioskAppNotConfigured));
|
|
return Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: KioskScreen(applicationInstanceDTO: applicationInstanceTablet)
|
|
);
|
|
case 'web' :
|
|
return const WebInstanceLoader();
|
|
case 'vr' :
|
|
var applicationInstanceVR = instanceDTO.applicationInstanceDTOs?.firstWhereOrNull((ai) => ai.appType == AppType.VR);
|
|
if (applicationInstanceVR == null) return Center(child: Text(AppLocalizations.of(context)!.vrAppNotConfigured));
|
|
return Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Text(AppLocalizations.of(context)!.vrAppNotConfigured)
|
|
);
|
|
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 'guide-ia' :
|
|
return const Padding(
|
|
padding: EdgeInsets.all(8.0),
|
|
child: GuideIaScreen()
|
|
);
|
|
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 'audit':
|
|
return const Padding(
|
|
padding: EdgeInsets.all(8.0),
|
|
child: AuditScreen()
|
|
);
|
|
case 'notifications':
|
|
return const Padding(
|
|
padding: EdgeInsets.all(8.0),
|
|
child: NotificationsScreen()
|
|
);
|
|
case 'subscription':
|
|
return const Padding(
|
|
padding: EdgeInsets.all(8.0),
|
|
child: SubscriptionScreen()
|
|
);
|
|
default:
|
|
return Center(child: Text(AppLocalizations.of(context)!.noData));
|
|
}
|
|
}
|
|
|
|
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<double> _dx;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
|
|
_dx = TweenSequence<double>([
|
|
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),
|
|
);
|
|
}
|
|
} |