Compare commits
22 Commits
master
...
Meta-Rayba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6526046c8 | ||
|
|
2701f4c963 | ||
|
|
bdabfad92e | ||
|
|
e8ef78d0e2 | ||
|
|
538e677993 | ||
|
|
c6599e13c9 | ||
|
|
959b494b12 | ||
|
|
f0e41adace | ||
|
|
303e50a255 | ||
|
|
c50083b19f | ||
|
|
f07570d8ee | ||
|
|
5e5d92a510 | ||
|
|
b20a112eb2 | ||
|
|
4e9dc59df9 | ||
|
|
7aca0638ce | ||
|
|
bddde86974 | ||
|
|
4946247812 | ||
|
|
9c5ae56549 | ||
|
|
b7ca69162c | ||
|
|
878a2e4cf0 | ||
|
|
53dff98388 | ||
|
|
42d3e257b2 |
53
.vscode/launch.json
vendored
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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..
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
117
AR-TEST/Tests/DebugOptionsWidget.dart
Normal 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
@ -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
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
170
AR-TEST/Tests/cloudtest.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
AR-TEST/Tests/localtest.dart
Normal 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);
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
AR-TEST/Tests/testother.dart
Normal 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
@ -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
@ -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.
|
||||||
|
|||||||
@ -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'
|
||||||
|
}
|
||||||
|
|||||||
105
android/app/src/dev/google-services.json
Normal 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"
|
||||||
|
}
|
||||||
105
android/app/src/fortsaintheribert/google-services.json
Normal 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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 62 KiB |
@ -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" />
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
6
android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
105
android/app/src/mdlf/google-services.json
Normal 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"
|
||||||
|
}
|
||||||
BIN
android/app/src/mdlf/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/mdlf/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/mdlf/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/mdlf/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/mdlf/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
BIN
assets/files/embedding_model.tflite
Normal file
BIN
assets/files/hey_visit.onnx
Normal file
BIN
assets/files/hey_visit.tflite
Normal file
BIN
assets/files/hey_viva.onnx
Normal file
BIN
assets/files/hey_viva.tflite
Normal file
BIN
assets/files/melspectrogram.onnx
Normal file
BIN
assets/files/melspectrogram.tflite
Normal file
202
assets/files/xr_environment.html
Normal 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
|
After Width: | Height: | Size: 4.7 KiB |
BIN
assets/loader/dev.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
assets/loader/fortsaintheribert.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
assets/loader/mdlf.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
assets/sounds/done.mp3
Normal file
BIN
assets/sounds/thinking.mp3
Normal file
BIN
assets/sounds/wake_detected.mp3
Normal file
BIN
assets/splash/dev.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
assets/splash/fortsaintheribert.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
assets/splash/mdlf.png
Normal file
|
After Width: | Height: | Size: 68 B |
3
devtools_options.yaml
Normal 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:
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
ios/Runner/AudioRoutingPlugin.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
33
ios/config/fortsaintheribert/GoogleService-Info.plist
Normal 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>
|
||||||
33
ios/config/mdlf/GoogleService-Info.plist
Normal 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>
|
||||||
33
ios/config/test/GoogleService-Info.plist
Normal 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>
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
) :
|
) :
|
||||||
|
|||||||
511
lib/Components/AssistantChatSheet.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 {}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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,13 +72,11 @@ 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
371
lib/Components/GlassesDebugPanel.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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){
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
@ -49,201 +44,128 @@ class _ScannerDialogState extends State<ScannerDialog> {
|
|||||||
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(
|
|
||||||
bottom: 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),
|
_buildControlButton(
|
||||||
child: InkWell(
|
icon: Icons.flash_on,
|
||||||
onTap: () async {
|
onTap: () => controller.toggleTorch(),
|
||||||
await controller?.flipCamera();
|
alignment: Alignment.topRight,
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
child: FutureBuilder(
|
|
||||||
future: controller?.getCameraInfo(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
return const Icon(Icons.flip_camera_android, color: Colors.white);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
),
|
),
|
||||||
|
_buildControlButton(
|
||||||
|
icon: Icons.flip_camera_android,
|
||||||
|
onTap: () => controller.switchCamera(),
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
|
if (barcode.format == BarcodeFormat.qrCode && code.isNotEmpty) {
|
||||||
|
isProcessing = true;
|
||||||
|
|
||||||
RegExp regExp = RegExp(r'^(?:https:\/\/web\.myinfomate\.be\/([^\/]+)\/([^\/]+)\/([^\/]+)|([^\/]+))$');
|
RegExp regExp = RegExp(r'^(?:https:\/\/web\.myinfomate\.be\/([^\/]+)\/([^\/]+)\/([^\/]+)|([^\/]+))$');
|
||||||
|
RegExp regExp2 = RegExp(r'^(?:https:\/\/web\.mymuseum\.be\/([^\/]+)\/([^\/]+)\/([^\/]+)|([^\/]+))$');
|
||||||
var match = regExp.firstMatch(code);
|
var match = regExp.firstMatch(code);
|
||||||
|
var match2 = regExp2.firstMatch(code);
|
||||||
String? instanceId;
|
String? instanceId;
|
||||||
String? configurationId;
|
String? configurationId;
|
||||||
String? sectionId;
|
String? sectionId;
|
||||||
|
|
||||||
if (match != null) {
|
if(match == null) {
|
||||||
|
instanceId = match2?.group(1);
|
||||||
|
configurationId = match2?.group(2);
|
||||||
|
sectionId = match2?.group(3) ?? match2?.group(4);
|
||||||
|
} else {
|
||||||
instanceId = match.group(1);
|
instanceId = match.group(1);
|
||||||
configurationId = match.group(2);
|
configurationId = match.group(2);
|
||||||
sectionId = match.group(3) ?? match.group(4);
|
sectionId = match.group(3) ?? match.group(4);
|
||||||
|
|
||||||
print('InstanceId: $instanceId');
|
|
||||||
print('ConfigurationId: $configurationId');
|
|
||||||
print('SectionId: $sectionId');
|
|
||||||
} else {
|
|
||||||
print('L\'URL ne correspond pas au format attendu.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((match == null && match2 == null) || sectionId == null) {
|
||||||
//print("QR CODE FOUND");
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
/*ScaffoldMessenger.of(context).showSnackBar(
|
SnackBar(content: Text("L'URL ne correspond pas au format attendu."), backgroundColor: kMainColor2),
|
||||||
SnackBar(content: Text('QR CODE FOUND - ${code.toString()}')),
|
);
|
||||||
);*/
|
Navigator.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
VisitAppContext visitAppContext = widget.appContext!.getContext();
|
VisitAppContext visitAppContext = widget.appContext!.getContext();
|
||||||
|
|
||||||
if(!visitAppContext.sectionIds!.contains(sectionId) || sectionId == null) {
|
if (visitAppContext.sectionIds == null || !visitAppContext.sectionIds!.contains(sectionId)) {
|
||||||
|
visitAppContext.statisticsService?.track(VisitEventType.qrScan, metadata: {'valid': false, 'sectionId': sectionId});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', widget.appContext!.getContext())), backgroundColor: kMainColor2),
|
SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', visitAppContext)), backgroundColor: kMainColor2),
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
SectionDTO section = visitAppContext.currentSections!.firstWhere((cs) => cs!.id == sectionId)!;
|
visitAppContext.statisticsService?.track(VisitEventType.qrScan, sectionId: sectionId, metadata: {'valid': true});
|
||||||
switch(section.type) {
|
dynamic rawSection = visitAppContext.currentSections!.firstWhere((cs) => cs!['id'] == sectionId)!;
|
||||||
case SectionType.Article:
|
Navigator.of(context).pop();
|
||||||
Navigator.pushReplacement(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
SlideFromRightRoute(page: SectionPage(
|
||||||
builder: (context) {
|
configuration: visitAppContext.configuration!,
|
||||||
return ArticlePage(visitAppContextIn: visitAppContext, articleId: section.id!);
|
rawSection: rawSection,
|
||||||
},
|
visitAppContextIn: visitAppContext,
|
||||||
),
|
sectionId: rawSection['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(
|
|
||||||
const SnackBar(content: Text('no Permission')),
|
|
||||||
);
|
|
||||||
|
|
||||||
// You can request multiple permissions at once.
|
|
||||||
Map<Permission, PermissionStatus> statuses = await [
|
|
||||||
Permission.camera,
|
|
||||||
].request();
|
|
||||||
print(statuses[Permission.camera]);
|
|
||||||
print(status);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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(
|
||||||
|
context: context,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
builder: (BuildContext context) => AlertDialog(
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(10.0))
|
borderRadius: BorderRadius.all(Radius.circular(10.0)),
|
||||||
),
|
),
|
||||||
content: ScannerDialog(appContext: appContext),
|
content: ScannerDialog(appContext: appContext),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
), context: context
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
24
lib/Components/SlideFromRouteRight.dart
Normal 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
|||||||
299
lib/Components/audio_player.dart
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
lib/Components/cached_custom_resource.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
92
lib/Components/show_element_for_resource.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
lib/Components/video_viewer.dart
Normal 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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
99
lib/Components/video_viewer_youtube.dart
Normal 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)));
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -175,8 +178,6 @@ class DatabaseHelper {
|
|||||||
$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"]),
|
||||||
);
|
);
|
||||||
|
|||||||
35
lib/Helpers/ImageCustomProvider.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
59
lib/Models/AssistantResponse.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
@ -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'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 = "";
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 = "";
|
||||||
|
|||||||
@ -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 = "";
|
||||||
|
|||||||
@ -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
@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
lib/PlatformChannels/audio_routing_channel.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
297
lib/Screens/ConfigurationPage/components/body.dart
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:diacritic/diacritic.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:manager_api_new/api.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Components/SlideFromRouteRight.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Components/SearchBox.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Components/SearchNumberBox.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Screens/section_page.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/apiService.dart';
|
||||||
|
import 'package:mymuseum_visitapp/app_context.dart';
|
||||||
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'section_card.dart';
|
||||||
|
|
||||||
|
class Body extends StatefulWidget {
|
||||||
|
const Body({Key? key, required this.configuration}) : super(key: key);
|
||||||
|
|
||||||
|
final ConfigurationDTO configuration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Body> createState() => _BodyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BodyState extends State<Body> {
|
||||||
|
late List<SectionDTO> sections;
|
||||||
|
late List<SectionDTO> _allSections;
|
||||||
|
late List<dynamic> rawSections;
|
||||||
|
String? searchValue;
|
||||||
|
int? searchNumberValue;
|
||||||
|
|
||||||
|
final ValueNotifier<List<SectionDTO>> filteredSections = ValueNotifier([]);
|
||||||
|
|
||||||
|
late Future<List<SectionDTO>> _futureSections;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final appContext = Provider.of<AppContext>(context, listen: false);
|
||||||
|
_futureSections = getSections(appContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final appContext = Provider.of<AppContext>(context);
|
||||||
|
VisitAppContext visitAppContext = appContext.getContext();
|
||||||
|
Size size = MediaQuery.of(context).size;
|
||||||
|
|
||||||
|
Color? primaryColor = widget.configuration.primaryColor != null ? Color(int.parse(widget.configuration.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)) : null;
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
top: false,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Hero(
|
||||||
|
tag: widget.configuration.id!,
|
||||||
|
child: Container(
|
||||||
|
height: size.height * 0.28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: kMainGrey,
|
||||||
|
spreadRadius: 0.5,
|
||||||
|
blurRadius: 5,
|
||||||
|
offset: Offset(0, 1), // changes position of shadow
|
||||||
|
),
|
||||||
|
],
|
||||||
|
gradient: widget.configuration.imageSource == null ? const LinearGradient(
|
||||||
|
begin: Alignment.centerRight,
|
||||||
|
end: Alignment.centerLeft,
|
||||||
|
colors: [
|
||||||
|
/*Color(0xFFDD79C2),
|
||||||
|
Color(0xFFB65FBE),
|
||||||
|
Color(0xFF9146BA),
|
||||||
|
Color(0xFF7633B8),
|
||||||
|
Color(0xFF6528B6),
|
||||||
|
Color(0xFF6025B6)*/
|
||||||
|
kMainColor0, //Color(0xFFf6b3c4)
|
||||||
|
kMainColor1,
|
||||||
|
kMainColor2,
|
||||||
|
|
||||||
|
],
|
||||||
|
) : null,
|
||||||
|
image: widget.configuration.imageSource != null ? DecorationImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
opacity: 0.65,
|
||||||
|
image: NetworkImage(
|
||||||
|
widget.configuration.imageSource!,
|
||||||
|
),
|
||||||
|
): null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
children: <Widget>[
|
||||||
|
SizedBox(
|
||||||
|
height: size.height * 0.11,
|
||||||
|
width: size.width,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 35,
|
||||||
|
left: 10,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
//setState(() {
|
||||||
|
/**/
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
visitAppContext.configuration = null;
|
||||||
|
visitAppContext.isScanningBeacons = false;
|
||||||
|
/*Navigator.of(context).pushAndRemoveUntil(MaterialPageRoute(
|
||||||
|
builder: (context) => const HomePage3(),
|
||||||
|
),(route) => false);*/
|
||||||
|
//});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.arrow_back, size: 23, color: Colors.white)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
SearchBox(onChanged: (value) {
|
||||||
|
searchValue = value?.trim();
|
||||||
|
applyFilters(visitAppContext);
|
||||||
|
}),
|
||||||
|
Expanded(
|
||||||
|
child: SearchNumberBox(onChanged: (value) {
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
searchNumberValue = int.tryParse(value);
|
||||||
|
} else {
|
||||||
|
searchNumberValue = null;
|
||||||
|
}
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
applyFilters(visitAppContext);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
//const SizedBox(height: kDefaultPadding / 2),
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: <Widget>[
|
||||||
|
// Our background
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 0),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: kMainGrey,
|
||||||
|
spreadRadius: 0.5,
|
||||||
|
blurRadius: 2,
|
||||||
|
offset: Offset(0, 1), // changes position of shadow
|
||||||
|
),
|
||||||
|
],
|
||||||
|
color: kBackgroundColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(30),
|
||||||
|
topRight: Radius.circular(30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FutureBuilder(
|
||||||
|
future: _futureSections,
|
||||||
|
builder: (context, AsyncSnapshot<dynamic> snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.done) {
|
||||||
|
/*print("SECTIONTODISPA");
|
||||||
|
print(sectionsToDisplay);*/
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 0),
|
||||||
|
child: RefreshIndicator(
|
||||||
|
color: kMainColor,
|
||||||
|
onRefresh: () async {
|
||||||
|
if(!widget.configuration.isOffline!) {
|
||||||
|
setState(() {
|
||||||
|
_futureSections = getSections(appContext);
|
||||||
|
});
|
||||||
|
} },
|
||||||
|
child: ValueListenableBuilder<List<SectionDTO>>(
|
||||||
|
valueListenable: filteredSections,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: value.length,
|
||||||
|
itemBuilder: (context, index) => SectionCard(
|
||||||
|
configuration: widget.configuration,
|
||||||
|
itemCount: value.length,
|
||||||
|
itemIndex: index,
|
||||||
|
sectionDTO: value[index],
|
||||||
|
press: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
SlideFromRightRoute(page: SectionPage(
|
||||||
|
configuration: widget.configuration,
|
||||||
|
rawSection: rawSections[index],
|
||||||
|
visitAppContextIn: appContext.getContext(),
|
||||||
|
sectionId: value[index].id!,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.none) {
|
||||||
|
return Text(TranslationHelper.getFromLocale("noData", appContext.getContext()));
|
||||||
|
} else {
|
||||||
|
return Center(
|
||||||
|
child: SizedBox(
|
||||||
|
height: size.height * 0.15,
|
||||||
|
child: const LoadingCommon()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SectionDTO>> getSections(AppContext appContext) async {
|
||||||
|
VisitAppContext visitAppContext = appContext.getContext();
|
||||||
|
sections = [];
|
||||||
|
if(widget.configuration.isOffline == true)
|
||||||
|
{
|
||||||
|
// OFFLINE
|
||||||
|
sections = List<SectionDTO>.from(await DatabaseHelper.instance.getData(DatabaseTableType.sections));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// ONLINE
|
||||||
|
List<dynamic>? sectionsDownloaded = await ApiService.getAllSections(visitAppContext.clientAPI, widget.configuration.id!);
|
||||||
|
rawSections = jsonDecode(jsonEncode(sectionsDownloaded));
|
||||||
|
var rawToSection = jsonDecode(jsonEncode(rawSections)).map((json) => SectionDTO.fromJson(json)).toList();
|
||||||
|
List<SectionDTO> sectionList = rawToSection.whereType<SectionDTO>().toList();
|
||||||
|
visitAppContext.currentSections = rawSections;
|
||||||
|
|
||||||
|
if(sectionList.isNotEmpty) {
|
||||||
|
sections = sectionList.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only filter by configurationId if sections have it set
|
||||||
|
final id = widget.configuration.id;
|
||||||
|
if (id != null && sections.any((s) => s.configurationId != null)) {
|
||||||
|
sections = sections.where((s) => s.configurationId == id).toList();
|
||||||
|
}
|
||||||
|
sections.sort((a,b) => a.order!.compareTo(b.order!));
|
||||||
|
|
||||||
|
_allSections = sections;
|
||||||
|
applyFilters(visitAppContext);
|
||||||
|
|
||||||
|
return _allSections;
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyFilters(VisitAppContext visitAppContext) {
|
||||||
|
List<SectionDTO> result = _allSections;
|
||||||
|
|
||||||
|
if (searchValue != null && searchValue!.isNotEmpty) {
|
||||||
|
result = result.where((s) =>
|
||||||
|
removeDiacritics(TranslationHelper.get(s.title, visitAppContext).toLowerCase())
|
||||||
|
.contains(removeDiacritics(searchValue!.toLowerCase()))
|
||||||
|
).toList();
|
||||||
|
} else if (searchNumberValue != null) {
|
||||||
|
result = result.where((s) => s.order! + 1 == searchNumberValue).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredSections.value = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||