Compare commits

..

22 Commits

Author SHA1 Message Date
Thomas Fransolet
c6526046c8 Working flow also in background ! With custom wakeword working (hey visit and hey viva). flow llm working + take photo (not stored for now) + scan qr code working (need to be tested but the issue was llm config in backend) 2026-06-04 17:00:43 +02:00
Thomas Fransolet
2701f4c963 Remove useless local manager api folder generation 2026-03-26 16:20:05 +01:00
Thomas Fransolet
bdabfad92e added launch screen + update in ai chat + notifs (to be tested) + EventAgenda layout (to be tested) + home 3.0 update (event) + event mode layout (to be tested) + guided Path layout (to be tested) + map parcours (to be tested) + update readme 2026-03-25 17:47:10 +01:00
Thomas Fransolet
e8ef78d0e2 Update fix after service generation + need test geoloc ! + need test for connexion with apikey also ! + check where http needed.. need to pass by service generation 2026-03-13 17:43:17 +01:00
Thomas Fransolet
538e677993 Update gradle, remove bluetooth beacon (gralde update etc..) + ai assistant (wip) + stats + layout update + clean repo (remove old release) + link to unique backend generation managerapp (TO BE TESTED) 2026-03-13 15:15:18 +01:00
Thomas Fransolet
c6599e13c9 Updates visit name to configuration + scan + misc 2025-07-09 17:45:27 +02:00
Thomas Fransolet
959b494b12 Support also mymuseum url 2025-07-09 14:48:36 +02:00
Thomas Fransolet
f0e41adace Test update scanner background color 2025-07-09 13:22:21 +02:00
Thomas Fransolet
303e50a255 Menu done + article refonte wip 2025-07-04 17:21:17 +02:00
Thomas Fransolet
c50083b19f Agenda + misc for sub menu (pdf, agenda) 2025-07-03 17:37:24 +02:00
Thomas Fransolet
f07570d8ee Weather done 2025-07-02 17:11:02 +02:00
Thomas Fransolet
5e5d92a510 misc 2025-07-02 13:11:16 +02:00
Thomas Fransolet
b20a112eb2 Map working - (only google, mapContext issue on selected point) 2025-06-19 16:50:28 +02:00
Thomas Fransolet
4e9dc59df9 Slider ok, map wip + update gradle etc + qr scanner to mobile scanner 2025-06-18 18:00:29 +02:00
Thomas Fransolet
7aca0638ce Puzzle working + slider wip + misc 2025-06-12 17:27:53 +02:00
Thomas Fransolet
bddde86974 Working Web, Pdf and Video type + misc and clean code 2025-06-11 17:26:36 +02:00
Thomas Fransolet
4946247812 Misc + clean code logic future for configurations and sections 2025-06-11 12:04:01 +02:00
Thomas Fransolet
9c5ae56549 misc + wip load section (not working) 2025-06-10 16:51:48 +02:00
Thomas Fransolet
b7ca69162c Misc, update layout, nice main page + hero to configuration detail 2025-06-05 18:20:48 +02:00
Thomas Fransolet
878a2e4cf0 Wip version 3.0.0 2025-05-28 17:16:18 +02:00
Thomas Fransolet
53dff98388 WIP version 3.0.0 2025-05-28 14:08:32 +02:00
Thomas Fransolet
42d3e257b2 Reintegrate carousel slider (fix rename 5.0) 2025-02-26 13:56:27 +01:00
387 changed files with 20556 additions and 21076 deletions

53
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,53 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Dev (flavor dev)",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"args": [
"--flavor",
"dev",
"-t",
"lib/main.dart",
"--dart-define=FLAVOR=dev",
"--dart-define=INSTANCE_ID=63514fd67ed8c735aaa4b8f2",
"--dart-define=API_BASE_URL=http://192.168.31.228:5000",
"--dart-define=API_KEY=ak_WJDTunmXQSaRmZSulSdYNP64wnPsFMIIS9X5nAfSBfE"
]
},
{
"name": "Debug MDLF",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"args": [
"--flavor",
"mdlf",
"-t",
"lib/main.dart",
"--dart-define=FLAVOR=mdlf",
"--dart-define=INSTANCE_ID=65ccc67265373befd15be511",
"--dart-define=API_BASE_URL=https://api.mymuseum.be",
"--dart-define=API_KEY=REPLACE_WITH_MDLF_KEY"
]
},
{
"name": "Debug Fort Saint-Héribert",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"args": [
"--flavor",
"fortsaintheribert",
"-t",
"lib/main.dart",
"--dart-define=FLAVOR=fortsaintheribert",
"--dart-define=INSTANCE_ID=633ee379d9405f32f166f047",
"--dart-define=API_BASE_URL=https://api.mymuseum.be",
"--dart-define=API_KEY=REPLACE_WITH_FSH_KEY"
]
}
]
}

View File

@ -3,10 +3,10 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/CustomAppBar.dart'; import 'package:mymuseum_visitapp/Components/CustomAppBar.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Article/article_page.dart'; import 'package:mymuseum_visitapp/Screens/section_page.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart'; import 'package:qr_code_scanner/qr_code_scanner.dart';
// NOT USED ANYMORE // NOT USED ANYMORE
@ -180,7 +180,7 @@ class _ScannerPageState extends State<ScannerPage> {
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) { builder: (context) {
return ArticlePage(articleId: code, visitAppContextIn: VisitAppContext()); // will not work.. return SectionPage(configuration: ConfigurationDTO(), rawSection: null, sectionId: code, visitAppContextIn: VisitAppContext()); // will not work..
}, },
), ),
); );

View File

@ -0,0 +1,117 @@
import 'package:ar_flutter_plugin/managers/ar_location_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_session_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_object_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_anchor_manager.dart';
import 'package:flutter/material.dart';
import 'package:ar_flutter_plugin/ar_flutter_plugin.dart';
import 'package:ar_flutter_plugin/datatypes/config_planedetection.dart';
class DebugOptionsWidget extends StatefulWidget {
DebugOptionsWidget({Key? key}) : super(key: key);
@override
_DebugOptionsWidgetState createState() => _DebugOptionsWidgetState();
}
class _DebugOptionsWidgetState extends State<DebugOptionsWidget> {
ARSessionManager? arSessionManager;
ARObjectManager? arObjectManager;
bool _showFeaturePoints = false;
bool _showPlanes = false;
bool _showWorldOrigin = false;
bool _showAnimatedGuide = true;
String _planeTexturePath = "Images/triangle.png";
bool _handleTaps = false;
@override
void dispose() {
super.dispose();
arSessionManager!.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Debug Options'),
),
body: Container(
child: Stack(children: [
ARView(
onARViewCreated: onARViewCreated,
planeDetectionConfig: PlaneDetectionConfig.horizontalAndVertical,
showPlatformType: true,
),
Align(
alignment: FractionalOffset.bottomRight,
child: Container(
width: MediaQuery.of(context).size.width * 0.5,
color: Color(0xFFFFFFF).withOpacity(0.5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
SwitchListTile(
title: const Text('Feature Points'),
value: _showFeaturePoints,
onChanged: (bool value) {
setState(() {
_showFeaturePoints = value;
updateSessionSettings();
});
},
),
SwitchListTile(
title: const Text('Planes'),
value: _showPlanes,
onChanged: (bool value) {
setState(() {
_showPlanes = value;
updateSessionSettings();
});
},
),
SwitchListTile(
title: const Text('World Origin'),
value: _showWorldOrigin,
onChanged: (bool value) {
setState(() {
_showWorldOrigin = value;
updateSessionSettings();
});
},
),
],
),
),
),
])));
}
void onARViewCreated(
ARSessionManager arSessionManager,
ARObjectManager arObjectManager,
ARAnchorManager arAnchorManager,
ARLocationManager arLocationManager) {
this.arSessionManager = arSessionManager;
this.arObjectManager = arObjectManager;
this.arSessionManager!.onInitialize(
showFeaturePoints: _showFeaturePoints,
showPlanes: _showPlanes,
customPlaneTexturePath: _planeTexturePath,
showWorldOrigin: _showWorldOrigin,
showAnimatedGuide: _showAnimatedGuide,
handleTaps: _handleTaps,
);
this.arObjectManager!.onInitialize();
}
void updateSessionSettings() {
this.arSessionManager!.onInitialize(
showFeaturePoints: _showFeaturePoints,
showPlanes: _showPlanes,
customPlaneTexturePath: _planeTexturePath,
showWorldOrigin: _showWorldOrigin,
);
}
}

144
AR-TEST/Tests/TestAR.dart Normal file
View File

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:ar_flutter_plugin/ar_flutter_plugin.dart';
import 'package:mymuseum_visitapp/Screens/Tests/DebugOptionsWidget.dart';
import 'package:mymuseum_visitapp/Screens/Tests/cloudtest.dart';
import 'package:mymuseum_visitapp/Screens/Tests/testother.dart';
import 'localtest.dart';
class TestAR extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<TestAR> {
String _platformVersion = 'Unknown';
static const String _title = 'AR Plugin Demo';
@override
void initState() {
super.initState();
initPlatformState();
}
// Platform messages are asynchronous, so we initialize in an async method.
Future<void> initPlatformState() async {
String platformVersion;
// Platform messages may fail, so we use a try/catch PlatformException.
try {
platformVersion = await ArFlutterPlugin.platformVersion;
} on PlatformException {
platformVersion = 'Failed to get platform version.';
}
// If the widget was removed from the tree while the asynchronous platform
// message was in flight, we want to discard the reply rather than calling
// setState to update our non-existent appearance.
if (!mounted) return;
setState(() {
_platformVersion = platformVersion;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text(_title),
),
body: Column(children: [
Text('Running on: $_platformVersion\n'),
Expanded(
child: ExampleList(),
),
]),
),
);
}
}
class ExampleList extends StatelessWidget {
ExampleList({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final examples = [
Example(
'Debug Options',
'Visualize feature points, planes and world coordinate system',
() => Navigator.push(context,
MaterialPageRoute(builder: (context) => DebugOptionsWidget()))),
/*Example(
'Local & Online Objects',
'Place 3D objects from Flutter assets and the web into the scene',
() => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LocalAndWebObjectsWidget()))),*/
Example(
'Anchors & Objects on Planes',
'Place 3D objects on detected planes using anchors',
() => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ObjectsOnPlanesWidget()))),
Example(
'Object Transformation Gestures',
'Rotate and Pan Objects',
() => Navigator.push(context,
MaterialPageRoute(builder: (context) => ObjectGesturesWidget()))),
Example(
'Screenshots',
'Place 3D objects on planes and take screenshots',
() => Navigator.push(context,
MaterialPageRoute(builder: (context) => ScreenshotWidget()))),
/*Example(
'Cloud Anchors',
'Place and retrieve 3D objects using the Google Cloud Anchor API',
() => Navigator.push(context,
MaterialPageRoute(builder: (context) => CloudAnchorWidget()))),
Example(
'External Model Management',
'Similar to Cloud Anchors example, but uses external database to choose from available 3D models',
() => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ExternalModelManagementWidget())))*/
];
return ListView(
children:
examples.map((example) => ExampleCard(example: example)).toList(),
);
}
}
class ExampleCard extends StatelessWidget {
ExampleCard({Key? key, required this.example}) : super(key: key);
final Example example;
@override
build(BuildContext context) {
return Card(
child: InkWell(
splashColor: Colors.blue.withAlpha(30),
onTap: () {
example.onTap();
},
child: ListTile(
title: Text(example.name),
subtitle: Text(example.description),
),
),
);
}
}
class Example {
const Example(this.name, this.description, this.onTap);
final String name;
final String description;
final Function onTap;
}

72
AR-TEST/Tests/XRTest.dart Normal file
View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
import 'package:webview_flutter/webview_flutter.dart';
class XRWithQRScannerPage extends StatefulWidget {
@override
_XRWithQRScannerPageState createState() => _XRWithQRScannerPageState();
}
class _XRWithQRScannerPageState extends State<XRWithQRScannerPage> {
String qrCode = "";
late final WebViewController _webViewController;
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
@override
void initState() {
super.initState();
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadRequest(Uri.parse("https://immersive-web.github.io/webxr-samples/immersive-ar-session.html"))
;
//..loadFlutterAsset('assets/files/xr_environment.html'); // Charge le fichier local
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("XR avec Scanner QR Code")),
body: Stack(
children: [
// La couche QR code scanner (superposée au-dessus de l'XR)
Positioned.fill(
child: QRView(
onQRViewCreated: (controller) {
controller.scannedDataStream.listen((scanData) {
setState(() {
qrCode = scanData.code!;
print('QR Code détecté : $qrCode');
fetchData(qrCode);
});
});
}, key: qrKey,
),
),
// La couche XR (WebXR avec Three.js ou autre moteur)
WebViewWidget(
controller: _webViewController
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Color(0x00000000))
..addJavaScriptChannel(
'WebViewChannel',
onMessageReceived: (message) {
// Message reçu de JavaScript
print(message.message);
showDialog(
context: context,
builder: (context) => AlertDialog(
content: Text(message.message),
),
);
},
),
),
],
),
);
}
Future<void> fetchData(String? qrCode) async {
print('Fetching data for QR Code: $qrCode');
}
}

View File

@ -0,0 +1,170 @@
import 'package:ar_flutter_plugin/managers/ar_location_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_session_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_object_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_anchor_manager.dart';
import 'package:ar_flutter_plugin/models/ar_anchor.dart';
import 'package:flutter/material.dart';
import 'package:ar_flutter_plugin/ar_flutter_plugin.dart';
import 'package:ar_flutter_plugin/datatypes/config_planedetection.dart';
import 'package:ar_flutter_plugin/datatypes/node_types.dart';
import 'package:ar_flutter_plugin/datatypes/hittest_result_types.dart';
import 'package:ar_flutter_plugin/models/ar_node.dart';
import 'package:ar_flutter_plugin/models/ar_hittest_result.dart';
import 'package:flutter/services.dart';
import 'package:vector_math/vector_math_64.dart';
import 'dart:math';
class ObjectGesturesWidget extends StatefulWidget {
ObjectGesturesWidget({Key? key}) : super(key: key);
@override
_ObjectGesturesWidgetState createState() => _ObjectGesturesWidgetState();
}
class _ObjectGesturesWidgetState extends State<ObjectGesturesWidget> {
ARSessionManager? arSessionManager;
ARObjectManager? arObjectManager;
ARAnchorManager? arAnchorManager;
List<ARNode> nodes = [];
List<ARAnchor> anchors = [];
@override
void dispose() {
super.dispose();
arSessionManager!.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Object Transformation Gestures'),
),
body: Container(
child: Stack(children: [
ARView(
onARViewCreated: onARViewCreated,
planeDetectionConfig: PlaneDetectionConfig.horizontalAndVertical,
),
Align(
alignment: FractionalOffset.bottomCenter,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: onRemoveEverything,
child: Text("Remove Everything")),
]),
)
])));
}
void onARViewCreated(
ARSessionManager arSessionManager,
ARObjectManager arObjectManager,
ARAnchorManager arAnchorManager,
ARLocationManager arLocationManager) {
this.arSessionManager = arSessionManager;
this.arObjectManager = arObjectManager;
this.arAnchorManager = arAnchorManager;
this.arSessionManager!.onInitialize(
showFeaturePoints: false,
showPlanes: true,
customPlaneTexturePath: "Images/triangle.png",
showWorldOrigin: true,
handlePans: true,
handleRotation: true,
);
this.arObjectManager!.onInitialize();
this.arSessionManager!.onPlaneOrPointTap = onPlaneOrPointTapped;
this.arObjectManager!.onPanStart = onPanStarted;
this.arObjectManager!.onPanChange = onPanChanged;
this.arObjectManager!.onPanEnd = onPanEnded;
this.arObjectManager!.onRotationStart = onRotationStarted;
this.arObjectManager!.onRotationChange = onRotationChanged;
this.arObjectManager!.onRotationEnd = onRotationEnded;
}
Future<void> onRemoveEverything() async {
/*nodes.forEach((node) {
this.arObjectManager.removeNode(node);
});*/
anchors.forEach((anchor) {
this.arAnchorManager!.removeAnchor(anchor);
});
anchors = [];
}
Future<void> onPlaneOrPointTapped(
List<ARHitTestResult> hitTestResults) async {
var singleHitTestResult = hitTestResults.firstWhere(
(hitTestResult) => hitTestResult.type == ARHitTestResultType.plane);
if (singleHitTestResult != null) {
var newAnchor =
ARPlaneAnchor(transformation: singleHitTestResult.worldTransform);
bool? didAddAnchor = await this.arAnchorManager!.addAnchor(newAnchor);
if (didAddAnchor!) {
this.anchors.add(newAnchor);
// Add note to anchor
var newNode = ARNode(
type: NodeType.webGLB,
uri:
"assets/files/Duck.glb",
scale: Vector3(0.2, 0.2, 0.2),
position: Vector3(0.0, 0.0, 0.0),
rotation: Vector4(1.0, 0.0, 0.0, 0.0));
bool? didAddNodeToAnchor =
await this.arObjectManager!.addNode(newNode, planeAnchor: newAnchor);
if (didAddNodeToAnchor!) {
this.nodes.add(newNode);
} else {
this.arSessionManager!.onError("Adding Node to Anchor failed");
}
} else {
this.arSessionManager!.onError("Adding Anchor failed");
}
}
}
onPanStarted(String nodeName) {
print("Started panning node " + nodeName);
}
onPanChanged(String nodeName) {
print("Continued panning node " + nodeName);
}
onPanEnded(String nodeName, Matrix4 newTransform) {
print("Ended panning node " + nodeName);
final pannedNode =
this.nodes.firstWhere((element) => element.name == nodeName);
/*
* Uncomment the following command if you want to keep the transformations of the Flutter representations of the nodes up to date
* (e.g. if you intend to share the nodes through the cloud)
*/
//pannedNode.transform = newTransform;
}
onRotationStarted(String nodeName) {
print("Started rotating node " + nodeName);
}
onRotationChanged(String nodeName) {
print("Continued rotating node " + nodeName);
}
onRotationEnded(String nodeName, Matrix4 newTransform) {
print("Ended rotating node " + nodeName);
final rotatedNode =
this.nodes.firstWhere((element) => element.name == nodeName);
/*
* Uncomment the following command if you want to keep the transformations of the Flutter representations of the nodes up to date
* (e.g. if you intend to share the nodes through the cloud)
*/
//rotatedNode.transform = newTransform;
}
}

View File

@ -0,0 +1,157 @@
import 'package:ar_flutter_plugin/managers/ar_location_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_session_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_object_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_anchor_manager.dart';
import 'package:ar_flutter_plugin/models/ar_anchor.dart';
import 'package:flutter/material.dart';
import 'package:ar_flutter_plugin/ar_flutter_plugin.dart';
import 'package:ar_flutter_plugin/datatypes/config_planedetection.dart';
import 'package:ar_flutter_plugin/datatypes/node_types.dart';
import 'package:ar_flutter_plugin/datatypes/hittest_result_types.dart';
import 'package:ar_flutter_plugin/models/ar_node.dart';
import 'package:ar_flutter_plugin/models/ar_hittest_result.dart';
import 'package:vector_math/vector_math_64.dart';
class ScreenshotWidget extends StatefulWidget {
const ScreenshotWidget({Key? key}) : super(key: key);
@override
_ScreenshotWidgetState createState() => _ScreenshotWidgetState();
}
class _ScreenshotWidgetState extends State<ScreenshotWidget> {
ARSessionManager? arSessionManager;
ARObjectManager? arObjectManager;
ARAnchorManager? arAnchorManager;
List<ARNode> nodes = [];
List<ARAnchor> anchors = [];
@override
void dispose() {
super.dispose();
arSessionManager!.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Screenshots'),
),
body:
Container(
child:
Stack(children: [
ARView(
onARViewCreated: onARViewCreated,
planeDetectionConfig: PlaneDetectionConfig.horizontalAndVertical,
),
Align(
alignment: FractionalOffset.bottomCenter,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: onRemoveEverything,
child: const Text("Remove Everything")),
ElevatedButton(
onPressed: onTakeScreenshot,
child: const Text("Take Screenshot")),
]),
)
])));
}
void onARViewCreated(
ARSessionManager arSessionManager,
ARObjectManager arObjectManager,
ARAnchorManager arAnchorManager,
ARLocationManager arLocationManager) {
this.arSessionManager = arSessionManager;
this.arObjectManager = arObjectManager;
this.arAnchorManager = arAnchorManager;
this.arSessionManager!.onInitialize(
showFeaturePoints: false,
showPlanes: true,
customPlaneTexturePath: "Images/triangle.png",
showWorldOrigin: true,
);
this.arObjectManager!.onInitialize();
this.arSessionManager!.onPlaneOrPointTap = onPlaneOrPointTapped;
this.arObjectManager!.onNodeTap = onNodeTapped;
}
Future<void> onRemoveEverything() async {
/*nodes.forEach((node) {
this.arObjectManager.removeNode(node);
});*/
// anchors.forEach((anchor)
for (var anchor in anchors)
{
arAnchorManager!.removeAnchor(anchor);
};
anchors = [];
}
Future<void> onTakeScreenshot() async {
var image = await arSessionManager!.snapshot();
await showDialog(
context: context,
builder: (_) => Dialog(
child: Container(
decoration: BoxDecoration(
image: DecorationImage(image: image, fit: BoxFit.cover)),
),
));
}
Future<void> onNodeTapped(List<String> nodes) async {
var number = nodes.length;
arSessionManager!.onError("Tapped $number node(s)");
}
Future<void> onPlaneOrPointTapped(
List<ARHitTestResult> hitTestResults) async {
var singleHitTestResult = hitTestResults.firstWhere(
(hitTestResult) => hitTestResult.type == ARHitTestResultType.plane);
if (singleHitTestResult != null) {
var newAnchor =
ARPlaneAnchor(transformation: singleHitTestResult.worldTransform);
bool? didAddAnchor = await arAnchorManager!.addAnchor(newAnchor);
if (didAddAnchor != null && didAddAnchor) {
anchors.add(newAnchor);
// Add note to anchor
var newNode = ARNode(
type: NodeType.webGLB,
uri:
"https://github.com/KhronosGroup/glTF-Sample-Models/blob/main/2.0/Duck/glTF-Binary/Duck.glb",
scale: Vector3(0.2, 0.2, 0.2),
position: Vector3(0.0, 0.0, 0.0),
rotation: Vector4(1.0, 0.0, 0.0, 0.0));
bool? didAddNodeToAnchor =
await arObjectManager!.addNode(newNode, planeAnchor: newAnchor);
if (didAddNodeToAnchor != null && didAddNodeToAnchor) {
nodes.add(newNode);
} else {
arSessionManager!.onError("Adding Node to Anchor failed");
}
} else {
arSessionManager!.onError("Adding Anchor failed");
}
/*
// To add a node to the tapped position without creating an anchor, use the following code (Please mind: the function onRemoveEverything has to be adapted accordingly!):
var newNode = ARNode(
type: NodeType.localGLTF2,
uri: "Models/Chicken_01/Chicken_01.gltf",
scale: Vector3(0.2, 0.2, 0.2),
transformation: singleHitTestResult.worldTransform);
bool didAddWebNode = await this.arObjectManager.addNode(newNode);
if (didAddWebNode) {
this.nodes.add(newNode);
}*/
}
}
}

