marathon tutorial added.

development_sikander
Sikander Saleem 2 years ago
parent a2ed4e52f3
commit d44bc37681

@ -542,5 +542,6 @@
"uploadedDocuments": "المستندات التي تم تحميلها",
"addAtLeastOneAttachment": "الرجاء إضافة مرفق واحد على الأقل.",
"pleaseClickButtonToJoinMarathon": "الرجاء الضغط على الزر أدناه للانضمام إلى الماراثون",
"youCannotJoinTheMarathon": "لا يمكنك الانضمام إلى الماراثون لأنك تجاوزت الحد الزمني"
"youCannotJoinTheMarathon": "لا يمكنك الانضمام إلى الماراثون لأنك تجاوزت الحد الزمني",
"open": "يفتح"
}

@ -559,6 +559,7 @@
"userSearch": "User Search",
"userName": "User Name",
"userId": "UserID",
"open": "open",
"addAtLeastOneAttachment": "Please add at least one attachment.",
"pleaseClickButtonToJoinMarathon": "Press the button below to join the Marathon.",
"youCannotJoinTheMarathon": "You cannot join the Marathon because you have exceeded the time limit."

@ -11,6 +11,7 @@ import 'package:mohem_flutter_app/config/routes.dart';
import 'package:mohem_flutter_app/models/marathon/marathon_generic_model.dart';
import 'package:mohem_flutter_app/models/marathon/marathon_model.dart';
import 'package:mohem_flutter_app/models/marathon/question_model.dart';
import 'package:mohem_flutter_app/models/marathon/tutorial_notification_model.dart';
import 'package:mohem_flutter_app/models/marathon/winner_model.dart';
import 'package:mohem_flutter_app/ui/marathon/marathon_provider.dart';
import 'package:provider/provider.dart';
@ -195,6 +196,24 @@ class MarathonApiClient {
return null;
}
Future<TutorialNotificationModel?> getMarathonTutorial() async {
Response response = await ApiClient().getJsonForResponse(
// ApiConsts.marathonGetTutorial,
"https://marathoon.com/uatservice/api/tutorial/GetTutorialNotification",
token: AppState().getMarathonToken == null || AppState().getMarathonToken == "" ? await getMarathonToken() : AppState().getMarathonToken,
);
try {
MarathonGenericModel marathonModel = MarathonGenericModel.fromJson(jsonDecode(response.body));
if (marathonModel.data is List) {
return TutorialNotificationModel.fromJson(marathonModel.data[0]);
}
} catch (ex) {
print(ex);
}
return null;
}
// late HubConnection hubConnection;
// Future<void> buildHubConnection(BuildContext context, String prizeId) async {
// HttpConnectionOptions httpOptions = HttpConnectionOptions(skipNegotiation: false, logMessageContent: true);