View File

@ -0,0 +1,139 @@
import 'package:ar_flutter_plugin/managers/ar_location_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_session_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_object_manager.dart';
import 'package:ar_flutter_plugin/managers/ar_anchor_manager.dart';
import 'package:ar_flutter_plugin/models/ar_anchor.dart';
import 'package:flutter/material.dart';
import 'package:ar_flutter_plugin/ar_flutter_plugin.dart';
import 'package:ar_flutter_plugin/datatypes/config_planedetection.dart';
import 'package:ar_flutter_plugin/datatypes/node_types.dart';
import 'package:ar_flutter_plugin/datatypes/hittest_result_types.dart';
import 'package:ar_flutter_plugin/models/ar_node.dart';
import 'package:ar_flutter_plugin/models/ar_hittest_result.dart';
import 'package:flutter/services.dart';
import 'package:vector_math/vector_math_64.dart';
import 'dart:math';
class ObjectsOnPlanesWidget extends StatefulWidget {
ObjectsOnPlanesWidget({Key? key}) : super(key: key);
@override
_ObjectsOnPlanesWidgetState createState() => _ObjectsOnPlanesWidgetState();
}
class _ObjectsOnPlanesWidgetState extends State<ObjectsOnPlanesWidget> {
ARSessionManager? arSessionManager;
ARObjectManager? arObjectManager;
ARAnchorManager? arAnchorManager;
List<ARNode> nodes = [];
List<ARAnchor> anchors = [];
@override
void dispose() {
super.dispose();
arSessionManager!.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Anchors & Objects on Planes'),
),
body: Container(
child: Stack(children: [
ARView(
onARViewCreated: onARViewCreated,
planeDetectionConfig: PlaneDetectionConfig.horizontalAndVertical,
),
Align(
alignment: FractionalOffset.bottomCenter,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: onRemoveEverything,
child: Text("Remove Everything")),
]),
)
])));
}
void onARViewCreated(
ARSessionManager arSessionManager,
ARObjectManager arObjectManager,
ARAnchorManager arAnchorManager,
ARLocationManager arLocationManager) {
this.arSessionManager = arSessionManager;
this.arObjectManager = arObjectManager;
this.arAnchorManager = arAnchorManager;
this.arSessionManager!.onInitialize(
showFeaturePoints: false,
showPlanes: true,
customPlaneTexturePath: "Images/triangle.png",
showWorldOrigin: true,
);
this.arObjectManager!.onInitialize();
this.arSessionManager!.onPlaneOrPointTap = onPlaneOrPointTapped;
this.arObjectManager!.onNodeTap = onNodeTapped;
}
Future<void> onRemoveEverything() async {
/*nodes.forEach((node) {
this.arObjectManager.removeNode(node);
});*/
anchors.forEach((anchor) {
this.arAnchorManager!.removeAnchor(anchor);
});
anchors = [];
}
Future<void> onNodeTapped(List<String> nodes) async {
var number = nodes.length;
this.arSessionManager!.onError("Tapped $number node(s)");
}
Future<void> onPlaneOrPointTapped(
List<ARHitTestResult> hitTestResults) async {
var singleHitTestResult = hitTestResults.firstWhere(
(hitTestResult) => hitTestResult.type == ARHitTestResultType.plane);
if (singleHitTestResult != null) {
var newAnchor =
ARPlaneAnchor(transformation: singleHitTestResult.worldTransform);
bool? didAddAnchor = await this.arAnchorManager!.addAnchor(newAnchor);
if (didAddAnchor!) {
this.anchors.add(newAnchor);
// Add note to anchor
var newNode = ARNode(
type: NodeType.webGLB,
uri:
"https://github.com/KhronosGroup/glTF-Sample-Models/blob/main/2.0/Duck/glTF-Binary/Duck.glb",
scale: Vector3(0.2, 0.2, 0.2),
position: Vector3(0.0, 0.0, 0.0),
rotation: Vector4(1.0, 0.0, 0.0, 0.0));
bool? didAddNodeToAnchor =
await this.arObjectManager!.addNode(newNode, planeAnchor: newAnchor);
if (didAddNodeToAnchor!) {
this.nodes.add(newNode);
} else {
this.arSessionManager!.onError("Adding Node to Anchor failed");
}
} else {
this.arSessionManager!.onError("Adding Anchor failed");
}
/*
// To add a node to the tapped position without creating an anchor, use the following code (Please mind: the function onRemoveEverything has to be adapted accordingly!):
var newNode = ARNode(
type: NodeType.localGLTF2,
uri: "Models/Chicken_01/Chicken_01.gltf",
scale: Vector3(0.2, 0.2, 0.2),
transformation: singleHitTestResult.worldTransform);
bool didAddWebNode = await this.arObjectManager.addNode(newNode);
if (didAddWebNode) {
this.nodes.add(newNode);
}*/
}
}
}

58
CLAUDE.md Normal file
View File

@ -0,0 +1,58 @@
# mymuseum-visitapp
App Flutter pour les visiteurs finaux, installée sur leurs propres devices (smartphone/tablette).
## Fonctionnement
- Le visiteur peut scanner un **QR code** pour afficher le détail d'un contenu
- Les **beacons** servent à suggérer l'affichage de contenu selon la position du visiteur
- Accès au contenu configuré dans `manager-app` pour son instance
## Stack
- Flutter mobile (Android + iOS)
- State management : **Provider** + **GetX** (hybride)
- `ChangeNotifier` pour le contexte global (VisitContext)
- `Get.put()` pour les contrôleurs GetX (ex: `RequirementStateController`)
- Gestion des langues via `translations.dart` (Helpers) : les langues sont récupérées depuis le backend, pas depuis la locale du device — c'est pourquoi on n'utilise pas `flutter_localizations` pour le contenu
- SQLite (`sqflite`) pour le cache local
- Firebase Messaging + Local Notifications (push notifications)
## Client API
Dépendance locale sur le client généré dans `manager-app` :
```yaml
manager_api_new:
path: ../manager-app/manager_api_new
```
**Ne pas copier/dupliquer le client** — toujours pointer vers `manager-app/manager_api_new`.
## Structure
```
lib/
├── api/ # Intégration OpenAPI
├── Components/ # AppBar, language selector, scanner, image/video viewers
├── Helpers/ # DatabaseHelper, networkCheck, translationHelper, modelsHelper
├── l10n/ # ARB files présents mais non utilisés pour le contenu (voir translationHelper)
├── Models/ # VisitContext, beaconSection, articleRead, agenda, weatherData
├── Screens/
│ ├── Home/ # Liste des configurations, écran d'accueil
│ ├── ConfigurationPage/ # Setup avec détection beacon
│ └── Sections/ # Agenda, Article, Game, Map, Menu, PDF,
│ # Quiz, Slider, Video, Weather, Web
└── Services/ # apiService, assistantService, downloadConfiguration,
# pushNotificationService, statisticsService
```
## Particularités vs tablet-app
- Langues gérées via `translationHelper` + données backend (pas via ARB/flutter_localizations pour le contenu)
- Push notifications (Firebase Messaging)
- Scanner QR (`mobile_scanner`) + support beacons
- Speech-to-text (`speech_to_text`)
- Pas de MQTT
- GetX en plus de Provider pour certains contrôleurs
## Commandes utiles
```bash
flutter run # Lancer sur device/émulateur connecté
flutter build apk # Build Android
flutter build ios # Build iOS
flutter gen-l10n # (peu utile, langues gérées via backend)
```

141
README.md
View File

@ -52,14 +52,135 @@ flutter build appbundle
Faut pas oublier d'aller changer la version avant chaque upload de version (Puis mettre l'app bundle dans le dossier du PC, peut-être mettre sur le repos aussi ?) Faut pas oublier d'aller changer la version avant chaque upload de version (Puis mettre l'app bundle dans le dossier du PC, peut-être mettre sur le repos aussi ?)
# update app # Clients existants
1) Mettre à jour l'instance Id | Flavor | Package Android | Bundle iOS | Instance ID | Nom app |
- MDLF : 65ccc67265373befd15be511 |---|---|---|---|---|
- Fort : 633ee379d9405f32f166f047 | `mdlf` | `be.unov.mymuseum.mdlf` | `be.unov.mymuseum.mdlf` | `65ccc67265373befd15be511` | MDLF |
2) Mettre à jour l'icone (dans pubspec.yaml ref icon) | `fortsaintheribert` | `be.unov.mymuseum.fortsaintheribert` | `be.unov.mymuseum.fortsaintheribert` | `633ee379d9405f32f166f047` | Fort Saint-Héribert |
-> https://easyappicon.com/ | `dev` | `be.unov.myinfomate.test` | `be.unov.myinfomate.test` | `63514fd67ed8c735aaa4b8f2` | MyMuseum Dev |
3) Mettre à jour android manifest et info.plist (faire un CTRL MAJ F et replace..)
- be.unov.myinfomate.mdlf # Builder pour un client
- android : be.unov.mymuseum.fortsaintheribert - iod: be.unov.myvisit.mymuseumVisitapp
4) Mettre à jour les couleurs de l'app Les paramètres à passer à chaque build :
- `FLAVOR` — nom du flavor (`dev`, `mdlf`, `fortsaintheribert`, ...)
- `INSTANCE_ID` — le GUID de l'instance dans le backend
- `API_BASE_URL` — l'URL de l'API backend
- `API_KEY` — la clé API de l'instance (générée via le Manager, endpoint `/api/apikey`)
> La clé API est embarquée dans le binaire au moment du build. Elle est récupérable dans le Manager.
## Android (App Bundle pour Play Store)
```
flutter build appbundle --flavor mdlf --release -t lib/main.dart \
--dart-define=FLAVOR=mdlf \
--dart-define=INSTANCE_ID=65ccc67265373befd15be511 \
--dart-define=API_BASE_URL=https://api.mymuseum.be \
--dart-define=API_KEY=CLE_API_MDLF
flutter build appbundle --flavor fortsaintheribert --release -t lib/main.dart \
--dart-define=FLAVOR=fortsaintheribert \
--dart-define=INSTANCE_ID=633ee379d9405f32f166f047 \
--dart-define=API_BASE_URL=https://api.mymuseum.be \
--dart-define=API_KEY=CLE_API_FORTSAINTHERIBERT
```
## iOS (fichier .ipa pour App Store)
> IPA = format de fichier d'une app iOS, équivalent de l'App Bundle Android. C'est ce qu'on upload sur l'App Store Connect.
```
flutter build ipa --flavor mdlf --release -t lib/main.dart \
--dart-define=FLAVOR=mdlf \
--dart-define=INSTANCE_ID=65ccc67265373befd15be511 \
--dart-define=API_BASE_URL=https://api.mymuseum.be \
--dart-define=API_KEY=CLE_API_MDLF
flutter build ipa --flavor fortsaintheribert --release -t lib/main.dart \
--dart-define=FLAVOR=fortsaintheribert \
--dart-define=INSTANCE_ID=633ee379d9405f32f166f047 \
--dart-define=API_BASE_URL=https://api.mymuseum.be \
--dart-define=API_KEY=CLE_API_FORTSAINTHERIBERT
```
## Dev local (flavor dev)
```
flutter run --flavor dev -t lib/main.dart \
--dart-define=FLAVOR=dev \
--dart-define=INSTANCE_ID=63514fd67ed8c735aaa4b8f2 \
--dart-define=API_BASE_URL=http://192.168.x.x:5000 \
--dart-define=API_KEY=CLE_API_DEV
```
## Icône de l'app
Pour mettre une icône spécifique par client, utilise https://easyappicon.com/ pour générer les assets,
puis place-les dans `android/app/src/{flavor}/res/mipmap-*/ic_launcher.png`
(ils remplaceront automatiquement l'icône par défaut au build).
# Ajouter un nouveau client
Exemple avec un client nommé `newclient` → package `be.unov.myinfomate.newclient`
**1. Firebase Console** → projet `mymuseum-3b97f`
- Ajouter app Android (`be.unov.myinfomate.newclient`) → re-télécharger `google-services.json` → le copier dans `android/app/src/newclient/google-services.json` (et mettre à jour les autres flavors aussi)
- Ajouter app iOS (`be.unov.myinfomate.newclient`) → télécharger `GoogleService-Info.plist` → sauvegarder dans `ios/config/newclient/GoogleService-Info.plist`
- Uploader l'APNs Auth Key dans Firebase pour la nouvelle app iOS (même clé que les autres apps)
**2. Apple Developer Portal**
- Créer App ID `be.unov.myinfomate.newclient` avec Push Notifications activé
**3. `android/app/build.gradle`** : ajouter le flavor dans `productFlavors`
```groovy
newclient {
dimension "client"
applicationId "be.unov.myinfomate.newclient"
resValue "string", "app_name", "Nom de l'app"
}
```
**4. Xcode** : dupliquer les 3 configs (`Debug`, `Release`, `Profile`) pour le nouveau flavor, créer le scheme partagé
- Build Settings → `PRODUCT_BUNDLE_IDENTIFIER` = `be.unov.myinfomate.newclient`
- Build Settings → ajouter `FLUTTER_FLAVOR` = `newclient`
**5. Mettre à jour le tableau "Clients existants"** en haut de ce README avec le nouveau client
**6. Ajouter les couleurs dans `lib/constants.dart`**
Ajouter un nouveau cas dans chaque ternaire (`kMainColor0`, `kMainColor1`, `kMainColor2`) :
```dart
_flavor == 'newclient' ? 0xFFxxxxxx : // couleur principale du client
```
**7. Ajouter les assets splash et loader**
- `assets/splash/newclient.png` — logo affiché au démarrage de l'app (fond transparent, ~400×400px)
- `assets/loader/newclient.png` — icône du loader in-app, affiché en rotation pendant les chargements (fond transparent, ~200×200px)
Puis ajouter le nouveau flavor dans les deux constantes de `lib/constants.dart` :
```dart
const kSplashLogoAsset = _flavor == 'mdlf'
? 'assets/splash/mdlf.png'
: _flavor == 'fortsaintheribert'
? 'assets/splash/fortsaintheribert.png'
: _flavor == 'newclient'
? 'assets/splash/newclient.png'
: 'assets/splash/dev.png';
const kLoaderAsset = _flavor == 'mdlf'
? 'assets/loader/mdlf.png'
: _flavor == 'fortsaintheribert'
? 'assets/loader/fortsaintheribert.png'
: _flavor == 'newclient'
? 'assets/loader/newclient.png'
: 'assets/loader/dev.png';
```
> Si tu ne fournis pas d'image, le fallback est automatique : `Icons.museum_outlined` pour le loader, `Icons.museum_outlined` pour le splash. Mais l'image doit quand même exister dans `assets/` (même un placeholder 1×1px) car Flutter vérifie les assets déclarés au build.
**8. Tester**
```
flutter run --flavor newclient -t lib/main.dart \
--dart-define=FLAVOR=newclient \
--dart-define=INSTANCE_ID=GUID_DU_CLIENT \
--dart-define=API_BASE_URL=https://api.mymuseum.be
```
> ⚠️ Les fichiers `ios/config/*/GoogleService-Info.plist` marqués PLACEHOLDER doivent être remplacés par les vrais fichiers téléchargés depuis Firebase Console avant de builder en production iOS.

View File

@ -3,6 +3,7 @@ plugins {
id "kotlin-android" id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
id "com.google.gms.google-services"
} }
def localProperties = new Properties() def localProperties = new Properties()
@ -39,11 +40,21 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"*/ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"*/
android { android {
compileSdkVersion 34 namespace = "be.unov.mymuseum.fortsaintheribert"
compileSdkVersion 36
ndkVersion "27.0.12077973"
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
coreLibraryDesugaringEnabled true
}
packagingOptions {
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
} }
kotlinOptions { kotlinOptions {
@ -55,13 +66,47 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). minSdkVersion 29 // meta_wearables SDK requires API 29+
applicationId "be.unov.mymuseum.fortsaintheribert" // Update for mdlf and other clients -- "be.unov.mymuseum.fortsaintheribert" // be.unov.myinfomate.mdlf
minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
multiDexEnabled true multiDexEnabled true
manifestPlaceholders = [
mwdatCallbackScheme: "mwdat-myinfomate",
applicationName: "io.flutter.app.FlutterApplication"
]
}
flavorDimensions "client"
productFlavors {
dev {
dimension "client"
applicationId "be.unov.myinfomate.test"
resValue "string", "app_name", "MyMuseum Dev"
manifestPlaceholders = [
mwdatCallbackScheme: "mwdat-myinfomate-dev",
applicationName: "io.flutter.app.FlutterApplication"
]
}
mdlf {
dimension "client"
applicationId "be.unov.mymuseum.mdlf"
resValue "string", "app_name", "MDLF"
manifestPlaceholders = [
mwdatCallbackScheme: "mwdat-mymuseum-mdlf",
applicationName: "io.flutter.app.FlutterApplication"
]
}
fortsaintheribert {
dimension "client"
applicationId "be.unov.mymuseum.fortsaintheribert"
resValue "string", "app_name", "Fort Saint-Héribert"
manifestPlaceholders = [
mwdatCallbackScheme: "mwdat-mymuseum-fortsaintheribert",
applicationName: "io.flutter.app.FlutterApplication"
]
}
} }
signingConfigs { signingConfigs {
@ -88,6 +133,8 @@ flutter {
source '../..' source '../..'
} }
/*dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
}*/ implementation 'org.tensorflow:tensorflow-lite:2.12.0'
implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.16.0'
}

View File

@ -0,0 +1,105 @@
{
"project_info": {
"project_number": "1034665398515",
"project_id": "mymuseum-3b97f",
"storage_bucket": "mymuseum-3b97f.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:b9731ff810b0a2a7d6a786",
"android_client_info": {
"package_name": "be.unov.myinfomate.tablet"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:2911c4647a36e47cd6a786",
"android_client_info": {
"package_name": "be.unov.myinfomate.test"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:efe01f2db9674c78d6a786",
"android_client_info": {
"package_name": "be.unov.mymuseum.fortsaintheribert"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:23d9de7735f898e5d6a786",
"android_client_info": {
"package_name": "be.unov.mymuseum.mdlf"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:b7475582b41ed32dd6a786",
"android_client_info": {
"package_name": "be.unov.mymuseum.tablet_app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@ -0,0 +1,105 @@
{
"project_info": {
"project_number": "1034665398515",
"project_id": "mymuseum-3b97f",
"storage_bucket": "mymuseum-3b97f.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:b9731ff810b0a2a7d6a786",
"android_client_info": {
"package_name": "be.unov.myinfomate.tablet"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:2911c4647a36e47cd6a786",
"android_client_info": {
"package_name": "be.unov.myinfomate.test"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:efe01f2db9674c78d6a786",
"android_client_info": {
"package_name": "be.unov.mymuseum.fortsaintheribert"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:23d9de7735f898e5d6a786",
"android_client_info": {
"package_name": "be.unov.mymuseum.mdlf"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:b7475582b41ed32dd6a786",
"android_client_info": {
"package_name": "be.unov.mymuseum.tablet_app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -1,7 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="be.unov.mymuseum.fortsaintheribert">
<!-- package="be.unov.myinfomate.mdlf"> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
@ -9,6 +8,12 @@
<!--<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />--> <!--<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />-->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Foreground Service — garde le wake word actif téléphone en poche -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Request legacy Bluetooth permissions on older devices. --> <!-- Request legacy Bluetooth permissions on older devices. -->
<!--<uses-permission android:name="android.permission.BLUETOOTH" <!--<uses-permission android:name="android.permission.BLUETOOTH"
@ -20,10 +25,31 @@
<uses-feature android:name="android.hardware.camera" />--> <uses-feature android:name="android.hardware.camera" />-->
<!-- android:label="Fort Saint Héribert" "Musée de la fraise" --> <!-- android:label="Fort Saint Héribert" "Musée de la fraise" -->
<application <application
android:label="Fort Saint Héribert" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<!-- Meta Wearables DAT — "0" = Developer Mode (String via resource), remplacer en production -->
<meta-data
android:name="com.meta.wearable.mwdat.APPLICATION_ID"
android:value="@string/mwdat_app_id" />
<!-- FileProvider requis par meta_wearables_dat pour sauvegarder les photos capturées -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".WakeWordService"
android:foregroundServiceType="dataSync|microphone"
android:exported="false" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@ -51,7 +77,8 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<meta-data android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyDg6ApuZb6TRsauIyHJ9-XVwGYeh7MsWXE"/>
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_icon" android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/logo" /> android:resource="@drawable/logo" />

View File

@ -1,6 +1,167 @@
package be.unov.mymuseum.fortsaintheribert package be.unov.mymuseum.fortsaintheribert
import io.flutter.embedding.android.FlutterActivity import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() { class MainActivity : FlutterFragmentActivity() {
private val audioRoutingChannel = "be.unov.mymuseum/audio_routing"
private val wakeWordChannel = "be.unov.mymuseum/wake_word"
private val wakeWordEventsChannel = "be.unov.mymuseum/wake_word_events"
private var wakeWordCacheDir: String = ""
companion object {
// Accès direct depuis WakeWordService (même processus)
@JvmStatic var eventSink: EventChannel.EventSink? = null
}
private val wakeWordReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val event = intent.getStringExtra(WakeWordService.EXTRA_EVENT) ?: return
android.util.Log.d("MainActivity", "WakeWord event received: $event — eventSink=${eventSink != null}")
runOnUiThread {
if (eventSink != null) {
eventSink?.success(event)
} else {
android.util.Log.w("MainActivity", "eventSink is null — Flutter stream not active!")
}
}
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Audio routing (BT SCO)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, audioRoutingChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
"enableBluetoothOutput" -> { enableBluetoothOutput(); result.success(null) }
"restoreDefaultOutput" -> { restoreDefaultOutput(); result.success(null) }
else -> result.notImplemented()
}
}
// Start / stop du service wake word
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, wakeWordChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
"start" -> {
requestBatteryOptimizationExemption()
val modelName = call.argument<String>("modelName")
?: WakeWordService.DEFAULT_MODEL
// activeInstance dans WakeWordService gère le cleanup de l'instance précédente
startService(
Intent(this, WakeWordService::class.java)
.setAction(WakeWordService.ACTION_START)
.putExtra(WakeWordService.EXTRA_MODEL_NAME, modelName)
.putExtra(WakeWordService.EXTRA_CACHE_DIR, wakeWordCacheDir)
)
result.success(null)
}
"setCacheDir" -> {
// Flutter nous passe le chemin où les modèles ont été extraits
val path = call.argument<String>("path") ?: ""
wakeWordCacheDir = path
result.success(null)
}
"pause" -> {
startService(
Intent(this, WakeWordService::class.java)
.setAction(WakeWordService.ACTION_PAUSE)
)
result.success(null)
}
"resume" -> {
startService(
Intent(this, WakeWordService::class.java)
.setAction(WakeWordService.ACTION_RESUME)
)
result.success(null)
}
"stop" -> {
startService(
Intent(this, WakeWordService::class.java)
.setAction(WakeWordService.ACTION_STOP)
)
result.success(null)
}
else -> result.notImplemented()
}
}
// Stream des événements wake word vers Flutter
EventChannel(flutterEngine.dartExecutor.binaryMessenger, wakeWordEventsChannel)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
eventSink = sink
val filter = IntentFilter(WakeWordService.EVENT_ACTION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(wakeWordReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(wakeWordReceiver, filter)
}
}
override fun onCancel(arguments: Any?) {
eventSink = null
try { unregisterReceiver(wakeWordReceiver) } catch (_: Exception) {}
}
})
}
private fun requestBatteryOptimizationExemption() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
startActivity(
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
.setData(Uri.parse("package:$packageName"))
)
}
}
}
private fun enableBluetoothOutput() {
val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val btDevice = audioManager.availableCommunicationDevices
.firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP }
?: audioManager.availableCommunicationDevices
.firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
if (btDevice != null) audioManager.setCommunicationDevice(btDevice)
} else {
@Suppress("DEPRECATION")
audioManager.startBluetoothSco()
@Suppress("DEPRECATION")
audioManager.isBluetoothScoOn = true
}
}
private fun restoreDefaultOutput() {
val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
audioManager.mode = AudioManager.MODE_NORMAL
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
} else {
@Suppress("DEPRECATION")
audioManager.stopBluetoothSco()
@Suppress("DEPRECATION")
audioManager.isBluetoothScoOn = false
}
}
} }

View File

@ -0,0 +1,518 @@
package be.unov.mymuseum.fortsaintheribert
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Build
import android.content.pm.ServiceInfo
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
import org.tensorflow.lite.Interpreter
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Pipeline OpenWakeWord :
* AudioRecord (16kHz PCM16)
* chunks de 1280 samples (80ms)
* melspectrogram.onnx [n_frames, 32] via ONNX Runtime (supporte formes dynamiques)
* fenêtres de 76 frames, stride 8
* embedding_model.tflite [1, 96] par fenêtre
* ring buffer des 16 derniers embeddings
* {model}.tflite score [0..1]
* score 0.5 broadcast "detected"
*/
class WakeWordService : Service() {
companion object {
const val ACTION_START = "be.unov.mymuseum.WAKE_WORD_START"
const val ACTION_STOP = "be.unov.mymuseum.WAKE_WORD_STOP"
const val ACTION_PAUSE = "be.unov.mymuseum.WAKE_WORD_PAUSE" // pause AudioRecord (pendant STT)
const val ACTION_RESUME = "be.unov.mymuseum.WAKE_WORD_RESUME" // reprend après STT
// Instance active — garantit qu'une seule instance tourne à la fois
private var activeInstance: WakeWordService? = null
const val EVENT_ACTION = "be.unov.mymuseum.WAKE_WORD_EVENT"
const val EXTRA_EVENT = "event"
const val EXTRA_MODEL_NAME = "modelName"
const val DEFAULT_MODEL = "hey_visit"
const val EXTRA_CACHE_DIR = "cacheDir"
private const val CHANNEL_ID = "wake_word_channel_v2" // v2 force recréation du canal
private const val NOTIFICATION_ID = 1338
private const val TAG = "WakeWordService"
private const val SAMPLE_RATE = 16000
private const val CHUNK_SAMPLES = 1280 // 80ms @ 16kHz
private const val MEL_BINS = 32
private const val MEL_WINDOW = 76 // frames → embedding model
private const val MEL_STRIDE = 8
private const val EMBEDDING_DIM = 96
private const val N_EMBEDDING_FRAMES = 16
private const val DETECTION_THRESHOLD = 0.1f // Calibrer après re-entraînement avec 50k exemples
private const val COOLDOWN_MS = 2000L
}
private var audioRecord: AudioRecord? = null
private var silenceTrack: android.media.AudioTrack? = null // maintient session audio active (pattern OpenGlasses)
private var audioFocusRequest: AudioFocusRequest? = null
private var ortEnv: OrtEnvironment? = null
private var melSession: OrtSession? = null
private var embeddingInterpreter: Interpreter? = null
private var classifierInterpreter: Interpreter? = null
private var isRunning = false
@Volatile private var requestedPause = false // demande depuis main thread
private var isPaused = false // état réel — géré uniquement par le thread capture
private var lastDetectionTime = 0L
private var modelName = DEFAULT_MODEL
private var wakeLock: PowerManager.WakeLock? = null
private val melBuffer = ArrayDeque<FloatArray>()
private val embeddingBuffer = ArrayDeque<FloatArray>()
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
// Stoppe l'instance précédente si elle tourne encore
activeInstance?.let {
if (it !== this) {
Log.d(TAG, "Stopping previous instance before starting new one")
it.stopDetection()
}
}
activeInstance = this
modelName = intent.getStringExtra(EXTRA_MODEL_NAME) ?: DEFAULT_MODEL
modelCacheDir = intent.getStringExtra(EXTRA_CACHE_DIR) ?: ""
startDetection()
}
ACTION_PAUSE -> {
requestedPause = true
Log.d(TAG, "Pause requested — AudioRecord will stop on next loop iteration")
}
ACTION_RESUME -> {
requestedPause = false
Log.d(TAG, "Resume requested — AudioRecord will restart on next loop iteration")
}
ACTION_STOP -> {
activeInstance = null
stopDetection()
stopSelf()
}
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() { stopDetection(); super.onDestroy() }
// ── Start / Stop ──────────────────────────────────────────────────────────
private fun startDetection() {
if (isRunning) return
isRunning = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID, buildNotification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
)
} else {
startForeground(NOTIFICATION_ID, buildNotification())
}
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MyVisit:WakeWordLock").apply { acquire() }
Thread {
try {
loadModels()
initAudioRecord()
runLoop()
} catch (e: Exception) {
Log.e(TAG, "Fatal error", e)
broadcast("error")
}
}.start()
}
private fun stopDetection() {
isRunning = false
requestedPause = false
isPaused = false
reacquireFocusHandler?.removeCallbacksAndMessages(null)
reacquireFocusHandler = null
audioRecord?.stop(); audioRecord?.release(); audioRecord = null
silenceTrack?.stop(); silenceTrack?.release(); silenceTrack = null
val am = getSystemService(AUDIO_SERVICE) as? AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { am?.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
am?.abandonAudioFocus(audioFocusListener)
}
audioFocusRequest = null
melSession?.close(); melSession = null
ortEnv?.close(); ortEnv = null
embeddingInterpreter?.close(); embeddingInterpreter = null
classifierInterpreter?.close(); classifierInterpreter = null
melBuffer.clear(); embeddingBuffer.clear()
audioManager = null
if (wakeLock?.isHeld == true) wakeLock?.release()
wakeLock = null
}
// ── Model loading ─────────────────────────────────────────────────────────
private fun loadModels() {
// mel : ONNX Runtime → supporte [1, ?] dynamique sans overflow
ortEnv = OrtEnvironment.getEnvironment()
val melFile = extractAsset("melspectrogram.onnx")
melSession = ortEnv!!.createSession(melFile.absolutePath, OrtSession.SessionOptions())
// embedding + classifieur : TFLite (formes statiques, pas de problème)
embeddingInterpreter = loadTflite("embedding_model.tflite")
classifierInterpreter = loadTflite("$modelName.tflite")
Log.d(TAG, "Models loaded — mel=ONNX, embedding+classifier=TFLite, model=$modelName")
}
private fun loadTflite(assetName: String): Interpreter {
val file = extractAsset(assetName)
val options = Interpreter.Options().apply {
setUseXNNPACK(false)
setNumThreads(2)
}
return Interpreter(file, options)
}
// Répertoire où Flutter a extrait les modèles (transmis via setCacheDir)
var modelCacheDir: String = ""
private fun extractAsset(assetName: String): File {
// Priorité : répertoire transmis par Flutter (toujours accessible)
if (modelCacheDir.isNotEmpty()) {
val file = File(modelCacheDir, assetName)
if (file.exists()) return file
}
// Fallback : AssetManager (fonctionne en release)
val file = File(cacheDir, assetName)
if (!file.exists()) {
assets.open("flutter_assets/assets/files/$assetName").use { input ->
FileOutputStream(file).use { output -> input.copyTo(output) }
}
}
return file
}
// ── Audio ──────────────────────────────────────────────────────────────────
private var audioManager: android.media.AudioManager? = null
private var reacquireFocusHandler: android.os.Handler? = null
// Listener et requête créés UNE SEULE FOIS — réutiliser le même objet évite
// qu'une nouvelle requête envoie AUDIOFOCUS_LOSS à l'ancienne, ce qui créait une boucle infinie.
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.d(TAG, "AudioFocus lost ($focusChange) — will re-acquire in 1.5s")
reacquireFocusHandler?.removeCallbacksAndMessages(null)
reacquireFocusHandler = android.os.Handler(android.os.Looper.getMainLooper()).also { h ->
h.postDelayed({
if (isRunning) {
val am = getSystemService(AUDIO_SERVICE) as? AudioManager
// Même requête, même listener — pas de boucle
audioFocusRequest?.let { am?.requestAudioFocus(it) }
Log.d(TAG, "AudioFocus re-acquired")
}
}, 1500)
}
}
AudioManager.AUDIOFOCUS_GAIN -> Log.d(TAG, "AudioFocus gained")
}
}
private fun acquireAudioFocus() {
val am = getSystemService(AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (audioFocusRequest == null) {
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener(audioFocusListener)
.build()
}
am.requestAudioFocus(audioFocusRequest!!)
} else {
@Suppress("DEPRECATION")
am.requestAudioFocus(audioFocusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
}
Log.d(TAG, "AudioFocus acquired")
}
private fun initAudioRecord() {
acquireAudioFocus()
// Silent AudioTrack — maintient la session audio active même en background (pattern OpenGlasses iOS)
// iOS : AVAudioEngine en .playAndRecord permanent | Android : AudioTrack silence en loop
val silenceBufSize = android.media.AudioTrack.getMinBufferSize(
16000, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT
)
silenceTrack = android.media.AudioTrack(
android.media.AudioManager.STREAM_MUSIC,
16000,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
silenceBufSize * 2,
android.media.AudioTrack.MODE_STREAM
)
// Bruit blanc Gaussien sigma=10 (~0.03% du max) — complètement inaudible, au-dessus du seuil "zero".
// PAS de setVolume(0f) — le [mute] résultant déclenche MIUI encore plus vite que [zero].
val rng = java.util.Random(42L)
val noiseBuffer = ShortArray(silenceBufSize) { (rng.nextGaussian() * 10.0).toInt().toShort() }
silenceTrack?.play()
Thread {
while (isRunning) {
val written = silenceTrack?.write(noiseBuffer, 0, noiseBuffer.size) ?: 0
if (written <= 0) {
// Buffer plein ou track suspendu — on attend avant de réessayer
Thread.sleep(100)
} else {
Thread.sleep(50)
}
}
}.also { it.isDaemon = true; it.start() }
Log.d(TAG, "Silent AudioTrack started (keeps audio session alive)")
val minBuf = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, CHUNK_SAMPLES * 4)
)
audioRecord!!.startRecording()
Log.d(TAG, "AudioRecord started at ${SAMPLE_RATE}Hz")
}
// ── Main loop ──────────────────────────────────────────────────────────────
private var debugFrameCount = 0
private fun runLoop() {
val pcm = ShortArray(CHUNK_SAMPLES)
while (isRunning) {
// Transition pause — géré uniquement ici, le seul thread qui touche AudioRecord
if (requestedPause && !isPaused) {
audioRecord?.stop()
isPaused = true
melBuffer.clear()
embeddingBuffer.clear()
Log.d(TAG, "AudioRecord stopped — STT can now use the mic")
}
if (!requestedPause && isPaused) {
audioRecord?.startRecording()
isPaused = false
Log.d(TAG, "AudioRecord restarted — wake word listening resumed")
}
if (isPaused) { Thread.sleep(20); continue }
val read = audioRecord?.read(pcm, 0, CHUNK_SAMPLES) ?: break
if (read <= 0) continue
val newFrames = computeMelOnnx(pcm, read)
melBuffer.addAll(newFrames)
// Debug toutes les 50 itérations (~4s)
debugFrameCount++
if (debugFrameCount % 50 == 0) {
Log.d(TAG, "DEBUG — melBuffer=${melBuffer.size} embBuf=${embeddingBuffer.size} newMelFrames=${newFrames.size}")
}
while (melBuffer.size >= MEL_WINDOW) {
val window = melBuffer.take(MEL_WINDOW)
repeat(MEL_STRIDE) { if (melBuffer.isNotEmpty()) melBuffer.removeFirst() }
val embedding = computeEmbedding(window)
embeddingBuffer.addLast(embedding)
if (embeddingBuffer.size > N_EMBEDDING_FRAMES) embeddingBuffer.removeFirst()
}
if (embeddingBuffer.size == N_EMBEDDING_FRAMES) {
val score = runClassifier()
if (debugFrameCount % 50 == 0) {
Log.d(TAG, "DEBUG — classifier score=$score (threshold=$DETECTION_THRESHOLD)")
}
val now = SystemClock.elapsedRealtime()
if (score >= DETECTION_THRESHOLD && (now - lastDetectionTime) > COOLDOWN_MS) {
lastDetectionTime = now
Log.d(TAG, "Wake word detected! score=$score model=$modelName")
broadcast("detected")
}
}
}
}
// ── Step 1 : mel via ONNX Runtime ─────────────────────────────────────────
private fun computeMelOnnx(pcm: ShortArray, length: Int): List<FloatArray> {
val env = ortEnv ?: return emptyList()
val session = melSession ?: return emptyList()
// Log les noms d'entrée/sortie au premier appel
if (debugFrameCount == 0) {
Log.d(TAG, "ONNX input names: ${session.inputNames}")
Log.d(TAG, "ONNX output names: ${session.outputNames}")
}
return try {
_computeMelOnnxInternal(env, session, pcm, length)
} catch (e: Exception) {
Log.e(TAG, "computeMelOnnx error (input length=$length)", e)
emptyList()
}
}
private fun _computeMelOnnxInternal(env: OrtEnvironment, session: OrtSession, pcm: ShortArray, length: Int): List<FloatArray> {
// Input : float32 normalisé [1, length]
val inputArray = Array(1) { FloatArray(length) { i -> pcm[i] / 32768f } }
val inputTensor = OnnxTensor.createTensor(env, inputArray)
val output = session.run(mapOf(session.inputNames.first() to inputTensor))
val rawValue = output[0].value
inputTensor.close(); output.close()
// Log le type de sortie au premier appel pour debug
if (debugFrameCount == 0) {
Log.d(TAG, "ONNX output type: ${rawValue?.javaClass?.name}${rawValue?.javaClass?.componentType?.name}")
if (rawValue is Array<*> && rawValue.isNotEmpty()) {
Log.d(TAG, "ONNX output[0] type: ${rawValue[0]?.javaClass?.name}")
if (rawValue[0] is Array<*>) {
val inner = rawValue[0] as Array<*>
Log.d(TAG, "ONNX output[0][0] type: ${inner[0]?.javaClass?.name}, size=${inner.size}")
}
}
}
// Shape réelle : [1, 1, n_frames, 32] = float[][][][]
// rawValue[0] → [1, n_frames, 32] (batch removed)
// rawValue[0][0] → [n_frames, 32] (extra dim removed)
// rawValue[0][0][frame] → float[32] (les mel bins)
val batchOutput = (rawValue as? Array<*>)?.get(0) as? Array<*>
?: run { Log.w(TAG, "Unexpected ONNX output: ${rawValue?.javaClass?.name}"); return emptyList() }
val timeSlice = batchOutput.getOrNull(0) as? Array<*>
?: run { Log.w(TAG, "No time slice in ONNX output"); return emptyList() }
val frames = timeSlice.mapNotNull { it as? FloatArray }
if (debugFrameCount == 0) Log.d(TAG, "Parsed ${frames.size} mel frames from [1,1,${frames.size},32]")
// Normalisation OWW : spec/10 + 2
return frames.map { f -> FloatArray(MEL_BINS) { i -> f[i] / 10f + 2f } }
}
// ── Step 2 : embedding TFLite ─────────────────────────────────────────────
private fun computeEmbedding(melWindow: List<FloatArray>): FloatArray {
val inputBuf = ByteBuffer.allocateDirect(4 * MEL_WINDOW * MEL_BINS).order(ByteOrder.nativeOrder())
for (frame in melWindow) for (bin in frame) inputBuf.putFloat(bin)
inputBuf.rewind()
// Shape réelle [1, 1, 1, 96] — output 4D
val output = Array(1) { Array(1) { Array(1) { FloatArray(EMBEDDING_DIM) } } }
embeddingInterpreter!!.run(inputBuf, output)
return output[0][0][0]
}
// ── Step 3 : classifier TFLite ────────────────────────────────────────────
private fun runClassifier(): Float {
val inputBuf = ByteBuffer.allocateDirect(4 * N_EMBEDDING_FRAMES * EMBEDDING_DIM).order(ByteOrder.nativeOrder())
for (embedding in embeddingBuffer) for (v in embedding) inputBuf.putFloat(v)
inputBuf.rewind()
val output = Array(1) { FloatArray(1) }
classifierInterpreter!!.run(inputBuf, output)
return output[0][0]
}
// ── Utilities ──────────────────────────────────────────────────────────────
private fun broadcast(event: String) {
val sink = MainActivity.eventSink
if (sink != null) {
// Appel direct au sink Flutter (même processus, plus fiable que sendBroadcast)
android.os.Handler(android.os.Looper.getMainLooper()).post {
try { sink.success(event) } catch (e: Exception) {
Log.w(TAG, "Direct sink failed, fallback to broadcast: $e")
sendBroadcast(Intent(EVENT_ACTION).putExtra(EXTRA_EVENT, event))
}
}
} else {
// Fallback si MainActivity n'est pas encore initialisée
Log.w(TAG, "eventSink null — sending broadcast instead")
sendBroadcast(Intent(EVENT_ACTION).putExtra(EXTRA_EVENT, event))
}
}
private fun buildNotification(): Notification {
// Intent pour ouvrir l'app en tapant sur la notification
val openIntent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = android.app.PendingIntent.getActivity(
this, 0, openIntent,
android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Guide de visite actif 🎧")
.setContentText("Dites « hey visit » pour parler à votre guide")
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
// PRIORITY_DEFAULT (pas LOW) — OEM restrictifs comme MIUI respectent mieux les services visibles
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true) // non dismissible par l'utilisateur
.setContentIntent(pendingIntent)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// IMPORTANCE_DEFAULT avec son désactivé — visible mais pas intrusif
val channel = NotificationChannel(
CHANNEL_ID,
"Guide vocal MyVisit",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
setSound(null, null)
enableVibration(false)
description = "Écoute active du wake word"
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
}

View File

@ -12,4 +12,6 @@
<!--<string name="fb_login_protocol_scheme">fb742185533300745</string>--> <!--<string name="fb_login_protocol_scheme">fb742185533300745</string>-->
<string name="default_notification_channel_id">main</string> <string name="default_notification_channel_id">main</string>
<!-- Meta Wearables DAT — "0" = Developer Mode (string, pas integer) -->
<string name="mwdat_app_id">0</string>
</resources> </resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cached_images" path="." />
<files-path name="app_files" path="." />
<external-path name="external_files" path="." />
</paths>

View File

@ -0,0 +1,105 @@
{
"project_info": {
"project_number": "1034665398515",
"project_id": "mymuseum-3b97f",
"storage_bucket": "mymuseum-3b97f.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:b9731ff810b0a2a7d6a786",
"android_client_info": {
"package_name": "be.unov.myinfomate.tablet"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:2911c4647a36e47cd6a786",
"android_client_info": {
"package_name": "be.unov.myinfomate.test"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:efe01f2db9674c78d6a786",
"android_client_info": {
"package_name": "be.unov.mymuseum.fortsaintheribert"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:23d9de7735f898e5d6a786",
"android_client_info": {
"package_name": "be.unov.mymuseum.mdlf"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1034665398515:android:b7475582b41ed32dd6a786",
"android_client_info": {
"package_name": "be.unov.mymuseum.tablet_app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -11,10 +11,25 @@
} }
}*/ }*/
def rootLocalProps = new Properties()
def rootLocalPropsFile = rootProject.file('local.properties')
if (rootLocalPropsFile.exists()) {
rootLocalPropsFile.withReader('UTF-8') { reader -> rootLocalProps.load(reader) }
}
allprojects { allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven {
url = uri("https://maven.pkg.github.com/facebook/meta-wearables-dat-android")
credentials {
username = "x-access-token"
password = System.getenv("GITHUB_TOKEN")
?: rootLocalProps.getProperty("github_token")
?: ""
}
}
} }
} }
@ -26,6 +41,22 @@ subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
// Fix pour les packages Flutter qui n'ont pas de namespace (beacon_scanner v0.0.4, etc.)
subprojects {
plugins.withId("com.android.library") {
if (!android.namespace) {
def manifestFile = android.sourceSets.main.manifest.srcFile
if (manifestFile?.exists()) {
def packageName = new groovy.xml.XmlSlurper()
.parse(manifestFile)['@package'].text()
if (packageName) {
android.namespace = packageName
}
}
}
}
}
tasks.register("clean", Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View File

@ -1,3 +1,9 @@
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx4096M
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=false
android.experimental.enable16kApk=true
android.useNewNativePlugin=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View File

@ -3,4 +3,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@ -31,8 +31,9 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.2.0" apply false id "com.android.application" version "8.9.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.0" apply false id "org.jetbrains.kotlin.android" version "2.1.0" apply false
id "com.google.gms.google-services" version "4.4.2" apply false
} }
include ":app" include ":app"