@ -3,8 +3,8 @@ import 'package:mohem_flutter_app/ui/marathon/widgets/question_card.dart';
class ApiConsts {
//static String baseUrl = "http://10.200.204.20:2801/"; // Local server
// static String baseUrl = "https://erptstapp.srca.org.sa"; // SRCA server
// static String baseUrl = "https://uat.hmgwebservices.com"; // UAT ser343622ver
static String baseUrl = "https://hmgwebservices.com"; // Live server
static String baseUrl = "https://uat.hmgwebservices.com"; // UAT ser343622ver
// static String baseUrl = "https://hmgwebservices.com"; // Live server
static String baseUrlServices = baseUrl + "/Services/"; // server
// static String baseUrlServices = "https://api.cssynapses.com/tangheem/"; // Live server
static String utilitiesRest = baseUrlServices + "Utilities.svc/REST/";
@ -51,6 +51,7 @@ class ApiConsts {
static String marathonQualifiersUrl = marathonBaseUrl + "winner/getWinner/";
static String marathonSelectedWinner = marathonBaseUrl + "winner/getSelectedWinner/";
static String marathonGetMarathonersCount = marathonBaseUrl + "Participant/GetRemainingParticipants";
static String marathonGetTutorial = marathonBaseUrl + "tutorial/GetTutorialNotification";
//DummyCards for the UI
static CardContent dummyQuestion = const CardContent();

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:open_file/open_file.dart';
import 'package:path_provider/path_provider.dart';
import 'package:http/http.dart' as http;
class FileProcess {
static bool isFolderCreated = false;
@ -23,9 +24,9 @@ class FileProcess {
}
}
static void openFile(String fileName) {
static void openFile(String fileName, {bool isFullPath = false}) {
String dir = directory!.path + "/$fileName.pdf";
OpenFile.open(dir);
OpenFile.open(isFullPath ? fileName : dir);
}
static Future<File> downloadFile(String base64Content, String fileName) async {
@ -37,4 +38,24 @@ class FileProcess {
await file.writeAsBytes(bytes);
return file;
}
static Future<String> downloadFileFromUrl(String url,String fileName) async {
await checkDocumentFolder();
String filePath = '${directory!.path}/$fileName';
if (await File(filePath).exists()) {
await Future.delayed(const Duration(seconds: 1));
return filePath;
} else {
var response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
var bytes = response.bodyBytes;
File file = File(filePath);
await file.writeAsBytes(bytes);
return filePath;
} else {
throw Exception('Failed to download file');
}
}
}
}

@ -27,6 +27,7 @@ import 'package:mohem_flutter_app/ui/login/verify_login_screen.dart';
import 'package:mohem_flutter_app/ui/marathon/marathon_intro_screen.dart';
import 'package:mohem_flutter_app/ui/marathon/marathon_screen.dart';
import 'package:mohem_flutter_app/ui/marathon/marathon_sponsor_video_screen.dart';
import 'package:mohem_flutter_app/ui/marathon/marathon_tutorial_viewer_screen.dart';
import 'package:mohem_flutter_app/ui/marathon/marathon_waiting_screen.dart';
import 'package:mohem_flutter_app/ui/misc/request_submit_screen.dart';
import 'package:mohem_flutter_app/ui/my_attendance/dynamic_screens/dynamic_input_screen.dart';
@ -203,6 +204,7 @@ class AppRoutes {
static const String marathonWinnerScreen = "/marathonWinnerScreen";
static const String marathonSponsorVideoScreen = "/marathonSponsorVideoScreen";
static const String marathonWaitingScreen = "/marathonWaitingScreen";
static const String marathonTutorialScreen = "/marathonTutorialScreen";
static const String unsafeDeviceScreen = "/unsafeDeviceScreen";
static const String appUpdateScreen = "/appUpdateScreen";
@ -319,6 +321,7 @@ class AppRoutes {
marathonScreen: (BuildContext context) => MarathonScreen(),
marathonSponsorVideoScreen: (BuildContext context) => const SponsorVideoScreen(),
marathonWaitingScreen: (BuildContext context) => const MarathonWaitingScreen(),
marathonTutorialScreen: (BuildContext context) => const MarathonTutorialViewerScreen(),
unsafeDeviceScreen: (BuildContext context) => const UnsafeDeviceScreen(),
appUpdateScreen: (BuildContext context) => const AppUpdateScreen(),

@ -549,4 +549,5 @@ abstract class LocaleKeys {
static const addAtLeastOneAttachment ='addAtLeastOneAttachment';
static const pleaseClickButtonToJoinMarathon ='pleaseClickButtonToJoinMarathon';
static const youCannotJoinTheMarathon ='youCannotJoinTheMarathon';
static const open ='open';
}

@ -94,8 +94,9 @@ class ViewAttachFileColl {
dynamic referenceItemId;
dynamic content;
dynamic filePath;
dynamic languageId;
ViewAttachFileColl({this.attachmentId, this.fileName, this.contentType, this.attachFileStream, this.base64String, this.isActive, this.referenceItemId, this.content, this.filePath});
ViewAttachFileColl({this.attachmentId, this.fileName, this.contentType, this.attachFileStream, this.base64String, this.isActive, this.referenceItemId, this.content, this.filePath, this.languageId});
ViewAttachFileColl.fromJson(Map<String, dynamic> json) {
attachmentId = json['attachmentId'];
@ -107,6 +108,7 @@ class ViewAttachFileColl {
referenceItemId = json['referenceItemId'];
content = json['content'];
filePath = json['filePath'];
languageId = json['languageId'];
}
Map<String, dynamic> toJson() {
@ -120,6 +122,7 @@ class ViewAttachFileColl {
data['referenceItemId'] = this.referenceItemId;
data['content'] = this.content;
data['filePath'] = this.filePath;
data['languageId'] = this.languageId;
return data;
}
}

@ -46,19 +46,31 @@ class ItgResponseResult {
this.errormsg,
});
final dynamic totalItemsCount;
final ItgResponseData? data;
final dynamic errormsg;
factory ItgResponseResult.fromJson(Map<String, dynamic> json) => ItgResponseResult(
totalItemsCount: json["totalItemsCount"],
data: json["data"] == null ? null : ItgResponseData.fromJson(json["data"]),
errormsg: json["errormsg"],
);
dynamic totalItemsCount;
List<ItgResponseData>? data;
dynamic errormsg;
ItgResponseResult.fromJson(Map<String, dynamic> json) {
totalItemsCount = json['totalItemsCount'];
if (json['data'] != null) {
data = [];
json['data'].forEach((v) {
data!.add(ItgResponseData.fromJson(v));
});
}
errormsg = json['errormsg'];
}
//
// factory ItgResponseResult.fromJson(Map<String, dynamic> json) => ItgResponseResult(
// totalItemsCount: json["totalItemsCount"],
// data: json["data"] == null ? null : ItgResponseData.fromJson(json["data"]),
// errormsg: json["errormsg"],
// );
Map<String, dynamic> toJson() => {
"totalItemsCount": totalItemsCount,
"data": data == null ? null : data!.toJson(),
"data": data == null ? null : data!.map((v) => v.toJson()).toList(),
"errormsg": errormsg,
};
}

@ -0,0 +1,104 @@
// class TutorialNotificationModel {
// List<Data>? data;
// bool? isSuccessful;
// String? message;
// int? statusCode;
//
// TutorialNotificationModel({this.data, this.isSuccessful, this.message, this.statusCode});
//
// TutorialNotificationModel.fromJson(Map<String, dynamic> json) {
// if (json['data'] != null) {
// data = <Data>[];
// json['data'].forEach((v) {
// data!.add(new Data.fromJson(v));
// });
// }
// isSuccessful = json['isSuccessful'];
// message = json['message'];
// statusCode = json['statusCode'];
// }
//
// Map<String, dynamic> toJson() {
// final Map<String, dynamic> data = new Map<String, dynamic>();
// if (this.data != null) {
// data['data'] = this.data!.map((v) => v.toJson()).toList();
// }
// data['isSuccessful'] = this.isSuccessful;
// data['message'] = this.message;
// data['statusCode'] = this.statusCode;
//
// return data;
// }
// }
class TutorialNotificationModel {
String? tutorialNotificationId;
String? tutorialName;
String? tutorialDescription;
String? startDate;
String? endDate;
String? fileName;
String? contentType;
String? filePath;
int? orderNo;
bool? isActive;
int? isStatus;
String? created;
String? createdBy;
String? modified;
String? modifiedBy;
TutorialNotificationModel({this.tutorialNotificationId,
this.tutorialName,
this.tutorialDescription,
this.startDate,
this.endDate,
this.fileName,
this.contentType,
this.filePath,
this.orderNo,
this.isActive,
this.isStatus,
this.created,
this.createdBy,
this.modified,
this.modifiedBy});
TutorialNotificationModel.fromJson(Map<String, dynamic> json) {
tutorialNotificationId = json['tutorialNotificationId'];
tutorialName = json['tutorialName'];
tutorialDescription = json['tutorialDescription'];
startDate = json['startDate'];
endDate = json['endDate'];
fileName = json['fileName'];
contentType = json['contentType'];
filePath = json['filePath'];
orderNo = json['orderNo'];
isActive = json['isActive'];
isStatus = json['isStatus'];
created = json['created'];
createdBy = json['createdBy'];
modified = json['modified'];
modifiedBy = json['modifiedBy'];
}
Map<String, dynamic> toJson() {
Map<String, dynamic> data = new Map<String, dynamic>();
data['tutorialNotificationId'] = this.tutorialNotificationId;
data['tutorialName'] = this.tutorialName;
data['tutorialDescription'] = this.tutorialDescription;
data['startDate'] = this.startDate;
data['endDate'] = this.endDate;
data['fileName'] = this.fileName;
data['contentType'] = this.contentType;
data['filePath'] = this.filePath;
data['orderNo'] = this.orderNo;
data['isActive'] = this.isActive;
data['isStatus'] = this.isStatus;
data['created'] = this.created;
data['createdBy'] = this.createdBy;
data['modified'] = this.modified;
data['modifiedBy'] = this.modifiedBy;
return data;
}
}

@ -150,6 +150,7 @@ class _DashboardScreenState extends State<DashboardScreen> with WidgetsBindingOb
data.fetchMenuEntries();
data.getCategoryOffersListAPI(context);
marathonProvider.getMarathonDetailsFromApi();
marathonProvider.getMarathonTutorial();
if (isFromInit) {
checkERMChannel();
}
@ -161,8 +162,8 @@ class _DashboardScreenState extends State<DashboardScreen> with WidgetsBindingOb
data.getITGNotification().then((val) {
if (val!.result!.data != null) {
print("-------------------- Survey ----------------------------");
if (val.result!.data!.notificationType == "Survey") {
DashboardApiClient().getAdvertisementDetail(val.result!.data!.notificationMasterId ?? "").then(
if (val.result!.data!.first.notificationType == "Survey") {
DashboardApiClient().getAdvertisementDetail(val.result!.data!.first.notificationMasterId ?? "").then(
(value) {
if (value!.mohemmItgResponseItem!.statusCode == 200) {
if (value.mohemmItgResponseItem!.result!.data != null) {
@ -178,13 +179,13 @@ class _DashboardScreenState extends State<DashboardScreen> with WidgetsBindingOb
);
} else {
print("------------------------------------------- Ads --------------------");
DashboardApiClient().getAdvertisementDetail(val.result!.data!.notificationMasterId ?? "").then(
DashboardApiClient().getAdvertisementDetail(val.result!.data!.first.notificationMasterId ?? "").then(
(value) {
if (value!.mohemmItgResponseItem!.statusCode == 200) {
if (value.mohemmItgResponseItem!.result!.data != null) {
Navigator.pushNamed(context, AppRoutes.advertisement, arguments: {
"masterId": val.result!.data!.notificationMasterId,
"advertisement": value.mohemmItgResponseItem!.result!.data!.advertisement,
"masterId": val.result!.data!.first.notificationMasterId,
"advertisement": value.mohemmItgResponseItem!.result!.data!.first.advertisement,
});
}
}
@ -493,7 +494,49 @@ class _DashboardScreenState extends State<DashboardScreen> with WidgetsBindingOb
mainAxisSize: MainAxisSize.min,
children: [
ServicesWidget(),
context.watch<MarathonProvider>().isLoading ? const MarathonBannerShimmer().paddingAll(20) : const MarathonBanner().paddingOnly(left: 21, right: 21, bottom: 21, top: 8),
context.watch<MarathonProvider>().isLoading ? const MarathonBannerShimmer().paddingAll(20) : const MarathonBanner().paddingOnly(left: 21, right: 21, bottom: 8, top: 8),
context.watch<MarathonProvider>().isTutorialLoading
? const MarathonBannerShimmer().paddingAll(20)
: Container(
padding: EdgeInsets.only(bottom: 12, top: 12),
margin: EdgeInsets.only(left: 21, right: 21, bottom: 21, top: 8),
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
color: MyColors.backgroundBlackColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MyColors.lightGreyEDColor, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Tutorial:",
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w600,
color: MyColors.white.withOpacity(0.83),
letterSpacing: -0.4,
),
),
Text(
context.read<MarathonProvider>().tutorial?.tutorialName ?? "",
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontStyle: FontStyle.italic,
fontSize: 19,
fontWeight: FontWeight.bold,
color: MyColors.white,
height: 32 / 22,
),
),
],
),
).onPress(() {
Navigator.pushNamed(context, AppRoutes.marathonTutorialScreen);
}),
],
),
),

@ -13,6 +13,7 @@ import 'package:mohem_flutter_app/config/routes.dart';
import 'package:mohem_flutter_app/models/marathon/marathon_generic_model.dart';
import 'package:mohem_flutter_app/models/marathon/marathon_model.dart';
import 'package:mohem_flutter_app/models/marathon/question_model.dart';
import 'package:mohem_flutter_app/models/marathon/tutorial_notification_model.dart';
import 'package:mohem_flutter_app/models/marathon/winner_model.dart';
import 'package:mohem_flutter_app/models/privilege_list_model.dart';
import 'package:mohem_flutter_app/ui/marathon/widgets/question_card.dart';
@ -594,4 +595,15 @@ class MarathonProvider extends ChangeNotifier {
AppState().setIsDemoMarathon = true;
await callNextQuestionApi();
}
bool isTutorialLoading = false;
TutorialNotificationModel? tutorial;
Future<void> getMarathonTutorial() async {
isTutorialLoading = true;
notifyListeners();
tutorial = await MarathonApiClient().getMarathonTutorial();
isTutorialLoading = false;
notifyListeners();
}
}

@ -0,0 +1,239 @@
import 'dart:convert';
import 'dart:io' as Io;
import 'dart:io';
import 'dart:typed_data';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_countdown_timer/index.dart';
import 'package:lottie/lottie.dart';
import 'package:mohem_flutter_app/api/dashboard_api_client.dart';
import 'package:mohem_flutter_app/app_state/app_state.dart';
import 'package:mohem_flutter_app/classes/colors.dart';
import 'package:mohem_flutter_app/classes/file_process.dart';
import 'package:mohem_flutter_app/classes/lottie_consts.dart';
import 'package:mohem_flutter_app/classes/utils.dart';
import 'package:mohem_flutter_app/extensions/int_extensions.dart';
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
import 'package:mohem_flutter_app/extensions/widget_extensions.dart';
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
import 'package:mohem_flutter_app/main.dart';
import 'package:mohem_flutter_app/models/itg/advertisement.dart' as ads;
import 'package:mohem_flutter_app/models/marathon/tutorial_notification_model.dart';
import 'package:mohem_flutter_app/ui/chat/common.dart';
import 'package:mohem_flutter_app/ui/marathon/marathon_provider.dart';
import 'package:mohem_flutter_app/widgets/app_bar_widget.dart';
import 'package:mohem_flutter_app/widgets/button/default_button.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
class MarathonTutorialViewerScreen extends StatefulWidget {
const MarathonTutorialViewerScreen({Key? key}) : super(key: key);
@override
_MarathonTutorialViewerScreenState createState() => _MarathonTutorialViewerScreenState();
}
class _MarathonTutorialViewerScreenState extends State<MarathonTutorialViewerScreen> {
late Future<VideoPlayerController> _futureController;
VideoPlayerController? _controller;
bool skip = false;
bool isVideo = false;
bool isImage = false;
bool isAudio = false;
bool isPdf = false;
String link = "";
String ext = '';
late File imageFile;
ads.Advertisement? advertisementData;
dynamic data;
String? masterID;
int videoDuration = 0;
ValueNotifier<int> videoLength = ValueNotifier(0);
void checkFileTypes(String link) {
ext = "." + link.split(".").last.toLowerCase();
if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif") {
isImage = true;
} else if (ext == ".pdf") {
isPdf = true;
} else {
if (ext == ".aac") {
isAudio = true;
}
isVideo = true;
_futureController = createVideoPlayer(link);
}
setState(() {});
}
Future processImage(String encodedBytes) async {
try {
Uint8List decodedBytes = base64Decode(encodedBytes.split("base64,").last);
Directory appDocumentsDirectory = await getApplicationDocumentsDirectory(); // 1
imageFile = Io.File("${appDocumentsDirectory.path}/addImage$ext");
imageFile.writeAsBytesSync(decodedBytes);
} catch (e) {
logger.d(e);
}
}
Future<VideoPlayerController> createVideoPlayer(String link) async {
try {
VideoPlayerController controller = VideoPlayerController.networkUrl(Uri.parse(link));
await controller.initialize();
await controller.play();
await controller.setVolume(1.0);
await controller.setLooping(false);
controller.addListener(() {
controller.position.then((value) {
videoLength.value = value!.inMilliseconds;
// if(controller.value.isCompleted) {
// videoLength.value = 0;
// }
});
});
return controller;
} catch (e) {
print(e);
return VideoPlayerController.network("https://apimohemmweb.cloudsolutions.com.sa/ErmAttachment/compressedvideo.mp4");
}
}
bool showControls = false;
void showVideoControls() {
showControls = !showControls;
setState(() {});
}
@override
void initState() {
super.initState();
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
TutorialNotificationModel? tutorial;
@override
Widget build(BuildContext context) {
if (tutorial == null) {
tutorial ??= context.read<MarathonProvider>().tutorial;
link = tutorial!.filePath!;
checkFileTypes(link);
}
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBarWidget(context, title: tutorial!.fileName!, showHomeButton: false),
body: Stack(
children: [
if (isVideo)
FutureBuilder(
future: _futureController,
builder: (BuildContext context, AsyncSnapshot<Object?> snapshot) {
if (snapshot.connectionState == ConnectionState.done && snapshot.data != null) {
_controller = snapshot.data as VideoPlayerController;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child: isAudio
? Lottie.asset(MyLottieConsts.audioPlaybackLottie)
: AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
VideoPlayer(_controller!),
AnimatedContainer(
duration: const Duration(milliseconds: 250),
color: Colors.black.withOpacity(showControls ? .4 : .0),
child: AnimatedOpacity(
opacity: showControls ? 1 : 0,
duration: const Duration(milliseconds: 250),
child: Column(
children: [
Icon(_controller!.value.isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white)
.onPress(() {
if (!showControls) {
showVideoControls();
return;
}
if (_controller!.value.isPlaying) {
_controller!.pause();
} else if (_controller!.value.isCompleted) {
videoLength.value = 0;
_controller!.play();
} else {
_controller!.play();
}
setState(() {});
})
.center
.expanded,
ValueListenableBuilder<int>(
valueListenable: videoLength,
builder: (context, val, child) {
return SeekBar(
duration: _controller!.value.duration,
position: Duration(milliseconds: val),
bufferedPosition: Duration(),
onChanged: (duration) {
_controller!.seekTo(duration);
},
);
}),
],
),
),
).onPress(() {
showVideoControls();
}),
],
),
),
),
30.height,
],
);
} else {
return const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
);
}
},
),
if (isImage) Image.network(link).center,
if (isPdf)
DefaultButton(LocaleKeys.open.tr(), () async {
try {
Utils.showLoading(context);
await FileProcess.downloadFileFromUrl(link, tutorial!.fileName!).then((path) {
Utils.hideLoading(context);
FileProcess.openFile(path, isFullPath: true);
});
} catch (ex) {
Utils.hideLoading(context);
Utils.handleException(ex, context, null);
}
}).paddingAll(21).center
],
),
);
}
}

@ -76,7 +76,7 @@ class _OffersAndDiscountsDetailsState extends State<OffersAndDiscountsDetails> {
: getOffersList[0].titleEn!.toText22(isBold: true, color: const Color(0xff2B353E)).center,
Html(
data: AppState().isArabic(context) ? getOffersList[0].descriptionAr! : getOffersList[0].descriptionEn ?? "",
onLinkTap: (String? url, RenderContext context, Map<String, String> attributes, _) {
onLinkTap: (String? url, Map<String, String> attributes, _) {
launchUrl(Uri.parse(url!));
},
),

Loading…
Cancel
Save