BIN
assets/files/Duck.glb Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/files/hey_visit.onnx Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/files/hey_viva.onnx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,202 @@
<!doctype html>
<!--
Copyright 2018 The Immersive Web Community Group
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-->
<html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'>
<meta name='mobile-web-app-capable' content='yes'>
<meta name='apple-mobile-web-app-capable' content='yes'>
<link rel='icon' type='image/png' sizes='32x32' href='favicon-32x32.png'>
<link rel='icon' type='image/png' sizes='96x96' href='favicon-96x96.png'>
<link rel='stylesheet' href='css/common.css'>
<title>Immersive AR Session</title>
</head>
<body>
<header>
<details open>
<summary>Immersive AR Session</summary>
<p>
This sample demonstrates how to use an 'immersive-ar' XRSession to
present a simple WebGL scene to a transparent or passthrough XR
device. The logic is largely the same as the corresponding VR sample,
with the primary difference being that no background is rendered and
the model is scaled down for easier viewing in a real-world space.
<a class="back" href="./">Back</a>
</p>
</details>
</header>
<script type="module">
import {WebXRButton} from './js/util/webxr-button.js';
import {Scene} from './js/render/scenes/scene.js';
import {Renderer, createWebGLContext} from './js/render/core/renderer.js';
import {SkyboxNode} from './js/render/nodes/skybox.js';
import {InlineViewerHelper} from './js/util/inline-viewer-helper.js';
import {Gltf2Node} from './js/render/nodes/gltf2.js';
import {QueryArgs} from './js/util/query-args.js';
// If requested, use the polyfill to provide support for mobile devices
// and devices which only support WebVR.
import WebXRPolyfill from './js/third-party/webxr-polyfill/build/webxr-polyfill.module.js';
if (QueryArgs.getBool('usePolyfill', true)) {
let polyfill = new WebXRPolyfill();
}
// XR globals.
let xrButton = null;
let xrImmersiveRefSpace = null;
let inlineViewerHelper = null;
// WebGL scene globals.
let gl = null;
let renderer = null;
let scene = new Scene();
let solarSystem = new Gltf2Node({url: 'media/gltf/space/space.gltf'});
// The solar system is big (citation needed). Scale it down so that users
// can move around the planets more easily.
solarSystem.scale = [0.01, 0.01, 0.1];
scene.addNode(solarSystem);
// Still adding a skybox, but only for the benefit of the inline view.
let skybox = new SkyboxNode({url: 'media/textures/milky-way-4k.png'});
scene.addNode(skybox);
function initXR() {
xrButton = new WebXRButton({
onRequestSession: onRequestSession,
onEndSession: onEndSession,
textEnterXRTitle: "START AR Youhou",
textXRNotFoundTitle: "AR NOT FOUND",
textExitXRTitle: "EXIT AR",
});
document.querySelector('header').appendChild(xrButton.domElement);
if (navigator.xr) {
// Checks to ensure that 'immersive-ar' mode is available, and only
// enables the button if so.
navigator.xr.isSessionSupported('immersive-ar').then((supported) => {
xrButton.enabled = supported;
});
navigator.xr.requestSession('inline').then(onSessionStarted);
}
}
function onRequestSession() {
// Requests an 'immersive-ar' session, which ensures that the users
// environment will be visible either via video passthrough or a
// transparent display. This may be presented either in a headset or
// fullscreen on a mobile device.
return navigator.xr.requestSession('immersive-ar')
.then((session) => {
xrButton.setSession(session);
session.isImmersive = true;
onSessionStarted(session);
});
}
function initGL() {
if (gl)
return;
gl = createWebGLContext({
xrCompatible: true
});
document.body.appendChild(gl.canvas);
function onResize() {
gl.canvas.width = gl.canvas.clientWidth * window.devicePixelRatio;
gl.canvas.height = gl.canvas.clientHeight * window.devicePixelRatio;
}
window.addEventListener('resize', onResize);
onResize();
renderer = new Renderer(gl);
scene.setRenderer(renderer);
}
function onSessionStarted(session) {
session.addEventListener('end', onSessionEnded);
if (session.isImmersive) {
// When in 'immersive-ar' mode don't draw an opaque background because
// we want the real world to show through.
skybox.visible = false;
}
initGL();
session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });
let refSpaceType = session.isImmersive ? 'local' : 'viewer';
session.requestReferenceSpace(refSpaceType).then((refSpace) => {
if (session.isImmersive) {
xrImmersiveRefSpace = refSpace;
xrImmersiveRefSpace.addEventListener('reset', (evt) => {
if (evt.transform) {
// AR experiences typically should stay grounded to the real world.
// If there's a known origin shift, compensate for it here.
xrImmersiveRefSpace = xrImmersiveRefSpace.getOffsetReferenceSpace(evt.transform);
}
});
} else {
inlineViewerHelper = new InlineViewerHelper(gl.canvas, refSpace);
}
session.requestAnimationFrame(onXRFrame);
});
}
function onEndSession(session) {
session.end();
}
function onSessionEnded(event) {
if (event.session.isImmersive) {
xrButton.setSession(null);
// Turn the background back on when we go back to the inlive view.
skybox.visible = true;
}
}
// Called every time a XRSession requests that a new frame be drawn.
function onXRFrame(t, frame) {
let session = frame.session;
let refSpace = session.isImmersive ?
xrImmersiveRefSpace :
inlineViewerHelper.referenceSpace;
let pose = frame.getViewerPose(refSpace);
scene.startFrame();
session.requestAnimationFrame(onXRFrame);
scene.drawXRFrame(frame, pose);
scene.endFrame();
}
// Start the XR application.
initXR();
</script>
</body>
</html>

BIN
assets/icons/marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
assets/loader/dev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

BIN
assets/loader/mdlf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

BIN
assets/sounds/done.mp3 Normal file

Binary file not shown.

BIN
assets/sounds/thinking.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/splash/dev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

BIN
assets/splash/mdlf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -8,6 +8,9 @@ import Flutter
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
if let registrar = self.registrar(forPlugin: "AudioRoutingPlugin") {
AudioRoutingPlugin.register(with: registrar)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
} }

View File

@ -0,0 +1,57 @@
// AudioRoutingPlugin.swift
// Force la sortie audio sur les lunettes Ray-Ban Meta via AVAudioSession.
// Pattern extrait de OpenGlasses (straff2002) WakeWordService.swift + GeminiLiveAudioManager.swift.
import Flutter
import AVFoundation
@objc class AudioRoutingPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "be.unov.mymuseum/audio_routing",
binaryMessenger: registrar.messenger()
)
let instance = AudioRoutingPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "enableBluetoothOutput":
enableBluetoothOutput()
result(nil)
case "restoreDefaultOutput":
restoreDefaultOutput()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
private func enableBluetoothOutput() {
do {
let session = AVAudioSession.sharedInstance()
// Mode voiceChat + A2DP + HFP : pattern validé dans OpenGlasses
// pour jouer audio sur lunettes tout en gardant le micro disponible
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetoothHFP, .allowBluetoothA2DP, .defaultToSpeaker]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
print("[AudioRoutingPlugin] enableBluetoothOutput error: \(error)")
}
}
private func restoreDefaultOutput() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, options: [])
try session.setActive(true)
} catch {
print("[AudioRoutingPlugin] restoreDefaultOutput error: \(error)")
}
}
}

View File

@ -4,6 +4,11 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<!-- Background audio — garde l'app active (HFP + wake word) quand téléphone en poche -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@ -39,7 +44,9 @@
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs location to show content based on location</string> <string>This app needs location to show content based on location</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>This app uses microphone only on qr code scanning (as it uses the camera)</string> <string>Microphone access is used for voice input in the assistant chat</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition is used to convert your voice to text in the assistant chat</string>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- PLACEHOLDER — Télécharger le vrai fichier depuis Firebase Console -->
<!-- Projet Firebase : mymuseum-3b97f -->
<!-- App iOS à enregistrer : be.unov.mymuseum.fortsaintheribert -->
<key>API_KEY</key>
<string>PLACEHOLDER</string>
<key>GCM_SENDER_ID</key>
<string>1034665398515</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>be.unov.mymuseum.fortsaintheribert</string>
<key>PROJECT_ID</key>
<string>mymuseum-3b97f</string>
<key>STORAGE_BUCKET</key>
<string>mymuseum-3b97f.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>PLACEHOLDER</string>
</dict>
</plist>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- PLACEHOLDER — Télécharger le vrai fichier depuis Firebase Console -->
<!-- Projet Firebase : mymuseum-3b97f -->
<!-- App iOS à enregistrer : be.unov.mymuseum.mdlf -->
<key>API_KEY</key>
<string>PLACEHOLDER</string>
<key>GCM_SENDER_ID</key>
<string>1034665398515</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>be.unov.mymuseum.mdlf</string>
<key>PROJECT_ID</key>
<string>mymuseum-3b97f</string>
<key>STORAGE_BUCKET</key>
<string>mymuseum-3b97f.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>PLACEHOLDER</string>
</dict>
</plist>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- PLACEHOLDER — Télécharger le vrai fichier depuis Firebase Console -->
<!-- Projet Firebase : mymuseum-3b97f -->
<!-- App iOS à enregistrer : be.unov.myinfomate.test -->
<key>API_KEY</key>
<string>PLACEHOLDER</string>
<key>GCM_SENDER_ID</key>
<string>1034665398515</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>be.unov.myinfomate.test</string>
<key>PROJECT_ID</key>
<string>mymuseum-3b97f</string>
<key>STORAGE_BUCKET</key>
<string>mymuseum-3b97f.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>PLACEHOLDER</string>
</dict>
</plist>

View File

@ -1,3 +1,3 @@
arb-dir: lib/l10n arb-dir: lib/l10n
template-arb-file: app_en.arb template-arb-file: app_en.arb
output-localization-file: app_localizations.dart output-localization-file: app_localizations.dart

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Components/GlassesDebugPanel.dart';
import 'package:mymuseum_visitapp/Components/check_input_container.dart'; import 'package:mymuseum_visitapp/Components/check_input_container.dart';
import 'package:mymuseum_visitapp/Components/rounded_button.dart'; import 'package:mymuseum_visitapp/Components/rounded_button.dart';
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart'; import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/app_context.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../constants.dart'; import '../constants.dart';
@ -34,7 +36,7 @@ class _AdminPopupState extends State<AdminPopup> {
return Container( return Container(
width: size.width*0.7, width: size.width*0.7,
height: isPasswordOk ? size.height*0.5 : size.height*0.15, height: isPasswordOk ? size.height*0.6 : size.height*0.15,
margin: const EdgeInsets.all(kDefaultPadding), margin: const EdgeInsets.all(kDefaultPadding),
child: isPasswordOk ? Column( child: isPasswordOk ? Column(
children: [ children: [
@ -110,6 +112,30 @@ class _AdminPopupState extends State<AdminPopup> {
vertical: 5 vertical: 5
), ),
), ),
),
SizedBox(
height: size.height*0.06,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ValueListenableBuilder<GlassesState>(
valueListenable: MetaGlassesService.instance.state,
builder: (_, state, __) => RoundedButton(
text: "Lunettes • ${state.name}",
color: state == GlassesState.streaming || state == GlassesState.connected
? Colors.green[700]!
: Colors.orange[700]!,
textColor: Colors.white,
icon: Icons.smart_toy_outlined,
press: () {
Navigator.of(context).pop();
GlassesDebugPanel.show(context);
},
fontSize: 16,
horizontal: 20,
vertical: 5,
),
),
),
) )
], ],
) : ) :

View File

@ -0,0 +1,511 @@
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:mymuseum_visitapp/Models/AssistantResponse.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/assistantService.dart';
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:speech_to_text/speech_to_text.dart';
String _stripHtml(String html) => html.replaceAll(RegExp(r'<[^>]*>'), '').trim();
class AssistantChatSheet extends StatefulWidget {
final VisitAppContext visitAppContext;
final String? configurationId;
final void Function(String sectionId, String sectionTitle)? onNavigateToSection;
const AssistantChatSheet({
Key? key,
required this.visitAppContext,
this.configurationId,
this.onNavigateToSection,
}) : super(key: key);
static void show(
BuildContext context, {
required VisitAppContext visitAppContext,
String? configurationId,
void Function(String sectionId, String sectionTitle)? onNavigateToSection,
}) {
showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: '',
barrierColor: Colors.black54,
transitionDuration: const Duration(milliseconds: 280),
pageBuilder: (dialogContext, _, __) => AssistantChatSheet(
visitAppContext: visitAppContext,
configurationId: configurationId,
onNavigateToSection: onNavigateToSection,
),
transitionBuilder: (_, animation, __, child) => SlideTransition(
position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
child: child,
),
);
}
@override
State<AssistantChatSheet> createState() => _AssistantChatSheetState();
}
class _AssistantChatSheetState extends State<AssistantChatSheet> {
late AssistantService _assistantService;
final TextEditingController _controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<Widget> _bubbles = [];
bool _isLoading = false;
final SpeechToText _speech = SpeechToText();
bool _speechAvailable = false;
bool _isListening = false;
@override
void initState() {
super.initState();
_assistantService = AssistantService(visitAppContext: widget.visitAppContext);
_initSpeech();
}
Future<void> _initSpeech() async {
final available = await _speech.initialize();
if (mounted) setState(() => _speechAvailable = available);
}
Future<void> _toggleListening() async {
if (_isListening) {
await _speech.stop();
setState(() => _isListening = false);
} else {
final locale = widget.visitAppContext.language?.toLowerCase() ?? 'fr';
setState(() => _isListening = true);
await _speech.listen(
localeId: locale,
onResult: (result) {
setState(() => _controller.text = result.recognizedWords);
if (result.finalResult) {
setState(() => _isListening = false);
}
},
listenOptions: SpeechListenOptions(partialResults: true),
);
}
}
@override
void dispose() {
_speech.cancel();
super.dispose();
}
Future<void> _send() async {
final text = _controller.text.trim();
if (text.isEmpty || _isLoading) return;
_controller.clear();
setState(() {
_bubbles.add(_ChatBubble(text: text, isUser: true));
_isLoading = true;
});
_scrollToBottom();
try {
final response = await _assistantService.chat(
message: text,
configurationId: widget.configurationId,
);
setState(() {
_bubbles.add(_AssistantMessage(
response: response,
onNavigate: widget.onNavigateToSection,
));
});
// Pipe TTS vers les lunettes si connectées
if (widget.visitAppContext.glassesEnabled &&
MetaGlassesService.instance.isConnected &&
response.reply.isNotEmpty) {
final lang = widget.visitAppContext.language ?? 'FR';
activeOrchestrator?.ttsEngine.speak(
response.reply,
languageCode: _toLangCode(lang),
);
}
} catch (e) {
debugPrint('AssistantChatSheet error: $e');
setState(() {
_bubbles.add(_ChatBubble(text: "Une erreur est survenue, réessayez.", isUser: false));
});
} finally {
setState(() => _isLoading = false);
_scrollToBottom();
}
}
String _toLangCode(String lang) {
switch (lang.toUpperCase()) {
case 'FR': return 'fr-FR';
case 'NL': return 'nl-NL';
case 'EN': return 'en-US';
case 'DE': return 'de-DE';
default: return 'fr-FR';
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
final height = MediaQuery.of(context).size.height * 0.9;
return Align(
alignment: Alignment.bottomCenter,
child: Material(
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
clipBehavior: Clip.antiAlias,
child: SizedBox(
height: height,
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(Icons.chat_bubble_outline, color: kMainColor1),
const SizedBox(width: 8),
Text("Assistant",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: kSecondGrey)),
const SizedBox(width: 8),
if (widget.visitAppContext.glassesEnabled)
ValueListenableBuilder<GlassesState>(
valueListenable: MetaGlassesService.instance.state,
builder: (_, glassesState, __) {
final connected = glassesState == GlassesState.connected ||
glassesState == GlassesState.streaming;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.smart_toy_outlined,
size: 14,
color: connected ? Colors.green : Colors.grey[400],
),
const SizedBox(width: 3),
Text(
connected ? 'Lunettes' : 'Déconnecté',
style: TextStyle(
fontSize: 11,
color: connected ? Colors.green : Colors.grey[400],
),
),
],
);
},
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
// Messages
Expanded(
child: _bubbles.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
"Bonjour ! Posez-moi vos questions sur cette visite.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[500], fontSize: 15),
),
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: _bubbles.length,
itemBuilder: (_, i) => _bubbles[i],
),
),
// Loading indicator
if (_isLoading)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: kMainColor1),
),
const SizedBox(width: 8),
Text("...", style: TextStyle(color: Colors.grey[400])),
],
),
),
// Input
Padding(
padding: EdgeInsets.only(
left: 12,
right: 12,
bottom: MediaQuery.of(context).viewInsets.bottom + 8,
top: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: "Votre question...",
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
onSubmitted: (_) => _send(),
),
),
const SizedBox(width: 8),
if (_speechAvailable)
AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _isListening ? Colors.red : Colors.grey[200],
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(
_isListening ? Icons.mic : Icons.mic_none,
color: _isListening ? Colors.white : Colors.grey[600],
size: 20,
),
onPressed: _toggleListening,
),
),
const SizedBox(width: 8),
CircleAvatar(
backgroundColor: kMainColor1,
child: IconButton(
icon: const Icon(Icons.send, color: Colors.white, size: 18),
onPressed: _send,
),
),
],
),
),
],
),
),
),
);
}
}
class _ChatBubble extends StatelessWidget {
final String text;
final bool isUser;
const _ChatBubble({required this.text, required this.isUser});
@override
Widget build(BuildContext context) {
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78),
decoration: BoxDecoration(
color: isUser ? kMainColor1 : Colors.grey[100],
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: isUser ? const Radius.circular(16) : const Radius.circular(4),
bottomRight: isUser ? const Radius.circular(4) : const Radius.circular(16),
),
),
child: isUser
? Text(
text,
style: const TextStyle(color: Colors.white, fontSize: 14),
)
: HtmlWidget(
text,
textStyle: TextStyle(color: kSecondGrey, fontSize: 14),
),
),
);
}
}
class _AssistantMessage extends StatelessWidget {
final AssistantResponse response;
final void Function(String sectionId, String sectionTitle)? onNavigate;
const _AssistantMessage({required this.response, this.onNavigate});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (response.reply.isNotEmpty)
_ChatBubble(text: response.reply, isUser: false),
if (response.cards != null && response.cards!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6, left: 4, right: 24),
child: Column(
children: response.cards!.map((card) => _AiCardWidget(card: card)).toList(),
),
),
if (response.navigation != null && onNavigate != null)
GestureDetector(
onTap: () {
Navigator.of(context).pop();
onNavigate!(
response.navigation!.sectionId,
_stripHtml(response.navigation!.sectionTitle),
);
},
child: Container(
margin: const EdgeInsets.only(top: 8, left: 4, right: 24),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: kMainColor1.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: kMainColor1.withValues(alpha: 0.35)),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: response.navigation!.imageUrl != null
? Image.network(
response.navigation!.imageUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: kMainColor1,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.place_outlined, color: Colors.white, size: 22),
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: kMainColor1,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.place_outlined, color: Colors.white, size: 22),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_stripHtml(response.navigation!.sectionTitle),
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: kSecondGrey,
),
),
Text(
"Voir cette section",
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
),
],
),
),
Icon(Icons.chevron_right, color: kMainColor1, size: 20),
],
),
),
),
],
),
);
}
}
class _AiCardWidget extends StatelessWidget {
final AiCard card;
const _AiCardWidget({required this.card});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey[200]!),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 3,
offset: const Offset(0, 1)),
],
),
child: Row(
children: [
if (card.icon != null) ...[
Text(card.icon!, style: const TextStyle(fontSize: 18)),
const SizedBox(width: 8),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(card.title,
style: TextStyle(
fontWeight: FontWeight.w600, fontSize: 13, color: kSecondGrey)),
if (card.subtitle.isNotEmpty)
Text(card.subtitle,
style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
],
),
);
}
}

View File

@ -1,149 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'carousel_options.dart';
import 'carousel_state.dart';
import 'utils.dart';
abstract class CarouselController {
bool get ready;
Future<Null> get onReady;
Future<void> nextPage({Duration? duration, Curve? curve});
Future<void> previousPage({Duration? duration, Curve? curve});
void jumpToPage(int page);
Future<void> animateToPage(int page, {Duration? duration, Curve? curve});
void startAutoPlay();
void stopAutoPlay();
factory CarouselController() => CarouselControllerImpl();
}
class CarouselControllerImpl implements CarouselController {
final Completer<Null> _readyCompleter = Completer<Null>();
CarouselState? _state;
set state(CarouselState? state) {
_state = state;
if (!_readyCompleter.isCompleted) {
_readyCompleter.complete();
}
}
void _setModeController() =>
_state!.changeMode(CarouselPageChangedReason.controller);
@override
bool get ready => _state != null;
@override
Future<Null> get onReady => _readyCompleter.future;
/// Animates the controlled [CarouselSlider] to the next page.
///
/// The animation lasts for the given duration and follows the given curve.
/// The returned [Future] resolves when the animation completes.
Future<void> nextPage(
{Duration? duration = const Duration(milliseconds: 300),
Curve? curve = Curves.linear}) async {
final bool isNeedResetTimer = _state!.options.pauseAutoPlayOnManualNavigate;
if (isNeedResetTimer) {
_state!.onResetTimer();
}
_setModeController();
await _state!.pageController!.nextPage(duration: duration!, curve: curve!);
if (isNeedResetTimer) {
_state!.onResumeTimer();
}
}
/// Animates the controlled [CarouselSlider] to the previous page.
///
/// The animation lasts for the given duration and follows the given curve.
/// The returned [Future] resolves when the animation completes.
Future<void> previousPage(
{Duration? duration = const Duration(milliseconds: 300),
Curve? curve = Curves.linear}) async {
final bool isNeedResetTimer = _state!.options.pauseAutoPlayOnManualNavigate;
if (isNeedResetTimer) {
_state!.onResetTimer();
}
_setModeController();
await _state!.pageController!
.previousPage(duration: duration!, curve: curve!);
if (isNeedResetTimer) {
_state!.onResumeTimer();
}
}
/// Changes which page is displayed in the controlled [CarouselSlider].
///
/// Jumps the page position from its current value to the given value,
/// without animation, and without checking if the new value is in range.
void jumpToPage(int page) {
final index = getRealIndex(_state!.pageController!.page!.toInt(),
_state!.realPage - _state!.initialPage, _state!.itemCount);
_setModeController();
final int pageToJump = _state!.pageController!.page!.toInt() + page - index;
return _state!.pageController!.jumpToPage(pageToJump);
}
/// Animates the controlled [CarouselSlider] from the current page to the given page.
///
/// The animation lasts for the given duration and follows the given curve.
/// The returned [Future] resolves when the animation completes.
Future<void> animateToPage(int page,
{Duration? duration = const Duration(milliseconds: 300),
Curve? curve = Curves.linear}) async {
final bool isNeedResetTimer = _state!.options.pauseAutoPlayOnManualNavigate;
if (isNeedResetTimer) {
_state!.onResetTimer();
}
final index = getRealIndex(_state!.pageController!.page!.toInt(),
_state!.realPage - _state!.initialPage, _state!.itemCount);
int smallestMovement = page - index;
if (_state!.options.enableInfiniteScroll &&
_state!.itemCount != null &&
_state!.options.animateToClosest) {
if ((page - index).abs() > (page + _state!.itemCount! - index).abs()) {
smallestMovement = page + _state!.itemCount! - index;
} else if ((page - index).abs() >
(page - _state!.itemCount! - index).abs()) {
smallestMovement = page - _state!.itemCount! - index;
}
}
_setModeController();
await _state!.pageController!.animateToPage(
_state!.pageController!.page!.toInt() + smallestMovement,
duration: duration!,
curve: curve!);
if (isNeedResetTimer) {
_state!.onResumeTimer();
}
}
/// Starts the controlled [CarouselSlider] autoplay.
///
/// The carousel will only autoPlay if the [autoPlay] parameter
/// in [CarouselOptions] is true.
void startAutoPlay() {
_state!.onResumeTimer();
}
/// Stops the controlled [CarouselSlider] from autoplaying.
///
/// This is a more on-demand way of doing this. Use the [autoPlay]
/// parameter in [CarouselOptions] to specify the autoPlay behaviour of the carousel.
void stopAutoPlay() {
_state!.onResetTimer();
}
}

View File

@ -1,223 +0,0 @@
import 'package:flutter/material.dart';
enum CarouselPageChangedReason { timed, manual, controller }
enum CenterPageEnlargeStrategy { scale, height, zoom }
class CarouselOptions {
/// Set carousel height and overrides any existing [aspectRatio].
final double? height;
/// Aspect ratio is used if no height have been declared.
///
/// Defaults to 16:9 aspect ratio.
final double aspectRatio;
/// The fraction of the viewport that each page should occupy.
///
/// Defaults to 0.8, which means each page fills 80% of the carousel.
final double viewportFraction;
/// The initial page to show when first creating the [CarouselSlider].
///
/// Defaults to 0.
final int initialPage;
///Determines if carousel should loop infinitely or be limited to item length.
///
///Defaults to true, i.e. infinite loop.
final bool enableInfiniteScroll;
///Determines if carousel should loop to the closest occurence of requested page.
///
///Defaults to true.
final bool animateToClosest;
/// Reverse the order of items if set to true.
///
/// Defaults to false.
final bool reverse;
/// Enables auto play, sliding one page at a time.
///
/// Use [autoPlayInterval] to determent the frequency of slides.
/// Defaults to false.
final bool autoPlay;
/// Sets Duration to determent the frequency of slides when
///
/// [autoPlay] is set to true.
/// Defaults to 4 seconds.
final Duration autoPlayInterval;
/// The animation duration between two transitioning pages while in auto playback.
///
/// Defaults to 800 ms.
final Duration autoPlayAnimationDuration;
/// Determines the animation curve physics.
///
/// Defaults to [Curves.fastOutSlowIn].
final Curve autoPlayCurve;
/// Determines if current page should be larger than the side images,
/// creating a feeling of depth in the carousel.
///
/// Defaults to false.
final bool? enlargeCenterPage;
/// The axis along which the page view scrolls.
///
/// Defaults to [Axis.horizontal].
final Axis scrollDirection;
/// Called whenever the page in the center of the viewport changes.
final Function(int index, CarouselPageChangedReason reason)? onPageChanged;
/// Called whenever the carousel is scrolled
final ValueChanged<double?>? onScrolled;
/// How the carousel should respond to user input.
///
/// For example, determines how the items continues to animate after the
/// user stops dragging the page view.
///
/// The physics are modified to snap to page boundaries using
/// [PageScrollPhysics] prior to being used.
///
/// Defaults to matching platform conventions.
final ScrollPhysics? scrollPhysics;
/// Set to false to disable page snapping, useful for custom scroll behavior.
///
/// Default to `true`.
final bool pageSnapping;
/// If `true`, the auto play function will be paused when user is interacting with
/// the carousel, and will be resumed when user finish interacting.
/// Default to `true`.
final bool pauseAutoPlayOnTouch;
/// If `true`, the auto play function will be paused when user is calling
/// pageController's `nextPage` or `previousPage` or `animateToPage` method.
/// And after the animation complete, the auto play will be resumed.
/// Default to `true`.
final bool pauseAutoPlayOnManualNavigate;
/// If `enableInfiniteScroll` is `false`, and `autoPlay` is `true`, this option
/// decide the carousel should go to the first item when it reach the last item or not.
/// If set to `true`, the auto play will be paused when it reach the last item.
/// If set to `false`, the auto play function will animate to the first item when it was
/// in the last item.
final bool pauseAutoPlayInFiniteScroll;
/// Pass a `PageStoragekey` if you want to keep the pageview's position when it was recreated.
final PageStorageKey? pageViewKey;
/// Use [enlargeStrategy] to determine which method to enlarge the center page.
final CenterPageEnlargeStrategy enlargeStrategy;
/// How much the pages next to the center page will be scaled down.
/// If `enlargeCenterPage` is false, this property has no effect.
final double enlargeFactor;
/// Whether or not to disable the `Center` widget for each slide.
final bool disableCenter;
/// Whether to add padding to both ends of the list.
/// If this is set to true and [viewportFraction] < 1.0, padding will be added such that the first and last child slivers will be in the center of the viewport when scrolled all the way to the start or end, respectively.
/// If [viewportFraction] >= 1.0, this property has no effect.
/// This property defaults to true and must not be null.
final bool padEnds;
/// Exposed clipBehavior of PageView
final Clip clipBehavior;
CarouselOptions({
this.height,
this.aspectRatio = 16 / 9,
this.viewportFraction = 0.8,
this.initialPage = 0,
this.enableInfiniteScroll = true,
this.animateToClosest = true,
this.reverse = false,
this.autoPlay = false,
this.autoPlayInterval = const Duration(seconds: 4),
this.autoPlayAnimationDuration = const Duration(milliseconds: 800),
this.autoPlayCurve = Curves.fastOutSlowIn,
this.enlargeCenterPage = false,
this.onPageChanged,
this.onScrolled,
this.scrollPhysics,
this.pageSnapping = true,
this.scrollDirection = Axis.horizontal,
this.pauseAutoPlayOnTouch = true,
this.pauseAutoPlayOnManualNavigate = true,
this.pauseAutoPlayInFiniteScroll = false,
this.pageViewKey,
this.enlargeStrategy = CenterPageEnlargeStrategy.scale,
this.enlargeFactor = 0.3,
this.disableCenter = false,
this.padEnds = true,
this.clipBehavior = Clip.hardEdge,
});
///Generate new [CarouselOptions] based on old ones.
CarouselOptions copyWith(
{double? height,
double? aspectRatio,
double? viewportFraction,
int? initialPage,
bool? enableInfiniteScroll,
bool? reverse,
bool? autoPlay,
Duration? autoPlayInterval,
Duration? autoPlayAnimationDuration,
Curve? autoPlayCurve,
bool? enlargeCenterPage,
Function(int index, CarouselPageChangedReason reason)? onPageChanged,
ValueChanged<double?>? onScrolled,
ScrollPhysics? scrollPhysics,
bool? pageSnapping,
Axis? scrollDirection,
bool? pauseAutoPlayOnTouch,
bool? pauseAutoPlayOnManualNavigate,
bool? pauseAutoPlayInFiniteScroll,
PageStorageKey? pageViewKey,
CenterPageEnlargeStrategy? enlargeStrategy,
double? enlargeFactor,
bool? disableCenter,
Clip? clipBehavior,
bool? padEnds}) =>
CarouselOptions(
height: height ?? this.height,
aspectRatio: aspectRatio ?? this.aspectRatio,
viewportFraction: viewportFraction ?? this.viewportFraction,
initialPage: initialPage ?? this.initialPage,
enableInfiniteScroll: enableInfiniteScroll ?? this.enableInfiniteScroll,
reverse: reverse ?? this.reverse,
autoPlay: autoPlay ?? this.autoPlay,
autoPlayInterval: autoPlayInterval ?? this.autoPlayInterval,
autoPlayAnimationDuration:
autoPlayAnimationDuration ?? this.autoPlayAnimationDuration,
autoPlayCurve: autoPlayCurve ?? this.autoPlayCurve,
enlargeCenterPage: enlargeCenterPage ?? this.enlargeCenterPage,
onPageChanged: onPageChanged ?? this.onPageChanged,
onScrolled: onScrolled ?? this.onScrolled,
scrollPhysics: scrollPhysics ?? this.scrollPhysics,
pageSnapping: pageSnapping ?? this.pageSnapping,
scrollDirection: scrollDirection ?? this.scrollDirection,
pauseAutoPlayOnTouch: pauseAutoPlayOnTouch ?? this.pauseAutoPlayOnTouch,
pauseAutoPlayOnManualNavigate:
pauseAutoPlayOnManualNavigate ?? this.pauseAutoPlayOnManualNavigate,
pauseAutoPlayInFiniteScroll:
pauseAutoPlayInFiniteScroll ?? this.pauseAutoPlayInFiniteScroll,
pageViewKey: pageViewKey ?? this.pageViewKey,
enlargeStrategy: enlargeStrategy ?? this.enlargeStrategy,
enlargeFactor: enlargeFactor ?? this.enlargeFactor,
disableCenter: disableCenter ?? this.disableCenter,
clipBehavior: clipBehavior ?? this.clipBehavior,
padEnds: padEnds ?? this.padEnds,
);
}

View File

@ -1,396 +0,0 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'carousel_controller.dart' as cs;
import 'carousel_options.dart';
import 'carousel_state.dart';
import 'utils.dart';
export 'carousel_controller.dart';
export 'carousel_options.dart';
typedef Widget ExtendedIndexedWidgetBuilder(
BuildContext context, int index, int realIndex);
class CarouselSlider extends StatefulWidget {
/// [CarouselOptions] to create a [CarouselState] with
final CarouselOptions options;
final bool? disableGesture;
/// The widgets to be shown in the carousel of default constructor
final List<Widget>? items;
/// The widget item builder that will be used to build item on demand
/// The third argument is the PageView's real index, can be used to cooperate
/// with Hero.
final ExtendedIndexedWidgetBuilder? itemBuilder;
/// A [MapController], used to control the map.
final cs.CarouselControllerImpl _carouselController;
final int? itemCount;
CarouselSlider(
{required this.items,
required this.options,
this.disableGesture,
cs.CarouselController? carouselController,
Key? key})
: itemBuilder = null,
itemCount = items != null ? items.length : 0,
_carouselController = carouselController != null
? carouselController as cs.CarouselControllerImpl
: cs.CarouselController() as cs.CarouselControllerImpl,
super(key: key);
/// The on demand item builder constructor
CarouselSlider.builder(
{required this.itemCount,
required this.itemBuilder,
required this.options,
this.disableGesture,
cs.CarouselController? carouselController,
Key? key})
: items = null,
_carouselController = carouselController != null
? carouselController as cs.CarouselControllerImpl
: cs.CarouselController() as cs.CarouselControllerImpl,
super(key: key);
@override
CarouselSliderState createState() => CarouselSliderState(_carouselController);
}
class CarouselSliderState extends State<CarouselSlider>
with TickerProviderStateMixin {
final cs.CarouselControllerImpl carouselController;
Timer? timer;
CarouselOptions get options => widget.options;
CarouselState? carouselState;
PageController? pageController;
/// mode is related to why the page is being changed
CarouselPageChangedReason mode = CarouselPageChangedReason.controller;
CarouselSliderState(this.carouselController);
void changeMode(CarouselPageChangedReason _mode) {
mode = _mode;
}
@override
void didUpdateWidget(CarouselSlider oldWidget) {
carouselState!.options = options;
carouselState!.itemCount = widget.itemCount;
// pageController needs to be re-initialized to respond to state changes
pageController = PageController(
viewportFraction: options.viewportFraction,
initialPage: carouselState!.realPage,
);
carouselState!.pageController = pageController;
// handle autoplay when state changes
handleAutoPlay();
super.didUpdateWidget(oldWidget);
}
@override
void initState() {
super.initState();
carouselState =
CarouselState(this.options, clearTimer, resumeTimer, this.changeMode);
carouselState!.itemCount = widget.itemCount;
carouselController.state = carouselState;
carouselState!.initialPage = widget.options.initialPage;
carouselState!.realPage = options.enableInfiniteScroll
? carouselState!.realPage + carouselState!.initialPage
: carouselState!.initialPage;
handleAutoPlay();
pageController = PageController(
viewportFraction: options.viewportFraction,
initialPage: carouselState!.realPage,
);
carouselState!.pageController = pageController;
}
Timer? getTimer() {
return widget.options.autoPlay
? Timer.periodic(widget.options.autoPlayInterval, (_) {
if (!mounted) {
clearTimer();
return;
}
final route = ModalRoute.of(context);
if (route?.isCurrent == false) {
return;
}
CarouselPageChangedReason previousReason = mode;
changeMode(CarouselPageChangedReason.timed);
int nextPage = carouselState!.pageController!.page!.round() + 1;
int itemCount = widget.itemCount ?? widget.items!.length;
if (nextPage >= itemCount &&
widget.options.enableInfiniteScroll == false) {
if (widget.options.pauseAutoPlayInFiniteScroll) {
clearTimer();
return;
}
nextPage = 0;
}
carouselState!.pageController!
.animateToPage(nextPage,
duration: widget.options.autoPlayAnimationDuration,
curve: widget.options.autoPlayCurve)
.then((_) => changeMode(previousReason));
})
: null;
}
void clearTimer() {
if (timer != null) {
timer?.cancel();
timer = null;
}
}
void resumeTimer() {
if (timer == null) {
timer = getTimer();
}
}
void handleAutoPlay() {
bool autoPlayEnabled = widget.options.autoPlay;
if (autoPlayEnabled && timer != null) return;
clearTimer();
if (autoPlayEnabled) {
resumeTimer();
}
}
Widget getGestureWrapper(Widget child) {
Widget wrapper;
if (widget.options.height != null) {
wrapper = Container(height: widget.options.height, child: child);
} else {
wrapper =
AspectRatio(aspectRatio: widget.options.aspectRatio, child: child);
}
if (true == widget.disableGesture) {
return NotificationListener(
onNotification: (Notification notification) {
if (widget.options.onScrolled != null &&
notification is ScrollUpdateNotification) {
widget.options.onScrolled!(carouselState!.pageController!.page);
}
return false;
},
child: wrapper,
);
}
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: {
_MultipleGestureRecognizer:
GestureRecognizerFactoryWithHandlers<_MultipleGestureRecognizer>(
() => _MultipleGestureRecognizer(),
(_MultipleGestureRecognizer instance) {
instance.onStart = (_) {
onStart();
};
instance.onDown = (_) {
onPanDown();
};
instance.onEnd = (_) {
onPanUp();
};
instance.onCancel = () {
onPanUp();
};
}),
},
child: NotificationListener(
onNotification: (Notification notification) {
if (widget.options.onScrolled != null &&
notification is ScrollUpdateNotification) {
widget.options.onScrolled!(carouselState!.pageController!.page);
}
return false;
},
child: wrapper,
),
);
}
Widget getCenterWrapper(Widget child) {
if (widget.options.disableCenter) {
return Container(
child: child,
);
}
return Center(child: child);
}
Widget getEnlargeWrapper(Widget? child,
{double? width,
double? height,
double? scale,
required double itemOffset}) {
if (widget.options.enlargeStrategy == CenterPageEnlargeStrategy.height) {
return SizedBox(child: child, width: width, height: height);
}
if (widget.options.enlargeStrategy == CenterPageEnlargeStrategy.zoom) {
late Alignment alignment;
final bool horizontal = options.scrollDirection == Axis.horizontal;
if (itemOffset > 0) {
alignment = horizontal ? Alignment.centerRight : Alignment.bottomCenter;
} else {
alignment = horizontal ? Alignment.centerLeft : Alignment.topCenter;
}
return Transform.scale(child: child, scale: scale!, alignment: alignment);
}
return Transform.scale(
scale: scale!,
child: Container(child: child, width: width, height: height));
}
void onStart() {
changeMode(CarouselPageChangedReason.manual);
}
void onPanDown() {
if (widget.options.pauseAutoPlayOnTouch) {
clearTimer();
}
changeMode(CarouselPageChangedReason.manual);
}
void onPanUp() {
if (widget.options.pauseAutoPlayOnTouch) {
resumeTimer();
}
}
@override
void dispose() {
super.dispose();
clearTimer();
}
@override
Widget build(BuildContext context) {
return getGestureWrapper(PageView.builder(
padEnds: widget.options.padEnds,
scrollBehavior: ScrollConfiguration.of(context).copyWith(
scrollbars: false,
overscroll: false,
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
},
),
clipBehavior: widget.options.clipBehavior,
physics: widget.options.scrollPhysics,
scrollDirection: widget.options.scrollDirection,
pageSnapping: widget.options.pageSnapping,
controller: carouselState!.pageController,
reverse: widget.options.reverse,
itemCount: widget.options.enableInfiniteScroll ? null : widget.itemCount,
key: widget.options.pageViewKey,
onPageChanged: (int index) {
int currentPage = getRealIndex(index + carouselState!.initialPage,
carouselState!.realPage, widget.itemCount);
if (widget.options.onPageChanged != null) {
widget.options.onPageChanged!(currentPage, mode);
}
},
itemBuilder: (BuildContext context, int idx) {
final int index = getRealIndex(idx + carouselState!.initialPage,
carouselState!.realPage, widget.itemCount);
return AnimatedBuilder(
animation: carouselState!.pageController!,
child: (widget.items != null)
? (widget.items!.length > 0 ? widget.items![index] : Container())
: widget.itemBuilder!(context, index, idx),
builder: (BuildContext context, child) {
double distortionValue = 1.0;
// if `enlargeCenterPage` is true, we must calculate the carousel item's height
// to display the visual effect
double itemOffset = 0;
if (widget.options.enlargeCenterPage != null &&
widget.options.enlargeCenterPage == true) {
// pageController.page can only be accessed after the first build,
// so in the first build we calculate the itemoffset manually
var position = carouselState?.pageController?.position;
if (position != null &&
position.hasPixels &&
position.hasContentDimensions) {
var _page = carouselState?.pageController?.page;
if (_page != null) {
itemOffset = _page - idx;
}
} else {
BuildContext storageContext = carouselState!
.pageController!.position.context.storageContext;
final double? previousSavedPosition =
PageStorage.of(storageContext)?.readState(storageContext)
as double?;
if (previousSavedPosition != null) {
itemOffset = previousSavedPosition - idx.toDouble();
} else {
itemOffset =
carouselState!.realPage.toDouble() - idx.toDouble();
}
}
final double enlargeFactor =
options.enlargeFactor.clamp(0.0, 1.0);
final num distortionRatio =
(1 - (itemOffset.abs() * enlargeFactor)).clamp(0.0, 1.0);
distortionValue =
Curves.easeOut.transform(distortionRatio as double);
}
final double height = widget.options.height ??
MediaQuery.of(context).size.width *
(1 / widget.options.aspectRatio);
if (widget.options.scrollDirection == Axis.horizontal) {
return getCenterWrapper(getEnlargeWrapper(child,
height: distortionValue * height,
scale: distortionValue,
itemOffset: itemOffset));
} else {
return getCenterWrapper(getEnlargeWrapper(child,
width: distortionValue * MediaQuery.of(context).size.width,
scale: distortionValue,
itemOffset: itemOffset));
}
},
);
},
));
}
}
class _MultipleGestureRecognizer extends PanGestureRecognizer {}

View File

@ -1,43 +0,0 @@
import 'package:flutter/material.dart';
import 'carousel_options.dart';
class CarouselState {
/// The [CarouselOptions] to create this state
CarouselOptions options;
/// [pageController] is created using the properties passed to the constructor
/// and can be used to control the [PageView] it is passed to.
PageController? pageController;
/// The actual index of the [PageView].
///
/// This value can be ignored unless you know the carousel will be scrolled
/// backwards more then 10000 pages.
/// Defaults to 10000 to simulate infinite backwards scrolling.
int realPage = 10000;
/// The initial index of the [PageView] on [CarouselSlider] init.
///
int initialPage = 0;
/// The widgets count that should be shown at carousel
int? itemCount;
/// Will be called when using pageController to go to next page or
/// previous page. It will clear the autoPlay timer.
/// Internal use only
Function onResetTimer;
/// Will be called when using pageController to go to next page or
/// previous page. It will restart the autoPlay timer.
/// Internal use only
Function onResumeTimer;
/// The callback to set the Reason Carousel changed
Function(CarouselPageChangedReason) changeMode;
CarouselState(
this.options, this.onResetTimer, this.onResumeTimer, this.changeMode);
}

View File

@ -1,23 +0,0 @@
/// Converts an index of a set size to the corresponding index of a collection of another size
/// as if they were circular.
///
/// Takes a [position] from collection Foo, a [base] from where Foo's index originated
/// and the [length] of a second collection Baa, for which the correlating index is sought.
///
/// For example; We have a Carousel of 10000(simulating infinity) but only 6 images.
/// We need to repeat the images to give the illusion of a never ending stream.
/// By calling _getRealIndex with position and base we get an offset.
/// This offset modulo our length, 6, will return a number between 0 and 5, which represent the image
/// to be placed in the given position.
int getRealIndex(int position, int base, int? length) {
final int offset = position - base;
return remainder(offset, length);
}
/// Returns the remainder of the modulo operation [input] % [source], and adjust it for
/// negative values.
int remainder(int input, int? source) {
if (source == 0) return 0;
final int result = input % source!;
return result < 0 ? source + result : result;
}

View File

@ -4,6 +4,7 @@ import 'package:mymuseum_visitapp/Components/AdminPopup.dart';
import 'package:mymuseum_visitapp/Components/LanguageSelection.dart'; import 'package:mymuseum_visitapp/Components/LanguageSelection.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Home/home.dart'; import 'package:mymuseum_visitapp/Screens/Home/home.dart';
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -71,14 +72,12 @@ class _CustomAppBarState extends State<CustomAppBar> {
leading: widget.isHomeButton ? IconButton( leading: widget.isHomeButton ? IconButton(
icon: const Icon(Icons.home, color: Colors.white), icon: const Icon(Icons.home, color: Colors.white),
onPressed: () { onPressed: () {
// Set new State
setState(() { setState(() {
visitAppContext.configuration = null; visitAppContext.configuration = null;
visitAppContext.isScanningBeacons = false; visitAppContext.isScanningBeacons = false;
//Navigator.of(context).pop();
Navigator.of(context).pushAndRemoveUntil(MaterialPageRoute( Navigator.of(context).pushAndRemoveUntil(MaterialPageRoute(
builder: (context) => const HomePage(), builder: (context) => const HomePage3(),
),(route) => false); ), (route) => false);
}); });
} }
) : null, ) : null,
@ -96,14 +95,14 @@ class _CustomAppBarState extends State<CustomAppBar> {
child: visitAppContext.isMaximizeTextSize ? const Icon(Icons.text_fields, color: Colors.white) : const Icon(Icons.format_size, color: Colors.white) child: visitAppContext.isMaximizeTextSize ? const Icon(Icons.text_fields, color: Colors.white) : const Icon(Icons.format_size, color: Colors.white)
), ),
), ),
Padding( /*Padding(
padding: const EdgeInsets.only(right: 5.0), padding: const EdgeInsets.only(right: 5.0),
child: SizedBox( child: SizedBox(
width: 50, width: 50,
height: 50, height: 50,
child: LanguageSelection() child: LanguageSelection()
) )
), ),*/
], ],
flexibleSpace: Container( flexibleSpace: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(

View File

@ -0,0 +1,371 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:meta_wearables_dat/meta_wearables_dat.dart';
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/constants.dart';
/// Panneau de debug pour l'intégration Ray-Ban Meta.
/// À ouvrir via un bouton discret dans l'app (ex: appui long sur le logo).
/// Ne pas inclure en production.
class GlassesDebugPanel extends StatefulWidget {
const GlassesDebugPanel({super.key});
static void show(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.grey[900],
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => const GlassesDebugPanel(),
);
}
@override
State<GlassesDebugPanel> createState() => _GlassesDebugPanelState();
}
class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
String _log = '';
bool _busy = false;
bool _monitoring = false;
final List<StreamSubscription> _subs = [];
@override
void dispose() {
for (final s in _subs) { s.cancel(); }
super.dispose();
}
void _addLog(String msg) {
if (!mounted) return;
setState(() => _log = '${DateTime.now().toIso8601String().substring(11, 19)} $msg\n$_log');
}
void _toggleMonitor() {
if (_monitoring) {
for (final s in _subs) { s.cancel(); }
_subs.clear();
setState(() => _monitoring = false);
_addLog('⏹ Monitor arrêté');
return;
}
_subs.add(Wearables.instance.registrationStateStream.listen(
(s) => _addLog('📋 registration: ${s.state} err=${s.error}'),
onError: (e) => _addLog('📋 registration error: $e'),
));
_subs.add(Wearables.instance.devicesStream.listen(
(d) => _addLog('📱 devices: $d'),
onError: (e) => _addLog('📱 devices error: $e'),
));
_subs.add(Wearables.instance.streamStateStream.listen(
(s) => _addLog('🎥 streamState: $s'),
onError: (e) => _addLog('🎥 streamState error: $e'),
));
_subs.add(Wearables.instance.videoFramesStream.listen(
(f) => _addLog('🖼 videoFrame: ${f.length} bytes'),
onError: (e) => _addLog('🖼 videoFrame error: $e'),
));
setState(() => _monitoring = true);
_addLog('▶ Monitor démarré — interagis avec les lunettes');
}
Future<void> _run(String label, Future<void> Function() fn) async {
if (_busy) return;
setState(() => _busy = true);
_addLog('$label');
try {
await fn();
_addLog('$label');
} catch (e) {
_addLog('$label: $e');
} finally {
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.75,
maxChildSize: 0.95,
builder: (_, scroll) => Column(
children: [
// Handle
Container(
margin: const EdgeInsets.symmetric(vertical: 8),
width: 40, height: 4,
decoration: BoxDecoration(
color: Colors.grey[600],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Icon(Icons.bug_report, color: Colors.amber, size: 18),
const SizedBox(width: 8),
const Text('Glasses Debug',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
const Spacer(),
// État en temps réel
ValueListenableBuilder<GlassesState>(
valueListenable: MetaGlassesService.instance.state,
builder: (_, state, __) {
final color = state == GlassesState.streaming
? Colors.green
: state == GlassesState.connected
? Colors.lightGreen
: state == GlassesState.connecting
? Colors.orange
: Colors.red;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
border: Border.all(color: color),
borderRadius: BorderRadius.circular(12),
),
child: Text(
state.name.toUpperCase(),
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold),
),
);
},
),
],
),
),
const Divider(color: Colors.grey),
// Boutons actions
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
spacing: 8, runSpacing: 8,
children: [
_ActionButton(
label: 'Activer caméra',
icon: Icons.camera_alt,
onTap: () => _run('requestCameraPermission + startStream', () async {
await Wearables.instance.requestCameraPermission();
await Wearables.instance.startStream(
videoQuality: 'MEDIUM',
frameRate: 24,
);
}),
),
_ActionButton(
label: 'Start stream',
icon: Icons.videocam,
onTap: () => _run('startStream (direct)', () async {
await Wearables.instance.startStream(
videoQuality: 'MEDIUM',
frameRate: 24,
);
}),
),
_ActionButton(
label: 'Capture photo',
icon: Icons.photo_camera,
onTap: () => _run('capturePhoto', () async {
await MetaGlassesService.instance.requestPhotoCapture();
}),
),
_ActionButton(
label: 'Test TTS',
icon: Icons.volume_up,
onTap: () => _run('TTS test', () async {
final o = activeOrchestrator;
if (o == null) {
_addLog('⚠ Orchestrateur non initialisé');
return;
}
// Stoppe l'écoute wake word pendant le TTS pour éviter le conflit audio focus
await o.wakeWordEngine.stop();
try {
await o.ttsEngine.speak(
'Bonjour. Je suis votre assistant de visite. Bienvenue au musée.',
languageCode: 'fr-FR',
);
} finally {
await o.wakeWordEngine.start(
onDetected: () => o.triggerConversation(),
onDetectedWithCommand: (cmd) => o.triggerConversation(),
);
}
}),
),
_ActionButton(
label: _monitoring ? 'Stop monitor' : 'Event monitor',
icon: _monitoring ? Icons.sensors_off : Icons.sensors,
color: _monitoring ? Colors.orange : Colors.purple,
onTap: _toggleMonitor,
),
_ActionButton(
label: 'Stop stream',
icon: Icons.stop,
color: Colors.red,
onTap: () => _run('stopStream', () async {
await Wearables.instance.stopStream();
}),
),
_ActionButton(
label: 'Reconnect',
icon: Icons.refresh,
onTap: () => _run('disconnect + connect', () async {
await MetaGlassesService.instance.disconnect();
await Future.delayed(const Duration(milliseconds: 500));
await MetaGlassesService.instance.connect();
}),
),
],
),
),
const Divider(color: Colors.grey),
// Transcription en direct
if (activeOrchestrator != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ValueListenableBuilder<bool>(
valueListenable: activeOrchestrator!.isListeningForCommand,
builder: (_, listening, __) => ValueListenableBuilder<String>(
valueListenable: activeOrchestrator!.lastTranscription,
builder: (_, text, __) => Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: listening
? Colors.green.withValues(alpha: 0.15)
: Colors.grey[850],
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: listening ? Colors.green : Colors.grey[700]!,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(
listening ? Icons.mic : Icons.mic_none,
color: listening ? Colors.green : Colors.grey,
size: 14,
),
const SizedBox(width: 6),
Text(
listening ? 'Écoute en cours...' : 'Dernière transcription',
style: TextStyle(
color: listening ? Colors.green : Colors.grey,
fontSize: 11,
),
),
]),
if (text.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'"$text"',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
),
),
),
// Dernier texte TTS
if (activeOrchestrator != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ValueListenableBuilder<String>(
valueListenable: activeOrchestrator!.lastTtsText,
builder: (_, text, __) => text.isEmpty
? const SizedBox.shrink()
: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.volume_up, color: Colors.blue, size: 14),
const SizedBox(width: 6),
const Text('Dernier TTS',
style: TextStyle(color: Colors.blue, fontSize: 11)),
]),
const SizedBox(height: 4),
Text(
text,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
),
),
const Divider(color: Colors.grey),
// Log
Expanded(
child: SingleChildScrollView(
controller: scroll,
padding: const EdgeInsets.all(12),
child: _log.isEmpty
? const Text('Logs apparaîtront ici...',
style: TextStyle(color: Colors.grey, fontSize: 12))
: Text(
_log,
style: const TextStyle(
color: Colors.greenAccent,
fontSize: 11,
fontFamily: 'monospace',
),
),
),
),
],
),
);
}
}
class _ActionButton extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback onTap;
final Color color;
const _ActionButton({
required this.label,
required this.icon,
required this.onTap,
this.color = Colors.blue,
});
@override
Widget build(BuildContext context) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color.withValues(alpha: 0.15),
foregroundColor: color,
side: BorderSide(color: color.withValues(alpha: 0.4)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
icon: Icon(icon, size: 16),
label: Text(label, style: const TextStyle(fontSize: 12)),
onPressed: onTap,
);
}
}

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
//import 'package:flutter_svg_provider/flutter_svg_provider.dart'; //import 'package:flutter_svg_provider/flutter_svg_provider.dart';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/constants.dart';
@ -48,9 +48,10 @@ class _LanguageSelection extends State<LanguageSelection> with TickerProviderSta
} }
return PopupMenuButton( return PopupMenuButton(
color: kMainColor.withValues(alpha: 0.65),
icon: Container( icon: Container(
height: size.height *0.07, height: size.height *0.08,
width: size.width *0.07, width: size.width *0.08,
decoration: flagDecoration(selectedLanguage!), decoration: flagDecoration(selectedLanguage!),
), ),
itemBuilder: (context){ itemBuilder: (context){

View File

@ -26,9 +26,16 @@ class _ScannerBoutonState extends State<ScannerBouton> {
return InkWell( return InkWell(
onTap: _onItemTapped, onTap: _onItemTapped,
child: Container( child: Container(
decoration: const BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: kMainColor1, color: kMainColor1.withValues(alpha: 0.6),
boxShadow: [
BoxShadow(
offset: const Offset(0, 1.5),
blurRadius: 3.5,
color: kConfigurationColor.withValues(alpha: 0.6), // Black color with 12% opacity
)
],
), ),
height: 85.0, height: 85.0,
width: 85.0, width: 85.0,

View File

@ -1,16 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/SlideFromRouteRight.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Article/article_page.dart'; import 'package:mymuseum_visitapp/Screens/section_page.dart';
import 'package:mymuseum_visitapp/Screens/Quizz/quizz_page.dart';
import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/constants.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
class ScannerDialog extends StatefulWidget { class ScannerDialog extends StatefulWidget {
const ScannerDialog({Key? key, required this.appContext}) : super(key: key); const ScannerDialog({Key? key, required this.appContext}) : super(key: key);
@ -22,19 +20,16 @@ class ScannerDialog extends StatefulWidget {
} }
class _ScannerDialogState extends State<ScannerDialog> { class _ScannerDialogState extends State<ScannerDialog> {
Barcode? result; final MobileScannerController controller = MobileScannerController();
QRViewController? controller; bool isProcessing = false;
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
// In order to get hot reload to work we need to pause the camera if the platform
// is android, or resume the camera if the platform is iOS.
@override @override
void reassemble() { void reassemble() {
super.reassemble(); super.reassemble();
if (Platform.isAndroid) { if (Platform.isAndroid) {
controller!.pauseCamera(); controller.stop();
} }
controller!.resumeCamera(); controller.start();
} }
@override @override
@ -42,208 +37,135 @@ class _ScannerDialogState extends State<ScannerDialog> {
Size size = MediaQuery.of(context).size; Size size = MediaQuery.of(context).size;
return Container( return Container(
height: size.height *0.5, height: size.height * 0.5,
width: size.width *0.9, width: size.width * 0.9,
child: Stack( child: Stack(
children: [ children: [
Center( Center(
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(10.0), borderRadius: BorderRadius.circular(10.0),
child: _buildQrView(context), child: MobileScanner(
) controller: controller,
), //allowDuplicates: false,
Positioned( onDetect: (barcodes) => _onDetect(barcodes),
top: 0,
right: 0,
child: Container(
width: 45,
height: 45,
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: kMainColor1,
borderRadius: BorderRadius.circular(20.0),
), ),
margin: const EdgeInsets.all(8),
child: InkWell(
onTap: () async {
await controller?.toggleFlash();
setState(() {});
},
child: FutureBuilder(
future: controller?.getFlashStatus(),
builder: (context, snapshot) {
return const Icon(Icons.flash_on, color: Colors.white);
},
)),
), ),
), ),
Positioned( _buildControlButton(
bottom: 0, icon: Icons.flash_on,
right: 0, onTap: () => controller.toggleTorch(),
child: Container( alignment: Alignment.topRight,
width: 45, ),
height: 45, _buildControlButton(
decoration: BoxDecoration( icon: Icons.flip_camera_android,
shape: BoxShape.rectangle, onTap: () => controller.switchCamera(),
color: kMainColor1, alignment: Alignment.bottomRight,
borderRadius: BorderRadius.circular(20.0),
),
margin: const EdgeInsets.all(8),
child: InkWell(
onTap: () async {
await controller?.flipCamera();
setState(() {});
},
child: FutureBuilder(
future: controller?.getCameraInfo(),
builder: (context, snapshot) {
return const Icon(Icons.flip_camera_android, color: Colors.white);
},
)),
),
), ),
], ],
), ),
); );
} }
Widget _buildQrView(BuildContext context) { Widget _buildControlButton({
// For this example we check how width or tall the device is and change the scanArea and overlay accordingly. required IconData icon,
var scanArea = (MediaQuery.of(context).size.width < 400 || required VoidCallback onTap,
MediaQuery.of(context).size.height < 400) required Alignment alignment,
? 225.0 }) {
: 300.0; return Align(
alignment: alignment,
// To ensure the Scanner view is properly sizes after rotation child: Container(
// we need to listen for Flutter SizeChanged notification and update controller width: 45,
return QRView( height: 45,
key: qrKey, margin: const EdgeInsets.all(8),
onQRViewCreated: _onQRViewCreated, decoration: BoxDecoration(
overlay: QrScannerOverlayShape( shape: BoxShape.rectangle,
borderColor: kMainColor1, color: kMainColor1,
borderRadius: 10, borderRadius: BorderRadius.circular(20.0),
borderLength: 25, ),
borderWidth: 5, child: InkWell(
overlayColor: Colors.black.withValues(alpha: 0.55), onTap: onTap,
cutOutSize: 225.0), child: Icon(icon, color: Colors.white),
onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p), ),
),
); );
} }
_onQRViewCreated(QRViewController controller) { void _onDetect(BarcodeCapture capture) {
setState(() { if (isProcessing) return;
this.controller = controller;
});
if (Platform.isAndroid) {
controller.pauseCamera();
}
controller.resumeCamera();
controller.scannedDataStream.listen((scanData) {
setState(() {
result = scanData;
var code = result == null ? "" : result!.code.toString(); final barcode = capture.barcodes.first;
if(result!.format == BarcodeFormat.qrcode) { final code = barcode.rawValue ?? "";
controller.pauseCamera();
RegExp regExp = RegExp(r'^(?:https:\/\/web\.myinfomate\.be\/([^\/]+)\/([^\/]+)\/([^\/]+)|([^\/]+))$'); if (barcode.format == BarcodeFormat.qrCode && code.isNotEmpty) {
var match = regExp.firstMatch(code); isProcessing = true;
String? instanceId;
String? configurationId;
String? sectionId;
if (match != null) { RegExp regExp = RegExp(r'^(?:https:\/\/web\.myinfomate\.be\/([^\/]+)\/([^\/]+)\/([^\/]+)|([^\/]+))$');
instanceId = match.group(1); RegExp regExp2 = RegExp(r'^(?:https:\/\/web\.mymuseum\.be\/([^\/]+)\/([^\/]+)\/([^\/]+)|([^\/]+))$');
configurationId = match.group(2); var match = regExp.firstMatch(code);
sectionId = match.group(3) ?? match.group(4); var match2 = regExp2.firstMatch(code);
String? instanceId;
String? configurationId;
String? sectionId;
print('InstanceId: $instanceId'); if(match == null) {
print('ConfigurationId: $configurationId'); instanceId = match2?.group(1);
print('SectionId: $sectionId'); configurationId = match2?.group(2);
} else { sectionId = match2?.group(3) ?? match2?.group(4);
print('L\'URL ne correspond pas au format attendu.'); } else {
} instanceId = match.group(1);
configurationId = match.group(2);
sectionId = match.group(3) ?? match.group(4);
}
if ((match == null && match2 == null) || sectionId == null) {
//print("QR CODE FOUND");
/*ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('QR CODE FOUND - ${code.toString()}')),
);*/
VisitAppContext visitAppContext = widget.appContext!.getContext();
if(!visitAppContext.sectionIds!.contains(sectionId) || sectionId == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', widget.appContext!.getContext())), backgroundColor: kMainColor2),
);
Navigator.of(context).pop();
} else {
SectionDTO section = visitAppContext.currentSections!.firstWhere((cs) => cs!.id == sectionId)!;
switch(section.type) {
case SectionType.Article:
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) {
return ArticlePage(visitAppContextIn: visitAppContext, articleId: section.id!);
},
),
);
break;
case SectionType.Quizz:
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) {
return QuizzPage(visitAppContextIn: visitAppContext, sectionId: section.id!);
},
),
);
break;
}
}
}
});
});
}
Future<void> _onPermissionSet(BuildContext context, QRViewController ctrl, bool p) async {
//log('${DateTime.now().toIso8601String()}_onPermissionSet $p');
if (!p) {
var status = await Permission.camera.status;
if(!status.isGranted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('no Permission')), SnackBar(content: Text("L'URL ne correspond pas au format attendu."), backgroundColor: kMainColor2),
); );
Navigator.of(context).pop();
return;
}
// You can request multiple permissions at once. VisitAppContext visitAppContext = widget.appContext!.getContext();
Map<Permission, PermissionStatus> statuses = await [
Permission.camera, if (visitAppContext.sectionIds == null || !visitAppContext.sectionIds!.contains(sectionId)) {
].request(); visitAppContext.statisticsService?.track(VisitEventType.qrScan, metadata: {'valid': false, 'sectionId': sectionId});
print(statuses[Permission.camera]); ScaffoldMessenger.of(context).showSnackBar(
print(status); SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', visitAppContext)), backgroundColor: kMainColor2),
);
Navigator.of(context).pop();
} else {
visitAppContext.statisticsService?.track(VisitEventType.qrScan, sectionId: sectionId, metadata: {'valid': true});
dynamic rawSection = visitAppContext.currentSections!.firstWhere((cs) => cs!['id'] == sectionId)!;
Navigator.of(context).pop();
Navigator.push(
context,
SlideFromRightRoute(page: SectionPage(
configuration: visitAppContext.configuration!,
rawSection: rawSection,
visitAppContextIn: visitAppContext,
sectionId: rawSection['id'],
)),
);
} }
} }
} }
@override @override
void dispose() { void dispose() {
controller?.dispose(); controller.dispose();
super.dispose(); super.dispose();
} }
} }
showScannerDialog (BuildContext context, AppContext appContext) { showScannerDialog(BuildContext context, AppContext appContext) {
showDialog( showDialog(
builder: (BuildContext context) => AlertDialog( context: context,
shape: const RoundedRectangleBorder( builder: (BuildContext context) => AlertDialog(
borderRadius: BorderRadius.all(Radius.circular(10.0)) shape: const RoundedRectangleBorder(
), borderRadius: BorderRadius.all(Radius.circular(10.0)),
content: ScannerDialog(appContext: appContext), ),
contentPadding: EdgeInsets.zero, content: ScannerDialog(appContext: appContext),
), context: context contentPadding: EdgeInsets.zero,
),
); );
} }

View File

@ -8,9 +8,11 @@ class SearchBox extends StatefulWidget {
const SearchBox({ const SearchBox({
Key? key, Key? key,
this.onChanged, this.onChanged,
this.width,
}) : super(key: key); }) : super(key: key);
final ValueChanged? onChanged; final ValueChanged? onChanged;
final double? width;
@override @override
State<SearchBox> createState() => _SearchBoxState(); State<SearchBox> createState() => _SearchBoxState();
@ -25,17 +27,18 @@ class _SearchBoxState extends State<SearchBox> {
final appContext = Provider.of<AppContext>(context); final appContext = Provider.of<AppContext>(context);
return Container( return Container(
width: size.width*0.65, width: widget.width ?? size.width*0.65,
margin: const EdgeInsets.all(kDefaultPadding), margin: const EdgeInsets.all(kDefaultPadding),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: kDefaultPadding, horizontal: kDefaultPadding,
vertical: kDefaultPadding / 4, // 5 top and bottom vertical: kDefaultPadding / 4, // 5 top and bottom
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.4), color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: TextFormField( child: TextFormField(
cursorColor: kMainColor,
controller: _controller, controller: _controller,
onChanged: widget.onChanged, onChanged: widget.onChanged,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),

View File

@ -26,10 +26,11 @@ class _SearchNumberBoxState extends State<SearchNumberBox> {
vertical: kDefaultPadding / 4, // 5 top and bottom vertical: kDefaultPadding / 4, // 5 top and bottom
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.4), color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: TextFormField( child: TextFormField(
cursorColor: kMainColor,
controller: _controller, controller: _controller,
onChanged: widget.onChanged, onChanged: widget.onChanged,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,

View File

@ -3,7 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/resourceModel.dart'; import 'package:mymuseum_visitapp/Models/resourceModel.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';

View File

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class SlideFromRightRoute extends PageRouteBuilder {
final Widget page;
SlideFromRightRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(1.0, 0.0); // départ à droite (hors écran)
const end = Offset.zero; // arrivée position normale
const curve = Curves.easeInOut;
final tween =
Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
final offsetAnimation = animation.drive(tween);
return SlideTransition(
position: offsetAnimation,
child: child,
);
},
);
}

View File

@ -1,10 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'package:mymuseum_visitapp/Components/Carousel/carousel_slider.dart' as cs; import 'package:carousel_slider/carousel_slider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/ShowImagePopup.dart'; import 'package:mymuseum_visitapp/Components/ShowImagePopup.dart';
import 'package:mymuseum_visitapp/Models/resourceModel.dart'; import 'package:mymuseum_visitapp/Models/resourceModel.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Map/marker_view.dart';
import 'package:mymuseum_visitapp/Services/apiService.dart'; import 'package:mymuseum_visitapp/Services/apiService.dart';
import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/constants.dart';
@ -22,12 +23,12 @@ class SliderImagesWidget extends StatefulWidget {
class _SliderImagesWidget extends State<SliderImagesWidget> { class _SliderImagesWidget extends State<SliderImagesWidget> {
List<ResourceModel?> resourcesInWidget = []; List<ResourceModel?> resourcesInWidget = [];
late cs.CarouselController? sliderController; late CarouselSliderController? sliderController;
final ValueNotifier<int> currentIndex = ValueNotifier<int>(1); final ValueNotifier<int> currentIndex = ValueNotifier<int>(1);
@override @override
void initState() { void initState() {
sliderController = cs.CarouselController(); sliderController = CarouselSliderController();
resourcesInWidget = widget.resources; resourcesInWidget = widget.resources;
super.initState(); super.initState();
} }
@ -53,10 +54,10 @@ class _SliderImagesWidget extends State<SliderImagesWidget> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if(resourcesInWidget.isNotEmpty) if(resourcesInWidget.isNotEmpty)
cs.CarouselSlider( CarouselSlider(
carouselController: sliderController, carouselController: sliderController,
options: cs.CarouselOptions( options: CarouselOptions(
onPageChanged: (int index, cs.CarouselPageChangedReason reason) { onPageChanged: (int index, CarouselPageChangedReason reason) {
//setState(() { //setState(() {
//print("SET STATE"); //print("SET STATE");
currentIndex.value = index + 1; currentIndex.value = index + 1;
@ -69,8 +70,12 @@ class _SliderImagesWidget extends State<SliderImagesWidget> {
items: resourcesInWidget.map<Widget>((i) { items: resourcesInWidget.map<Widget>((i) {
return Builder( return Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
AppContext appContext = Provider.of<AppContext>(context);
ContentDTO contentDTO = ContentDTO(resourceId: i!.id, resource: ResourceDTO(id: i.id, type: i.type, label: i.label, url: i.source));
var resourcetoShow = getElementForResource(context, appContext, contentDTO, true);
return resourcetoShow;
//print(widget.imagesDTO[currentIndex-1]); //print(widget.imagesDTO[currentIndex-1]);
return FutureBuilder( /*return FutureBuilder(
future: ApiService.getResource(appContext, visitAppContext.configuration!, i!.id!), future: ApiService.getResource(appContext, visitAppContext.configuration!, i!.id!),
builder: (context, AsyncSnapshot<dynamic> snapshot) { builder: (context, AsyncSnapshot<dynamic> snapshot) {
return Padding( return Padding(
@ -118,7 +123,7 @@ class _SliderImagesWidget extends State<SliderImagesWidget> {
), ),
); );
} }
); );*/
}, },
); );
}).toList(), }).toList(),

View File

@ -0,0 +1,299 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:provider/provider.dart';
import 'package:just_audio/just_audio.dart';
import 'package:just_audio_cache/just_audio_cache.dart';
class AudioPlayerFloatingContainer extends StatefulWidget {
const AudioPlayerFloatingContainer({Key? key, required this.file, required this.audioBytes, required this.resourceURl, required this.isAuto}) : super(key: key);
final File? file;
final Uint8List? audioBytes;
final String resourceURl;
final bool isAuto;
@override
State<AudioPlayerFloatingContainer> createState() => _AudioPlayerFloatingContainerState();
}
class _AudioPlayerFloatingContainerState extends State<AudioPlayerFloatingContainer> {
AudioPlayer player = AudioPlayer();
Uint8List? audiobytes = null;
bool isplaying = false;
bool audioplayed = false;
int currentpos = 0;
int maxduration = 100;
Duration? durationAudio;
String currentpostlabel = "00:00";
bool _isDisposed = false;
@override
void initState() {
//print("IN INITSTATE AUDDDIOOOO");
Future.delayed(Duration.zero, () async {
if(widget.audioBytes != null) {
audiobytes = widget.audioBytes!;
}
if(widget.file != null) {
audiobytes = await fileToUint8List(widget.file!);
}
player.durationStream.listen((Duration? d) { //get the duration of audio
if(d != null) {
maxduration = d.inSeconds;
durationAudio = d;
}
});
//player.bufferedPositionStream
player.positionStream.listen((event) {
if(durationAudio != null) {
currentpos = event.inMilliseconds; //get the current position of playing audio
//generating the duration label
int shours = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inHours;
int sminutes = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inMinutes;
int sseconds = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inSeconds;
int rminutes = sminutes - (shours * 60);
int rseconds = sseconds - (sminutes * 60 + shours * 60 * 60);
String minutesToShow = rminutes < 10 ? '0$rminutes': rminutes.toString();
String secondsToShow = rseconds < 10 ? '0$rseconds': rseconds.toString();
currentpostlabel = "$minutesToShow:$secondsToShow";
setState(() {
//refresh the UI
if(currentpos > player.duration!.inMilliseconds) {
print("RESET ALL");
player.stop();
player.seek(const Duration(seconds: 0));
isplaying = false;
audioplayed = false;
currentpostlabel = "00:00";
}
});
}
});
/*player.onPositionChanged.listen((Duration p){
currentpos = p.inMilliseconds; //get the current position of playing audio
//generating the duration label
int shours = Duration(milliseconds:currentpos).inHours;
int sminutes = Duration(milliseconds:currentpos).inMinutes;
int sseconds = Duration(milliseconds:currentpos).inSeconds;
int rminutes = sminutes - (shours * 60);
int rseconds = sseconds - (sminutes * 60 + shours * 60 * 60);
String minutesToShow = rminutes < 10 ? '0$rminutes': rminutes.toString();
String secondsToShow = rseconds < 10 ? '0$rseconds': rseconds.toString();
currentpostlabel = "$minutesToShow:$secondsToShow";
setState(() {
//refresh the UI
});
});*/
/*if(audiobytes != null) {
print("GOT AUDIOBYYYTES - LOCALLY SOSO");
await player.setAudioSource(LoadedSource(audiobytes!));
} else {
print("GET SOUND BY URL");
await player.dynamicSet(url: widget.resourceURl);
}*/
if(widget.isAuto) {
//player.play(BytesSource(audiobytes));
//
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}
});
super.initState();
Future.microtask(() async {
try {
if (_isDisposed || !mounted) return;
if (widget.audioBytes != null) {
await player.setAudioSource(LoadedSource(widget.audioBytes!));
} else {
await player.dynamicSet(url: widget.resourceURl);
}
if (_isDisposed || !mounted) return;
if (widget.isAuto) {
await player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}
} catch (e, stack) {
debugPrint('Audio error: $e');
debugPrintStack(stackTrace: stack);
}
});
}
@override
void dispose() {
_isDisposed = true;
player.stop();
player.dispose();
super.dispose();
}
Future<Uint8List> fileToUint8List(File file) async {
List<int> bytes = await file.readAsBytes();
return Uint8List.fromList(bytes);
}
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
VisitAppContext visitAppContext = appContext.getContext();
Size size = MediaQuery.of(context).size;
return InkWell(
onTap: () async {
if(!isplaying && !audioplayed){
//player.play(BytesSource(audiobytes));
//await player.setUrl(widget.resourceURl);
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}else if(audioplayed && !isplaying){
//player.resume();
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}else{
player.pause();
setState(() {
isplaying = false;
});
}
},
child: Container(
width: size.width,
decoration: BoxDecoration(
color: Color(int.parse(visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)).withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 15.0),
),
child: isplaying ? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.pause),
Text(currentpostlabel),
],
) : audioplayed ? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.play_arrow),
Text(currentpostlabel),
],
): const Icon(Icons.play_arrow),
/*Column(
children: [
//Text(currentpostlabel, style: const TextStyle(fontSize: 25)),
Wrap(
spacing: 10,
children: [
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: kSecondColor, // Background color
),
onPressed: () async {
if(!isplaying && !audioplayed){
//player.play(BytesSource(audiobytes));
await player.setAudioSource(LoadedSource(audiobytes));
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}else if(audioplayed && !isplaying){
//player.resume();
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}else{
player.pause();
setState(() {
isplaying = false;
});
}
},
icon: Icon(isplaying?Icons.pause:Icons.play_arrow),
//label:Text(isplaying?TranslationHelper.getFromLocale("pause", appContext.getContext()):TranslationHelper.getFromLocale("play", appContext.getContext()))
),
/*ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: kSecondColor, // Background color
),
onPressed: () async {
player.stop();
player.seek(const Duration(seconds: 0));
setState(() {
isplaying = false;
audioplayed = false;
currentpostlabel = "00:00";
});
},
icon: const Icon(Icons.stop),
//label: Text(TranslationHelper.getFromLocale("stop", appContext.getContext()))
),*/
],
)
],
),*/
),
);
}
}
// Feed your own stream of bytes into the player
class LoadedSource extends StreamAudioSource {
final List<int> bytes;
LoadedSource(this.bytes);
@override
Future<StreamAudioResponse> request([int? start, int? end]) async {
start ??= 0;
end ??= bytes.length;
return StreamAudioResponse(
sourceLength: bytes.length,
contentLength: end - start,
offset: start,
stream: Stream.value(bytes.sublist(start, end)),
contentType: 'audio/mpeg',
);
}
}

View File

@ -0,0 +1,126 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/audio_player.dart';
import 'package:mymuseum_visitapp/Components/video_viewer.dart';
import 'package:mymuseum_visitapp/Components/video_viewer_youtube.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
class CachedCustomResource extends StatelessWidget {
final ResourceDTO resourceDTO;
final bool isAuto;
final bool webView;
final BoxFit fit;
CachedCustomResource({
required this.resourceDTO,
required this.isAuto,
required this.webView,
this.fit = BoxFit.cover,
});
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
VisitAppContext visitAppContext = appContext.getContext();
Size size = MediaQuery.of(context).size;
Color primaryColor = Color(int.parse(visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16));
if(resourceDTO.type == ResourceType.ImageUrl || resourceDTO.type == ResourceType.VideoUrl)
{
// Image Url or Video Url don't care, just get resource
if(resourceDTO.type == ResourceType.ImageUrl) {
return CachedNetworkImage(
imageUrl: resourceDTO.url!,
fit: BoxFit.fill,
progressIndicatorBuilder: (context, url, downloadProgress) =>
CircularProgressIndicator(value: downloadProgress.progress, color: primaryColor),
errorWidget: (context, url, error) => Icon(Icons.error),
);
} else {
if(resourceDTO.url == null) {
return const Center(child: Text("Error loading video"));
} else {
return VideoViewerYoutube(videoUrl: resourceDTO.url!, isAuto: isAuto, webView: webView);
}
}
} else {
// Check if exist on local storage, if no, just show it via url
print("Check local storage in cached custom resource");
return FutureBuilder<File?>(
future: _checkIfLocalResourceExists(visitAppContext),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
// Loader ou indicateur de chargement pendant la vérification
return const CircularProgressIndicator();
} else if (snapshot.hasError || snapshot.data == null) {
// Si la ressource locale n'existe pas ou s'il y a une erreur
switch(resourceDTO.type) {
case ResourceType.Image :
return CachedNetworkImage(
imageUrl: resourceDTO.url!,
fit: fit,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
);
case ResourceType.Video :
return VideoViewer(file: null, videoUrl: resourceDTO.url!);
case ResourceType.Audio :
return AudioPlayerFloatingContainer(file: null, audioBytes: null, resourceURl: resourceDTO.url!, isAuto: isAuto);
default:
return const Text("Not supported type");
}
} else {
switch(resourceDTO.type) {
case ResourceType.Image :
return Image.file(
snapshot.data!,
fit: fit,
);
case ResourceType.Video :
return VideoViewer(file: snapshot.data!, videoUrl: resourceDTO.url!);
case ResourceType.Audio :
return AudioPlayerFloatingContainer(file: snapshot.data!, audioBytes: null, resourceURl: resourceDTO.url!, isAuto: isAuto);
default:
return const Text("Not supported type");
}
// Utilisation de l'image locale
}
},
);
}
}
Future<File?> _checkIfLocalResourceExists(VisitAppContext visitAppContext) async {
try {
Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory();
String localPath = appDocumentsDirectory!.path;
Directory configurationDirectory = Directory('$localPath/${visitAppContext.configuration!.id}');
List<FileSystemEntity> fileList = configurationDirectory.listSync();
if(fileList.any((fileL) => fileL.uri.pathSegments.last.contains(resourceDTO.id!))) {
File file = File(fileList.firstWhere((fileL) => fileL.uri.pathSegments.last.contains(resourceDTO.id!)).path);
return file;
}
} catch(e) {
print("ERROR _checkIfLocalResourceExists CachedCustomResource");
print(e);
}
return null;
}
Future<String> get localPath async {
Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory();
return appDocumentsDirectory!.path;
}
}

View File

@ -1,23 +0,0 @@
import 'package:flutter/material.dart';
class FadeRoute extends PageRouteBuilder {
final Widget page;
FadeRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = 0.0;
const end = 1.0;
const curve = Curves.easeInOut;
final tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
final opacityAnimation = animation.drive(tween);
return FadeTransition(
opacity: opacityAnimation,
child: child,
);
},
);
}

View File

@ -2,7 +2,7 @@ import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/constants.dart';
@ -81,7 +81,14 @@ class _LoadingCommonState extends State<LoadingCommon> with TickerProviderStateM
} }
}, },
) )
: Icon(Icons.museum_outlined, color: kMainColor2, size: size.height*0.1), : Image.asset(
kLoaderAsset,
width: size.height * 0.1,
height: size.height * 0.1,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
Icon(Icons.museum_outlined, color: kMainColor2, size: size.height * 0.1),
),
), ),
), ),
); );

View File

@ -0,0 +1,92 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/audio_player.dart';
import 'package:mymuseum_visitapp/Components/video_viewer.dart';
import 'package:mymuseum_visitapp/Components/video_viewer_youtube.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'cached_custom_resource.dart';
showElementForResource(ResourceDTO resourceDTO, AppContext appContext, bool isAuto, bool webView) {
VisitAppContext visitAppContext = appContext.getContext();
Color primaryColor = Color(int.parse(visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16));
return CachedCustomResource(resourceDTO: resourceDTO, isAuto: isAuto, webView: webView);
switch(resourceDTO.type) {
case ResourceType.Image:
case ResourceType.ImageUrl:
return CachedNetworkImage(
imageUrl: resourceDTO.url!,
fit: BoxFit.fill,
progressIndicatorBuilder: (context, url, downloadProgress) =>
CircularProgressIndicator(value: downloadProgress.progress, color: primaryColor),
errorWidget: (context, url, error) => Icon(Icons.error),
);
/*return Image.network(
resourceDTO.url!,
fit:BoxFit.fill,
loadingBuilder: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Center(
child: CircularProgressIndicator(
color: primaryColor,
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
);*/
case ResourceType.Audio:
return AudioPlayerFloatingContainer(file: null, audioBytes: null, resourceURl: resourceDTO.url!, isAuto: isAuto);
/*return FutureBuilder(
future: getAudio(resourceDTO.url, appContext),
builder: (context, AsyncSnapshot<dynamic> snapshot) {
Size size = MediaQuery.of(context).size;
if (snapshot.connectionState == ConnectionState.done) {
var audioBytes;
if(snapshot.data != null) {
print("snapshot.data");
print(snapshot.data);
audioBytes = snapshot.data;
//this.player.playBytes(audiobytes);
}
return AudioPlayerFloatingContainer(audioBytes: audioBytes, resourceURl: resourceDTO.url, isAuto: true);
} else if (snapshot.connectionState == ConnectionState.none) {
return Text("No data");
} else {
return Center(
child: Container(
//height: size.height * 0.2,
width: size.width * 0.2,
child: LoadingCommon()
)
);
}
}
);*/
case ResourceType.Video:
if(resourceDTO.url == null) {
return Center(child: Text("Error loading video"));
} else {
return VideoViewer(file: null, videoUrl: resourceDTO.url!);
}
case ResourceType.VideoUrl:
if(resourceDTO.url == null) {
return Center(child: Text("Error loading video"));
} else {
return VideoViewerYoutube(videoUrl: resourceDTO.url!, isAuto: isAuto, webView: webView);
}
}
}

View File

@ -0,0 +1,94 @@
import 'dart:io';
//import 'package:cached_video_player/cached_video_player.dart';
import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Components/loading_common.dart';
import 'package:video_player/video_player.dart';
import '../../constants.dart';
class VideoViewer extends StatefulWidget {
final String videoUrl;
final File? file;
VideoViewer({required this.videoUrl, required this.file});
@override
_VideoViewer createState() => _VideoViewer();
}
class _VideoViewer extends State<VideoViewer> {
late VideoPlayerController _controller; // Cached
@override
void initState() {
super.initState();
if(widget.file != null) {
_controller = VideoPlayerController.file(widget.file!) // Uri.parse() // Cached
..initialize().then((_) {
// Ensure the first frame is shown after the video is initialized, even before the play button has been pressed.
setState(() {});
});
} else {
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)) // Uri.parse()
..initialize().then((_) {
// Ensure the first frame is shown after the video is initialized, even before the play button has been pressed.
setState(() {});
});
}
}
@override
void dispose() {
super.dispose();
_controller.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Center(
child: InkWell(
onTap: () {
setState(() {
if(_controller.value.isInitialized) {
if(_controller.value.isPlaying) {
_controller.pause();
} else {
_controller.play();
}
}
});
},
child: _controller.value.isInitialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
)
: Center(
child: Container(
child: LoadingCommon()
)
),
),
),
if(!_controller.value.isPlaying && _controller.value.isInitialized)
Center(
child: FloatingActionButton(
backgroundColor: kMainColor.withValues(alpha: 0.8),
onPressed: () {
setState(() {
_controller.value.isPlaying
? _controller.pause()
: _controller.play();
});
},
child: Icon(
_controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white),
),
)
],
);
}
}

View File

@ -0,0 +1,99 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:youtube_player_iframe/youtube_player_iframe.dart' as iframe;
//import 'package:youtube_player_flutter/youtube_player_flutter.dart';
class VideoViewerYoutube extends StatefulWidget {
final String videoUrl;
final bool isAuto;
final bool webView;
VideoViewerYoutube({required this.videoUrl, required this.isAuto, this.webView = false});
@override
_VideoViewerYoutube createState() => _VideoViewerYoutube();
}
class _VideoViewerYoutube extends State<VideoViewerYoutube> {
iframe.YoutubePlayer? _videoViewWeb;
//YoutubePlayer? _videoView;
@override
void initState() {
String? videoId;
if (widget.videoUrl.isNotEmpty ) {
//videoId = YoutubePlayer.convertUrlToId(widget.videoUrl);
if (true) {
final _controllerWeb = iframe.YoutubePlayerController(
params: iframe.YoutubePlayerParams(
mute: false,
showControls: false,
showFullscreenButton: false,
loop: false,
showVideoAnnotations: false,
strictRelatedVideos: false,
enableKeyboard: false,
enableCaption: false,
pointerEvents: iframe.PointerEvents.auto
),
);
_controllerWeb.loadVideo(widget.videoUrl);
if(!widget.isAuto) {
_controllerWeb.stopVideo();
}
_videoViewWeb = iframe.YoutubePlayer(
controller: _controllerWeb,
//showVideoProgressIndicator: false,
/*progressIndicatorColor: Colors.amber,
progressColors: ProgressBarColors(
playedColor: Colors.amber,
handleColor: Colors.amberAccent,
),*/
);
} else /*{
// Cause memory issue on tablet
videoId = YoutubePlayer.convertUrlToId(widget.videoUrl);
YoutubePlayerController _controller = YoutubePlayerController(
initialVideoId: videoId!,
flags: YoutubePlayerFlags(
autoPlay: widget.isAuto,
controlsVisibleAtStart: false,
loop: true,
hideControls: false,
hideThumbnail: false,
),
);
_videoView = YoutubePlayer(
controller: _controller,
//showVideoProgressIndicator: false,
progressIndicatorColor: Colors.amber,
progressColors: ProgressBarColors(
playedColor: Colors.amber,
handleColor: Colors.amberAccent,
),
);
}*/
super.initState();
}
}
@override
void dispose() {
//_videoView = null;
_videoViewWeb = null;
super.dispose();
}
@override
Widget build(BuildContext context) => widget.videoUrl.isNotEmpty ?
_videoViewWeb!: //(widget.webView ? _videoViewWeb! : _videoView!)
const Center(child: Text("La vidéo ne peut pas être affichée, l'url est incorrecte", style: TextStyle(fontSize: kNoneInfoOrIncorrect)));
}

View File

@ -1,6 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/articleRead.dart'; import 'package:mymuseum_visitapp/Models/articleRead.dart';
import 'package:mymuseum_visitapp/Models/beaconSection.dart'; import 'package:mymuseum_visitapp/Models/beaconSection.dart';
import 'package:mymuseum_visitapp/Models/resourceModel.dart'; import 'package:mymuseum_visitapp/Models/resourceModel.dart';
@ -31,8 +31,7 @@ class DatabaseHelper {
static const columnData = 'data'; static const columnData = 'data';
static const columnType = 'type'; static const columnType = 'type';
static const columnDateCreation = 'dateCreation'; static const columnDateCreation = 'dateCreation';
static const columnIsMobile = 'isMobile'; // columnIsMobile/columnIsTablet removed: now in AppConfigurationLink
static const columnIsTablet = 'isTablet';
static const columnIsOffline = 'isOffline'; static const columnIsOffline = 'isOffline';
static const configurationsTable = 'configurations'; static const configurationsTable = 'configurations';
@ -65,6 +64,8 @@ class DatabaseHelper {
static const columnIsAdmin = 'isAdmin'; static const columnIsAdmin = 'isAdmin';
static const columnIsAllLanguages = 'isAllLanguages'; static const columnIsAllLanguages = 'isAllLanguages';
static const columnApiKey = 'apiKey';
static const columnNotificationsEnabled = 'notificationsEnabled';
DatabaseHelper._privateConstructor(); DatabaseHelper._privateConstructor();
@ -158,7 +159,9 @@ class DatabaseHelper {
$columnLanguage TEXT NOT NULL, $columnLanguage TEXT NOT NULL,
$columnInstanceId TEXT NOT NULL, $columnInstanceId TEXT NOT NULL,
$columnIsAdmin BOOLEAN NOT NULL CHECK ($columnIsAdmin IN (0,1)), $columnIsAdmin BOOLEAN NOT NULL CHECK ($columnIsAdmin IN (0,1)),
$columnIsAllLanguages BOOLEAN CHECK ($columnIsAllLanguages IN (0,1)) $columnIsAllLanguages BOOLEAN CHECK ($columnIsAllLanguages IN (0,1)),
$columnApiKey TEXT,
$columnNotificationsEnabled BOOLEAN CHECK ($columnNotificationsEnabled IN (0,1))
) )
'''); ''');
break; break;
@ -172,11 +175,9 @@ class DatabaseHelper {
$columnImageId TEXT, $columnImageId TEXT,
$columnImageSource TEXT, $columnImageSource TEXT,
$columnLanguages TEXT NOT NULL, $columnLanguages TEXT NOT NULL,
$columnDateCreation TEXT NOT NULL, $columnDateCreation TEXT NOT NULL,
$columnPrimaryColor TEXT, $columnPrimaryColor TEXT,
$columnSecondaryColor TEXT, $columnSecondaryColor TEXT,
$columnIsMobile BOOLEAN NOT NULL CHECK ($columnIsMobile IN (0,1)),
$columnIsTablet BOOLEAN NOT NULL CHECK ($columnIsTablet IN (0,1)),
$columnIsOffline BOOLEAN NOT NULL CHECK ($columnIsOffline IN (0,1)) $columnIsOffline BOOLEAN NOT NULL CHECK ($columnIsOffline IN (0,1))
) )
'''); ''');
@ -255,6 +256,12 @@ class DatabaseHelper {
} else { } else {
print("IN columnIsAllLanguages"); print("IN columnIsAllLanguages");
} }
if(test.where((e) => e.toString().contains(columnApiKey)).isEmpty) {
await db.rawQuery("ALTER TABLE $nameOfTable ADD $columnApiKey TEXT");
}
if(test.where((e) => e.toString().contains(columnNotificationsEnabled)).isEmpty) {
await db.rawQuery("ALTER TABLE $nameOfTable ADD $columnNotificationsEnabled BOOLEAN CHECK ($columnNotificationsEnabled IN (0,1))");
}
DatabaseHelper.instance.insert(DatabaseTableType.main, visitAppContext.toMap()); DatabaseHelper.instance.insert(DatabaseTableType.main, visitAppContext.toMap());
} catch (e) { } catch (e) {
print("ERROR IN updateTableMain"); print("ERROR IN updateTableMain");
@ -321,6 +328,8 @@ class DatabaseHelper {
language: element["language"], language: element["language"],
isAdmin: element["isAdmin"] == 1 ? true : false, isAdmin: element["isAdmin"] == 1 ? true : false,
isAllLanguages: element["isAllLanguages"] == 1 ? true : false, isAllLanguages: element["isAllLanguages"] == 1 ? true : false,
apiKey: element["apiKey"] as String?,
notificationsEnabled: element["notificationsEnabled"] == null || element["notificationsEnabled"] == 1,
); );
break; break;
case DatabaseTableType.configurations: case DatabaseTableType.configurations:
@ -381,8 +390,6 @@ class DatabaseHelper {
secondaryColor: element["secondaryColor"], secondaryColor: element["secondaryColor"],
languages: List<String>.from(jsonDecode(element["languages"])), languages: List<String>.from(jsonDecode(element["languages"])),
dateCreation: DateTime.tryParse(element["dateCreation"]), dateCreation: DateTime.tryParse(element["dateCreation"]),
isMobile: element["isMobile"] == 1 ? true : false,
isTablet: element["isTablet"] == 1 ? true : false,
isOffline: element["isOffline"] == 1 ? true : false isOffline: element["isOffline"] == 1 ? true : false
); );
} }
@ -400,7 +407,7 @@ class DatabaseHelper {
imageSource: element["imageSource"], imageSource: element["imageSource"],
configurationId: element["configurationId"], configurationId: element["configurationId"],
type: SectionType.values[element["type"]], type: SectionType.values[element["type"]],
data: element["data"], // data: element["data"], // TODO section data
dateCreation: DateTime.tryParse(element["dateCreation"]), dateCreation: DateTime.tryParse(element["dateCreation"]),
order: int.parse(element["orderOfElement"]), order: int.parse(element["orderOfElement"]),
); );

View File

@ -0,0 +1,35 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart';
class ImageCustomProvider {
static ImageProvider<Object> getImageProvider(AppContext appContext, String? imageId, String imageSource) {
VisitAppContext visitAppContext = appContext.getContext();
try {
if(appContext.getContext().localPath != null && visitAppContext.configuration != null) {
Directory configurationDirectory = Directory('${visitAppContext.localPath!}/${visitAppContext.configuration!.id!}');
List<FileSystemEntity> fileList = configurationDirectory.listSync();
if(imageId != null && fileList.any((fileL) => fileL.uri.pathSegments.last.contains(imageId))) {
File file = File(fileList.firstWhere((fileL) => fileL.uri.pathSegments.last.contains(imageId)).path);
print("FILE EXISTT");
return FileImage(file);
}
}
} catch(e) {
print("Error getImageProvider");
print(e.toString());
}
// If localpath not found or file missing
print("MISSINGG FILE");
print(imageId);
return CachedNetworkImageProvider(imageSource);
}
}

View File

@ -1,6 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/beaconSection.dart'; import 'package:mymuseum_visitapp/Models/beaconSection.dart';
class ModelsHelper { class ModelsHelper {
@ -16,8 +16,6 @@ class ModelsHelper {
'secondaryColor': configuration.secondaryColor, 'secondaryColor': configuration.secondaryColor,
'languages': configuration.languages, 'languages': configuration.languages,
'dateCreation': configuration.dateCreation!.toUtc().toIso8601String(), 'dateCreation': configuration.dateCreation!.toUtc().toIso8601String(),
'isMobile': configuration.isMobile,
'isTablet': configuration.isTablet,
'isOffline': configuration.isOffline 'isOffline': configuration.isOffline
}; };
} }
@ -35,7 +33,7 @@ class ModelsHelper {
'isSubSection': section.isSubSection, 'isSubSection': section.isSubSection,
'parentId': section.parentId, 'parentId': section.parentId,
'type': section.type!.value, 'type': section.type!.value,
'data': section.data, //'data': section.data, // TODO section data
'dateCreation': section.dateCreation!.toUtc().toIso8601String(), 'dateCreation': section.dateCreation!.toUtc().toIso8601String(),
'orderOfElement': section.order, 'orderOfElement': section.order,
}; };

View File

@ -1,16 +1,16 @@
import 'package:flutter_beacon/flutter_beacon.dart'; // TODO // import 'package:flutter_beacon/flutter_beacon.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
class RequirementStateController extends GetxController { class RequirementStateController extends GetxController {
var bluetoothState = BluetoothState.stateOff.obs; var bluetoothState = false; //BluetoothState.stateOff.obs;
var authorizationStatus = AuthorizationStatus.notDetermined.obs; var authorizationStatus = false; //AuthorizationStatus.notDetermined.obs;
var locationService = false.obs; var locationService = false.obs;
var _startBroadcasting = false.obs; var _startBroadcasting = false.obs;
var _startScanning = false.obs; var _startScanning = false.obs;
var _pauseScanning = false.obs; var _pauseScanning = false.obs;
bool get bluetoothEnabled => bluetoothState.value == BluetoothState.stateOn; /*bool get bluetoothEnabled => bluetoothState.value == BluetoothState.stateOn;
bool get authorizationStatusOk => bool get authorizationStatusOk =>
authorizationStatus.value == AuthorizationStatus.allowed || authorizationStatus.value == AuthorizationStatus.allowed ||
authorizationStatus.value == AuthorizationStatus.always; authorizationStatus.value == AuthorizationStatus.always;
@ -22,7 +22,7 @@ class RequirementStateController extends GetxController {
updateAuthorizationStatus(AuthorizationStatus status) { updateAuthorizationStatus(AuthorizationStatus status) {
authorizationStatus.value = status; authorizationStatus.value = status;
} }*/
updateLocationService(bool flag) { updateLocationService(bool flag) {
locationService.value = flag; locationService.value = flag;

View File

@ -1,4 +1,4 @@
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/translations.dart'; import 'package:mymuseum_visitapp/translations.dart';

View File

@ -0,0 +1,59 @@
class AiCard {
final String title;
final String subtitle;
final String? icon;
const AiCard({required this.title, required this.subtitle, this.icon});
factory AiCard.fromJson(Map<String, dynamic> json) => AiCard(
title: json['title'] as String? ?? '',
subtitle: json['subtitle'] as String? ?? '',
icon: json['icon'] as String?,
);
}
class AssistantNavigationAction {
final String sectionId;
final String sectionTitle;
final String sectionType;
final String? imageUrl;
const AssistantNavigationAction({
required this.sectionId,
required this.sectionTitle,
required this.sectionType,
this.imageUrl,
});
factory AssistantNavigationAction.fromJson(Map<String, dynamic> json) =>
AssistantNavigationAction(
sectionId: json['sectionId'] as String? ?? '',
sectionTitle: json['sectionTitle'] as String? ?? '',
sectionType: json['sectionType'] as String? ?? '',
imageUrl: json['imageUrl'] as String?,
);
}
class AssistantResponse {
final String reply;
final List<AiCard>? cards;
final AssistantNavigationAction? navigation;
const AssistantResponse({
required this.reply,
this.cards,
this.navigation,
});
factory AssistantResponse.fromJson(Map<String, dynamic> json) =>
AssistantResponse(
reply: json['reply'] as String? ?? '',
cards: (json['cards'] as List<dynamic>?)
?.map((e) => AiCard.fromJson(e as Map<String, dynamic>))
.toList(),
navigation: json['navigation'] != null
? AssistantNavigationAction.fromJson(
json['navigation'] as Map<String, dynamic>)
: null,
);
}

View File

@ -1,4 +1,4 @@
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
class ResponseSubDTO { class ResponseSubDTO {
List<TranslationAndResourceDTO>? label; List<TranslationAndResourceDTO>? label;

184
lib/Models/agenda.dart Normal file
View File

@ -0,0 +1,184 @@
import 'dart:convert';
import 'package:manager_api_new/api.dart';
class Agenda {
List<EventAgenda> events;
Agenda({required this.events});
factory Agenda.fromJson(String jsonString) {
final List<dynamic> jsonList = json.decode(jsonString);
List<EventAgenda> events = [];
for (var eventData in jsonList) {
try {
events.add(EventAgenda.fromJson(eventData));
} catch(e) {
print("Erreur lors du parsing du json : ${e.toString()}");
}
}
return Agenda(events: events);
}
}
class EventAgenda {
String? name;
String? description;
String? type;
DateTime? dateAdded;
DateTime? dateFrom;
DateTime? dateTo;
String? dateHour;
EventAddress? address;
String? website;
String? phone;
String? idVideoYoutube;
String? videoLink;
String? videoResourceUrl;
String? email;
String? image;
EventAgenda({
required this.name,
required this.description,
required this.type,
required this.dateAdded,
required this.dateFrom,
required this.dateTo,
required this.dateHour,
required this.address,
required this.website,
required this.phone,
required this.idVideoYoutube,
this.videoLink,
this.videoResourceUrl,
required this.email,
required this.image,
});
factory EventAgenda.fromDto(EventAgendaDTO dto, String language) {
String? pickTranslation(List<TranslationDTO>? list) {
if (list == null || list.isEmpty) return null;
return (list.firstWhere(
(t) => t.language == language,
orElse: () => list.first,
)).value;
}
EventAddress? address;
final a = dto.address;
if (a != null) {
final coords = a.geometry?.coordinates as List?;
address = EventAddress(
address: a.address,
lat: coords != null && coords.length >= 2 ? coords[1] : null,
lng: coords != null && coords.length >= 2 ? coords[0] : null,
zoom: a.zoom,
placeId: null,
name: null,
streetNumber: a.streetNumber,
streetName: a.streetName,
streetNameShort: null,
city: a.city,
state: a.state,
stateShort: null,
postCode: a.postCode,
country: a.country,
countryShort: null,
);
}
return EventAgenda(
name: pickTranslation(dto.label),
description: pickTranslation(dto.description),
type: dto.type,
dateAdded: dto.dateAdded,
dateFrom: dto.dateFrom,
dateTo: dto.dateTo,
dateHour: null,
address: address,
website: dto.website,
phone: dto.phone,
idVideoYoutube: dto.idVideoYoutube,
videoLink: dto.videoLink,
videoResourceUrl: dto.videoResource?.url,
email: dto.email,
image: dto.resource?.url,
);
}
factory EventAgenda.fromJson(Map<String, dynamic> json) {
return EventAgenda(
name: json['name'],
description: json['description'],
type: json['type'] is !bool ? json['type'] : null,
dateAdded: json['date_added'] != null && json['date_added'].isNotEmpty ? DateTime.parse(json['date_added']) : null,
dateFrom: json['date_from'] != null && json['date_from'].isNotEmpty ? DateTime.parse(json['date_from']) : null,
dateTo: json['date_to'] != null && json['date_to'].isNotEmpty ? DateTime.parse(json['date_to']) : null,
dateHour: json['date_hour'],
address: json['address'] is !bool ? EventAddress.fromJson(json['address']) : null,
website: json['website'],
phone: json['phone'],
idVideoYoutube: json['id_video_youtube'],
email: json['email'],
image: json['image'],
);
}
}
class EventAddress {
String? address;
dynamic lat;
dynamic lng;
int? zoom;
String? placeId;
String? name;
String? streetNumber;
String? streetName;
String? streetNameShort;
String? city;
String? state;
String? stateShort;
String? postCode;
String? country;
String? countryShort;
EventAddress({
required this.address,
required this.lat,
required this.lng,
required this.zoom,
required this.placeId,
required this.name,
required this.streetNumber,
required this.streetName,
required this.streetNameShort,
required this.city,
required this.state,
required this.stateShort,
required this.postCode,
required this.country,
required this.countryShort,
});
factory EventAddress.fromJson(Map<String, dynamic> json) {
return EventAddress(
address: json['address'],
lat: json['lat'],
lng: json['lng'],
zoom: json['zoom'],
placeId: json['place_id'],
name: json['name'],
streetNumber: json['street_number'],
streetName: json['street_name'],
streetNameShort: json['street_name_short'],
city: json['city'],
state: json['state'],
stateShort: json['state_short'],
postCode: json['post_code'],
country: json['country'],
countryShort: json['country_short'],
);
}
}

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
class SectionRead { class SectionRead {
String id = ""; String id = "";

View File

@ -1,4 +1,4 @@
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
class BeaconSection { class BeaconSection {
int? minorBeaconId; int? minorBeaconId;

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
class ResourceModel { class ResourceModel {
String? id = ""; String? id = "";

View File

@ -1,4 +1,4 @@
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
class Translation { class Translation {
String? language = ""; String? language = "";

View File

@ -1,36 +1,61 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/articleRead.dart'; import 'package:mymuseum_visitapp/Models/articleRead.dart';
import 'package:mymuseum_visitapp/Services/statisticsService.dart';
import 'package:mymuseum_visitapp/Models/beaconSection.dart'; import 'package:mymuseum_visitapp/Models/beaconSection.dart';
import 'package:mymuseum_visitapp/Models/resourceModel.dart'; import 'package:mymuseum_visitapp/Models/resourceModel.dart';
import 'package:mymuseum_visitapp/client.dart'; import 'package:mymuseum_visitapp/client.dart';
import 'package:mymuseum_visitapp/constants.dart';
class VisitAppContext with ChangeNotifier { class VisitAppContext with ChangeNotifier {
Client clientAPI = Client("https://api.myinfomate.be"); // Replace by https://api.mymuseum.be //http://192.168.31.140:8089 Client clientAPI = Client(kApiBaseUrl);
String? id = ""; String? id = "";
String? language = ""; String? language = "";
String? instanceId = "633ee379d9405f32f166f047"; // 63514fd67ed8c735aaa4b8f2 MyInfoMate test instance -- Fort de Saint-Héribert Mymuseum instance id : 633ee379d9405f32f166f047 // 63514fd67ed8c735aaa4b8f1 Mymuseum test // MDLF instance 65ccc67265373befd15be511 String? instanceId = kInstanceId;
String? apiKey;
List<ConfigurationDTO>? configurations; List<ConfigurationDTO>? configurations;
ConfigurationDTO? configuration; ConfigurationDTO? configuration;
List<String?>? sectionIds; // Use to valid QR code found List<String?>? sectionIds; // Use to valid QR code found
List<BeaconSection?>? beaconSections; List<BeaconSection?>? beaconSections;
List<SectionDTO?>? currentSections; List<dynamic>? currentSections;
List<SectionRead> readSections = []; List<SectionRead> readSections = [];
bool isContentCurrentlyShown = false; bool isContentCurrentlyShown = false;
bool isScanningBeacons = false; bool isScanningBeacons = false;
bool isScanBeaconAlreadyAllowed = false; bool isScanBeaconAlreadyAllowed = false;
bool isMaximizeTextSize = false; bool isMaximizeTextSize = false;
Size? puzzleSize;
List<ResourceModel> audiosNotWorking = []; List<ResourceModel> audiosNotWorking = [];
ApplicationInstanceDTO? applicationInstanceDTO; // null = assistant non activé
StatisticsService? statisticsService;
/// Retourne l'AppConfigurationLink de l'instance mobile pour la configuration courante.
/// Contient roundedValue, isSectionImageBackground, etc.
AppConfigurationLink? get currentAppConfigurationLink {
if (applicationInstanceDTO == null || configuration == null) return null;
return applicationInstanceDTO!.configurations
?.where((c) => c.configurationId == configuration!.id)
.firstOrNull;
}
bool? isAdmin = false; bool? isAdmin = false;
bool? isAllLanguages = false; bool? isAllLanguages = false;
bool notificationsEnabled = true;
/// Active le mode lunettes Ray-Ban Meta (SDK DAT + TTS Bluetooth).
bool glassesEnabled = false;
/// Mode proactif lunettes : l'assistant parle automatiquement à l'approche
/// d'un beacon ou d'une zone géographique, sans que le visiteur pose de question.
bool proactiveModeEnabled = false;
String? localPath; String? localPath;
VisitAppContext({this.language, this.id, this.configuration, this.isAdmin, this.isAllLanguages, this.instanceId}); VisitAppContext({this.language, this.id, this.configuration, this.isAdmin, this.isAllLanguages, this.instanceId, this.apiKey, this.notificationsEnabled = true});
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
@ -38,7 +63,9 @@ class VisitAppContext with ChangeNotifier {
'instanceId': instanceId, 'instanceId': instanceId,
'language': language, 'language': language,
'isAdmin': isAdmin != null ? isAdmin! ? 1 : 0 : 0, 'isAdmin': isAdmin != null ? isAdmin! ? 1 : 0 : 0,
'isAllLanguages': isAllLanguages != null ? isAllLanguages! ? 1 : 0 : 0 'isAllLanguages': isAllLanguages != null ? isAllLanguages! ? 1 : 0 : 0,
'apiKey': apiKey,
'notificationsEnabled': notificationsEnabled ? 1 : 0,
}; };
} }
@ -48,6 +75,7 @@ class VisitAppContext with ChangeNotifier {
instanceId: json['instanceId'] as String, instanceId: json['instanceId'] as String,
language: json['language'] as String, language: json['language'] as String,
configuration: json['configuration'] == null ? null : ConfigurationDTO.fromJson(json['configuration']), configuration: json['configuration'] == null ? null : ConfigurationDTO.fromJson(json['configuration']),
apiKey: json['apiKey'] as String?,
); );
} }

217
lib/Models/weatherData.dart Normal file
View File

@ -0,0 +1,217 @@
class WeatherData {
String? cod;
int? message;
int? cnt;
List<WeatherForecast>? list;
City? city;
WeatherData({this.cod, this.message, this.cnt, this.list, this.city});
factory WeatherData.fromJson(Map<String, dynamic> json) {
return WeatherData(
cod: json['cod'],
message: json['message'],
cnt: json['cnt'],
list: (json['list'] as List?)?.map((item) => WeatherForecast.fromJson(item)).toList(),
city: json['city'] != null ? City.fromJson(json['city']) : null,
);
}
}
class WeatherForecast {
int? dt;
MainWeatherData? main;
List<Weather>? weather;
Clouds? clouds;
Wind? wind;
int? visibility;
dynamic pop;
Rain? rain;
Sys? sys;
String? dtTxt;
WeatherForecast({
this.dt,
this.main,
this.weather,
this.clouds,
this.wind,
this.visibility,
this.pop,
this.rain,
this.sys,
this.dtTxt,
});
factory WeatherForecast.fromJson(Map<String, dynamic> json) {
return WeatherForecast(
dt: json['dt'],
main: json['main'] != null ? MainWeatherData.fromJson(json['main']) : null,
weather: (json['weather'] as List?)?.map((item) => Weather.fromJson(item)).toList(),
clouds: json['clouds'] != null ? Clouds.fromJson(json['clouds']) : null,
wind: json['wind'] != null ? Wind.fromJson(json['wind']) : null,
visibility: json['visibility'],
pop: json['pop'],
rain: json['rain'] != null ? Rain.fromJson(json['rain']) : null,
sys: json['sys'] != null ? Sys.fromJson(json['sys']) : null,
dtTxt: json['dt_txt'],
);
}
}
class MainWeatherData {
double? temp;
double? feelsLike;
double? tempMin;
double? tempMax;
int? pressure;
int? seaLevel;
int? grndLevel;
int? humidity;
double? tempKf;
MainWeatherData({
this.temp,
this.feelsLike,
this.tempMin,
this.tempMax,
this.pressure,
this.seaLevel,
this.grndLevel,
this.humidity,
this.tempKf,
});
factory MainWeatherData.fromJson(Map<String, dynamic> json) {
return MainWeatherData(
temp: json['temp']?.toDouble(),
feelsLike: json['feels_like']?.toDouble(),
tempMin: json['temp_min']?.toDouble(),
tempMax: json['temp_max']?.toDouble(),
pressure: json['pressure'],
seaLevel: json['sea_level'],
grndLevel: json['grnd_level'],
humidity: json['humidity'],
tempKf: json['temp_kf']?.toDouble(),
);
}
}
class Weather {
int? id;
String? main;
String? description;
String? icon;
Weather({this.id, this.main, this.description, this.icon});
factory Weather.fromJson(Map<String, dynamic> json) {
return Weather(
id: json['id'],
main: json['main'],
description: json['description'],
icon: json['icon'],
);
}
}
class Clouds {
int? all;
Clouds({this.all});
factory Clouds.fromJson(Map<String, dynamic> json) {
return Clouds(
all: json['all'],
);
}
}
class Wind {
double? speed;
int? deg;
double? gust;
Wind({this.speed, this.deg, this.gust});
factory Wind.fromJson(Map<String, dynamic> json) {
return Wind(
speed: json['speed']?.toDouble(),
deg: json['deg'],
gust: json['gust']?.toDouble(),
);
}
}
class Rain {
double? h3;
Rain({this.h3});
factory Rain.fromJson(Map<String, dynamic> json) {
return Rain(
h3: json['3h']?.toDouble(),
);
}
}
class Sys {
String? pod;
Sys({this.pod});
factory Sys.fromJson(Map<String, dynamic> json) {
return Sys(
pod: json['pod'],
);
}
}
class City {
int? id;
String? name;
Coord? coord;
String? country;
int? population;
int? timezone;
int? sunrise;
int? sunset;
City({
this.id,
this.name,
this.coord,
this.country,
this.population,
this.timezone,
this.sunrise,
this.sunset,
});
factory City.fromJson(Map<String, dynamic> json) {
return City(
id: json['id'],
name: json['name'],
coord: json['coord'] != null ? Coord.fromJson(json['coord']) : null,
country: json['country'],
population: json['population'],
timezone: json['timezone'],
sunrise: json['sunrise'],
sunset: json['sunset'],
);
}
}
class Coord {
double? lat;
double? lon;
Coord({this.lat, this.lon});
factory Coord.fromJson(Map<String, dynamic> json) {
return Coord(
lat: json['lat']?.toDouble(),
lon: json['lon']?.toDouble(),
);
}
}

View File

@ -0,0 +1,33 @@
import 'dart:io';
import 'package:flutter/services.dart';
/// Channel Dart vers le plugin natif AudioRoutingPlugin.
/// Permet de forcer la sortie audio sur les lunettes (A2DP Bluetooth)
/// au lieu du haut-parleur téléphone.
class AudioRoutingChannel {
static const MethodChannel _channel =
MethodChannel('be.unov.mymuseum/audio_routing');
/// Force la sortie audio vers le device Bluetooth connecté (lunettes).
/// iOS : AVAudioSession .playAndRecord + .allowBluetoothA2DP + .allowBluetoothHFP
/// Android : AudioManager setCommunicationDevice(A2DP)
static Future<void> enableBluetoothOutput() async {
if (!Platform.isAndroid && !Platform.isIOS) return;
try {
await _channel.invokeMethod('enableBluetoothOutput');
} on PlatformException catch (e) {
// Dégradation gracieuse le son jouera sur le haut-parleur par défaut
print('[AudioRoutingChannel] enableBluetoothOutput failed: $e');
}
}
/// Restaure la sortie audio par défaut (haut-parleur téléphone).
static Future<void> restoreDefaultOutput() async {
if (!Platform.isAndroid && !Platform.isIOS) return;
try {
await _channel.invokeMethod('restoreDefaultOutput');
} on PlatformException catch (e) {
print('[AudioRoutingChannel] restoreDefaultOutput failed: $e');
}
}
}

View File

@ -3,7 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:manager_api/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/Loading.dart'; import 'package:mymuseum_visitapp/Components/Loading.dart';
import 'package:mymuseum_visitapp/Components/SearchBox.dart'; import 'package:mymuseum_visitapp/Components/SearchBox.dart';
import 'package:mymuseum_visitapp/Components/SearchNumberBox.dart'; import 'package:mymuseum_visitapp/Components/SearchNumberBox.dart';
@ -12,7 +12,7 @@ import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/beaconSection.dart'; import 'package:mymuseum_visitapp/Models/beaconSection.dart';
import 'package:mymuseum_visitapp/Models/resourceModel.dart'; import 'package:mymuseum_visitapp/Models/resourceModel.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Article/article_page.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Article/article_page.dart';
import 'package:mymuseum_visitapp/Services/apiService.dart'; import 'package:mymuseum_visitapp/Services/apiService.dart';
import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/client.dart'; import 'package:mymuseum_visitapp/client.dart';

Some files were not shown because too many files have changed in this diff Show More