diff --git a/assets/images/screenshot_1.png b/assets/images/screenshot_1.png new file mode 100644 index 0000000..9f16e95 Binary files /dev/null and b/assets/images/screenshot_1.png differ diff --git a/assets/images/screenshot_2.png b/assets/images/screenshot_2.png new file mode 100644 index 0000000..0ecb7bf Binary files /dev/null and b/assets/images/screenshot_2.png differ diff --git a/assets/images/screenshot_3.png b/assets/images/screenshot_3.png new file mode 100644 index 0000000..450f0ac Binary files /dev/null and b/assets/images/screenshot_3.png differ diff --git a/assets/images/screenshot_4.png b/assets/images/screenshot_4.png new file mode 100644 index 0000000..8b90452 Binary files /dev/null and b/assets/images/screenshot_4.png differ diff --git a/lib/Screens/forex/forexDetails.dart b/lib/Screens/forex/forexDetails.dart index ddb6e09..4c6ec99 100644 --- a/lib/Screens/forex/forexDetails.dart +++ b/lib/Screens/forex/forexDetails.dart @@ -1,33 +1,266 @@ +import 'dart:convert'; + import 'package:dropdown_search/dropdown_search.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; +import '../../config/apiUrl.dart'; +import '../../services/apiService.dart'; +import '../../utils/auth_utils.dart'; import '../../widgets/custom_text_forex.dart'; +import 'forex_list.dart'; class ForexData extends StatefulWidget { + final Future> Function() fetchGetForex; final bool isDesktop; final Color? layoutColor; - const ForexData({super.key, required this.isDesktop, this.layoutColor}); + + final int? forexId; // <-- Add this + final Map? forexData; + + const ForexData( + {super.key, + required this.isDesktop, + this.layoutColor, + required this.fetchGetForex, + this.forexId, + this.forexData}); @override ForexDataState createState() => ForexDataState(); } class ForexDataState extends State { + final ApiService apiService = ApiService(); + Map countryMap = {}; + late List? apiCountryData; + late List? apiAirlineCountryData; + Map? apiData; + final Map controllers = {}; + Map errorMessages = {}; + List countryList = []; String? selectedCountry; + String? selectedCountryName; String? selectedCurrency; String? selectedDuration; String? selectedPerdiemAmount; + String? userId; + int? forexDataId; + late String isActive = "1"; + + List dataHeader = [ + "country_code", + "country", + "currency", + "perdiemAmount" + ]; + + Map forex_Detials() { + final data = { + // "forex_perdiem_id": int.parse(forexId), + "country_code": selectedCountry, + "country_name": selectedCountryName, + "currency": controllers["currency"]?.text, + "perdiem_amount": controllers["perdiemAmount"]?.text, + "is_active": 1, + "created_by": userId, + "is_active": isActive, + }; + return data; + } + + @override + void initState() { + super.initState(); + + apiCountryData = null; + apiData = null; + for (var field in dataHeader) { + controllers[field] = TextEditingController(); + } + fetchCountries(); + if (widget.forexId != null) { + print('Editing Forex ID: ${widget.forexId}'); + updateForexDetails(); + } + _clearError(); + } + + void _clearError() { + setState(() { + errorMessages.clear(); + }); + } + + @override + void dispose() { + for (var controller in controllers.values) { + controller.dispose(); + } + super.dispose(); + } + + void updateForexDetails() { + print("Updateeee - ${widget.forexData}"); + + final data = widget.forexData; + + if (data == null) return; + setState(() { + selectedCountry = data['country_code']; // For dropdown + selectedCountryName = + data['country_name']; // For dropdown label or display + selectedCurrency = data['currency']; // Optional if used elsewhere + + controllers['currency']?.text = data['currency'] ?? ''; + controllers['perdiemAmount']?.text = data['perdiem_amount'].toString(); + isActive = data["is_active"]; + final forexId = int.tryParse(data['forex_perdiem_id'].toString()); + forexDataId = forexId; + }); + } + + Future fetchCountries() async { + try { + List countries = await apiService.fetchCountryList(); + setState(() { + apiCountryData = countries; + }); + } catch (e) { + print('Error fetching country list: $e'); + } + } + + void toggleStatus() { + setState(() { + isActive = isActive == "1" ? "0" : "1"; + }); + } + + bool validateData() { + errorMessages.clear(); + + final data = { + "country_code": selectedCountry, + "country": selectedCountryName, + "currency": controllers["currency"]?.text, + "perdiemAmount": controllers["perdiemAmount"]?.text, + }; + + final requiredFields = [ + "country_code", + "country", + "currency", + "perdiemAmount" + ]; + + // Check validation for each field + for (String field in requiredFields) { + if (data[field] == null || data[field].toString().trim().isEmpty) { + errorMessages[field] = "Required"; + } + } + + return errorMessages.isEmpty; + } + + Future handleSubmit() async { + userId = await getUserId(); + + setState(() { + // This triggers UI rebuild with error messages + if (validateData()) { + postForexData(); + } + }); + + final forexData1 = forex_Detials(); + print("ForexDAta - $forexData1"); + } + + Future postForexData({int isActive = 1}) async { + // final remarksData = getData(); + + final forexData = forex_Detials(); + + print("forexDataPOSDf - $forexData"); + + final String apiUrldata; + if (forexDataId != null) { + print("feforexDataId - $forexDataId"); + + apiUrldata = '$apiUrl/api/updateForexPerdiem/$forexDataId'; + forexData["id"] = forexDataId; + forexData["updated_by"] = userId; + } else { + apiUrldata = '$apiUrl/api/createForexPerdiem'; + forexData["created_by"] = userId; + } + + print("Remarks Data - remarksData"); + + // final String apiUrldata = '$apiUrl/api/createForexPerdiem'; + // api/updateForexPerdiem/39 + + final token = await getToken(); // Fetch token + + if (token == null) { + throw Exception('Token not found. Please log in.'); + } + + // if (selectedPlanId != null && selectedPlanId!.isNotEmpty) { + // planData['plan_id'] = selectedPlanId; // Add plan_id for update + // } + + try { + final uri = Uri.parse(apiUrldata); + final headers = { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }; + final body = jsonEncode(forexData); + + final response = forexDataId != null + ? await http.put(uri, headers: headers, body: body) + : await http.post(uri, headers: headers, body: body); + + // final response = await http.post( + // Uri.parse(apiUrldata), + // headers: { + // 'Authorization': 'Bearer $token', + // 'Content-Type': 'application/json', + // }, + // body: jsonEncode(forexData), // Convert map to JSON + // ); + + if (response.statusCode == 200 || response.statusCode == 201) { + print("Forex Details Created successfully!"); + print("Response: ${response.body}"); + // _clearError(); + _clearError(); + await widget.fetchGetForex(); + + // dispose(); + Navigator.of(context).pop(); + } else { + print("Failed to submit plan. Status: ${response.statusCode}"); + print("Error: ${response.body}"); + } + } catch (e) { + print(" Error submitting plan: $e"); + } + } @override Widget build(BuildContext context) { late Map countryMap; // Mapping country_code -> country_name late List countryCodes; // List of country codes - countryList = []; - // countryList = widget.apiCountryData ?? []; + // countryList = []; + countryList = apiCountryData ?? []; // Map country codes to country names countryMap = { @@ -52,13 +285,18 @@ class ForexDataState extends State { Row( children: [ Text( - 'Create Forex Details', - style: GoogleFonts.poppins(fontSize: 18, color: Colors.black), + 'Create Perdiem Amount', + style: GoogleFonts.poppins(fontSize: 15, color: Colors.black), ), const Spacer(), ], ), - const SizedBox(height: 10), + const SizedBox(height: 2), + Divider( + thickness: 0.2, + color: Colors.blueGrey.shade100, + ), + const SizedBox(height: 5), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -80,10 +318,23 @@ class ForexDataState extends State { selectedItem: countryMap[selectedCountry], popupProps: PopupProps.menu( showSearchBox: true, // Enables search functionality + menuProps: const MenuProps( + backgroundColor: Colors.white, + ), + constraints: BoxConstraints(maxHeight: 250), + itemBuilder: (context, item, isSelected) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, vertical: 6.0), + child: Text( + item, + style: GoogleFonts.poppins(fontSize: 11.5), + ), + ), searchFieldProps: TextFieldProps( decoration: InputDecoration( hintText: "Search Country...", - contentPadding: EdgeInsets.symmetric(horizontal: 10), + hintStyle: GoogleFonts.poppins(fontSize: 11), + contentPadding: EdgeInsets.symmetric(horizontal: 4), ), ), ), @@ -101,7 +352,7 @@ class ForexDataState extends State { alignment: Alignment.centerLeft, child: Text( selectedItem ?? "Select Country", - style: TextStyle(fontSize: 12), + style: GoogleFonts.poppins(fontSize: 11), ), ), onChanged: (String? newValue) { @@ -110,18 +361,19 @@ class ForexDataState extends State { selectedCountry = countryMap.entries .firstWhere((entry) => entry.value == newValue) .key; + selectedCountryName = newValue; }); }, ), ), ), - // if (errorMessages["country_code"] != null) ...[ - // SizedBox(height: 5), // Space before error message - // Text( - // "Select Country", - // style: TextStyle(color: Colors.red, fontSize: 12), - // ), - // ], + if (errorMessages["country_code"] != null) ...[ + SizedBox(height: 5), // Space before error message + Text( + errorMessages["country_code"]!, + style: TextStyle(color: Colors.red, fontSize: 12), + ), + ], ], ), const SizedBox(height: 10), @@ -129,7 +381,7 @@ class ForexDataState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Currency *", + "Currency", style: GoogleFonts.poppins( fontSize: 12, fontWeight: FontWeight.w600, @@ -144,24 +396,26 @@ class ForexDataState extends State { // ? MediaQuery.of(context).size.width * 0.330 // : MediaQuery.of(context).size.width * 0.66, child: SizedBox( - height: 40, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Center( - child: Text( - // "cur", - // "${selectedCurrency}", - selectedCurrency ?? "Currency", - // selectedCurrency?.isNotEmpty == true ? selectedCurrency! : "Currency", - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFF575A74)), + height: 40, + child: TextField( + controller: controllers["currency"], + style: const TextStyle(fontSize: 12), + decoration: const InputDecoration( + labelText: "Currency", + labelStyle: TextStyle(fontSize: 11, color: Colors.grey), + floatingLabelBehavior: FloatingLabelBehavior.never, + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 16), ), - ), - ), - ), + )), ), + if (errorMessages["currency"] != null) ...[ + SizedBox(height: 5), // Space before error message + Text( + errorMessages["currency"]!, + style: TextStyle(color: Colors.red, fontSize: 12), + ), + ], ], ), // if (isDesktop)SizedBox(width: 8,) else SizedBox(height: 8,), @@ -185,33 +439,64 @@ class ForexDataState extends State { isDesktop: widget.isDesktop, color: Colors.transparent, child: SizedBox( - height: 35, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - // "amo", - selectedPerdiemAmount ?? "Amount", - // selectedPerdiemAmount?.isNotEmpty == true ? selectedPerdiemAmount! : "Amount", - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFF575A74)), - // decoration: const InputDecoration( - // labelText: "To", - // labelStyle: TextStyle(fontSize: 12, color: Colors.grey), - // floatingLabelBehavior: FloatingLabelBehavior.never, - // border: InputBorder.none, - // contentPadding: EdgeInsets.symmetric(vertical: 16), - // ), - ), - ), - ), + height: 40, + child: TextField( + controller: controllers["perdiemAmount"], + style: const TextStyle(fontSize: 12), + decoration: const InputDecoration( + labelText: "Perdiem Amount", + labelStyle: TextStyle(fontSize: 11, color: Colors.grey), + floatingLabelBehavior: FloatingLabelBehavior.never, + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 16), + ), + )), ), + if (errorMessages["perdiemAmount"] != null) ...[ + SizedBox(height: 5), // Space before error message + Text( + errorMessages["perdiemAmount"]!, + style: TextStyle(color: Colors.red, fontSize: 12), + ), + ], ], ), SizedBox( height: 15, ), + + if (forexDataId != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Change Status ", + style: GoogleFonts.poppins( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF575A74)), + ), + Tooltip( + message: + isActive == "1" ? "Tap to deactivate" : "Tap to activate", + child: GestureDetector( + onTap: toggleStatus, + child: Text( + isActive == "1" ? "Active" : "Inactive", + style: TextStyle( + fontSize: 13, + fontFamily: "Inter", + color: isActive == "1" ? Colors.green : Colors.red, + ), + ), + ), + ) + ], + ), + if (forexDataId != null) + SizedBox( + height: 15, + ), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -238,8 +523,9 @@ class ForexDataState extends State { SizedBox( child: ElevatedButton( onPressed: () { + handleSubmit(); // You can get text from commentController.text - Navigator.of(context).pop(); // Close the modal + // Navigator.of(context).pop(); // Close the modal }, style: ElevatedButton.styleFrom( backgroundColor: widget.layoutColor, @@ -247,7 +533,7 @@ class ForexDataState extends State { borderRadius: BorderRadius.circular(8), ), ), - child: Text('Submit', + child: Text('Save', style: GoogleFonts.poppins( fontSize: 11, color: Colors.white)), ), diff --git a/lib/Screens/forex/forex_list.dart b/lib/Screens/forex/forex_list.dart index a4cc19d..58dae9b 100644 --- a/lib/Screens/forex/forex_list.dart +++ b/lib/Screens/forex/forex_list.dart @@ -25,6 +25,9 @@ class ForexDataList extends StatefulWidget { } class ForexDataListState extends State { + final GlobalKey forexListKey = + GlobalKey(); + final ApiService apiService = ApiService(); late Future> futureForex; @@ -62,6 +65,19 @@ class ForexDataListState extends State { // futurePlans = fetchPlans(); } + Future> refreshData() { + print("Calling Refresh Data"); + + futureForex = fetchGetForex(); + + return futureForex.then((users) { + setState(() { + allForex = users; + }); + return users; + }); + } + void loadInitialData() async { String? layoutString = await getLayoutColor(); String? bodyStringColor = await getBodyColor(); @@ -266,21 +282,43 @@ class ForexDataListState extends State { }); } - void filterUsers(String query) { + void filterForex1(String query) { print("allUsers before filtering: $query"); final lowerQuery = query.toLowerCase(); setState(() { - filteredForex = allForex.where((user) { - return (user['first_name']?.toLowerCase().contains(lowerQuery) ?? + filteredForex = allForex.where((forex) { + return (forex['country_code']?.toLowerCase().contains(lowerQuery) ?? false) || - (user['last_name']?.toLowerCase().contains(lowerQuery) ?? false) || - (user['email']?.toLowerCase().contains(lowerQuery) ?? false) || - (user['role_value']?.toLowerCase().contains(lowerQuery) ?? false); + (forex['country_name']?.toLowerCase().contains(lowerQuery) ?? + false) || + (forex['currency']?.toLowerCase().contains(lowerQuery) ?? false) || + (forex['perdiem_amount']?.toLowerCase().contains(lowerQuery) ?? + false); }).toList(); }); print("filteredPlans: $filteredForex"); } + void filterForex(String query) { + print("allForex before filtering: $query"); + final lowerQuery = query.toLowerCase(); + setState(() { + filteredForex = allForex.where((forex) { + final isActiveStatus = + forex['is_active'] == "1" ? "active" : "inactive"; + return (forex['country_code']?.toLowerCase().contains(lowerQuery) ?? + false) || + (forex['country_name']?.toLowerCase().contains(lowerQuery) ?? + false) || + (forex['currency']?.toLowerCase().contains(lowerQuery) ?? false) || + (forex['perdiem_amount']?.toLowerCase().contains(lowerQuery) ?? + false) || + (isActiveStatus.contains(lowerQuery)); + }).toList(); + }); + print("filteredForex: $filteredForex"); + } + @override Widget build(BuildContext context) { return ResponsiveBuilder(builder: (context, sizingInfo) { @@ -360,7 +398,7 @@ class ForexDataListState extends State { Row( children: [ Text( - 'Forex Details', + 'Perdiem Amount Details', style: GoogleFonts.poppins( fontSize: isDesktop ? 16 : 14, fontWeight: FontWeight.w600, @@ -371,7 +409,7 @@ class ForexDataListState extends State { ), if (isDesktop) SizedBox( - width: MediaQuery.of(context).size.width * 0.22, + width: MediaQuery.of(context).size.width * 0.16, ), if (isDesktop) @@ -380,7 +418,7 @@ class ForexDataListState extends State { height: 40, child: TextField( controller: searchController, - onChanged: filterUsers, + onChanged: filterForex, decoration: InputDecoration( hintText: "Search ...", hintStyle: TextStyle( @@ -431,11 +469,8 @@ class ForexDataListState extends State { context: context, builder: (context) => ForexData( isDesktop: isDesktop, - // planId: plan.planId, - // planId: plan - // .planId - // .toString(), layoutColor: layoutColor!, + fetchGetForex: refreshData, // role: // "Travel Agent" ), @@ -446,7 +481,7 @@ class ForexDataListState extends State { MainAxisSize.min, // Ensures content fits nicely children: [ Text( - "Add Forex", + "Add Perdiem", style: GoogleFonts.poppins( fontSize: isDesktop ? 13 : 11, ), @@ -477,7 +512,7 @@ class ForexDataListState extends State { height: 35, child: TextField( controller: searchController, - onChanged: filterUsers, + onChanged: filterForex, decoration: InputDecoration( hintText: "Search ...", hintStyle: TextStyle( @@ -537,7 +572,7 @@ class ForexDataListState extends State { // ), const SizedBox(height: 8), Text( - "No Plans Available For This User", + "No Perdiem Available ", textAlign: TextAlign.center, style: GoogleFonts.poppins( fontSize: 20, @@ -546,7 +581,7 @@ class ForexDataListState extends State { ), const SizedBox(height: 20), Text( - "Please Create Plan", + "Please Create Perdiem Amount", textAlign: TextAlign.center, style: GoogleFonts.poppins( fontSize: 16, color: Colors.grey), @@ -558,10 +593,10 @@ class ForexDataListState extends State { ); } - List users = + List forex = filteredForex.isNotEmpty ? filteredForex : allForex; - users.sort((a, b) { + forex.sort((a, b) { DateTime dateA = DateTime.parse(a['created_on']); DateTime dateB = DateTime.parse(b['created_on']); @@ -569,7 +604,7 @@ class ForexDataListState extends State { .compareTo(dateA); // Descending: newest first }); - List paginatedUser = users + List paginatedForex = forex .skip(currentPage * itemsPerPage) .take(itemsPerPage) .toList(); @@ -617,6 +652,13 @@ class ForexDataListState extends State { fontSize: 13, fontWeight: FontWeight.w600), )), + DataColumn( + label: Text( + 'Status', + style: GoogleFonts.poppins( + fontSize: 13, + fontWeight: FontWeight.w600), + )), DataColumn( label: Text( 'Actions', @@ -625,7 +667,7 @@ class ForexDataListState extends State { fontWeight: FontWeight.w600), )), ], - rows: paginatedUser.map((forex) { + rows: paginatedForex.map((forex) { String forexId = forex['forex_perdiem_id'] .toString(); // Get user ID bool isSelected = selectedUserId == forexId; @@ -658,186 +700,64 @@ class ForexDataListState extends State { softWrap: true, overflow: TextOverflow.ellipsis)), DataCell( - UserActionsMenu( - user: forex, - getUserDetails: (id) => - apiService.getSingleUser(id), + Text( + forex['is_active'] == "1" + ? 'Active' + : 'Inactive', + style: TextStyle( + fontSize: 13, + fontFamily: "Inter", + // color: forex['is_active'] == "1" + // ? Colors.green + // : Colors.grey, + ), + softWrap: true, + overflow: TextOverflow.ellipsis, ), - // PopupMenuButton( - // color: Colors.white, - // padding: EdgeInsets.zero, - // offset: Offset(0, 30), - // icon: Icon( - // Icons.more_vert, - // color: Color(0xFF475569), - // size: 14, - // ), - // itemBuilder: (context) => [ - // CustomPopupMenuEntry( - // child: Container( - // padding: EdgeInsets.symmetric( - // horizontal: 8, vertical: 8), - // child: Row( - // mainAxisSize: MainAxisSize.min, - // mainAxisAlignment: - // MainAxisAlignment.center, - // children: [ - // IconButton( - // icon: Icon( - // Icons.remove_red_eye, - // color: - // Color(0xFF475569), - // size: 18), - // onPressed: () async { - // print( - // "USerDAta1 - $user"); - // // Fetch the user data properly with await - // Map - // usersData = - // await apiService - // .getSingleUser(user[ - // 'user_id'] - // is String - // ? int.parse(user[ - // 'user_id']) - // : user[ - // 'user_id']); - // - // print( - // "USerDAta2 - $usersData"); - // - // // userSingleData = - // // await apiService - // // .getSingleUser(user[ - // // 'user_id']); - // - // context.go( - // "/CreateUserDetails", - // extra: { - // "selectedUser": - // usersData, - // "isViewMode": true - // }, - // ); - // }), - // IconButton( - // icon: Image.asset( - // 'assets/images/IconsImg/edit.png', - // width: 20, - // height: 15), - // onPressed: () async { - // // Fetch the user data properly with await - // Map - // usersData = - // await apiService - // .getSingleUser(user[ - // 'user_id'] - // is String - // ? int.parse(user[ - // 'user_id']) - // : user[ - // 'user_id']); - // - // print( - // "USerDAta2 - $usersData"); - // context.go( - // "/CreateUserDetails", - // extra: { - // "selectedUser": - // usersData, - // "isViewMode": false - // }, - // ); - // }, - // ), - // ], - // ), - // ), - // ), - // ], - // ), - // Row( - // children: [ - // MouseRegion( - // cursor: user['is_active'] == "0" - // ? SystemMouseCursors.forbidden - // : SystemMouseCursors.click, - // child: IconButton( - // icon: Icon(Icons.remove_red_eye, - // size: 18, - // color: user['is_active'] == "0" - // ? Colors.grey - // : Color(0xFF475569)), - // onPressed: user['is_active'] == "0" - // ? null - // : () { - // context.go( - // "/CreateUserDetails", - // extra: { - // "selectedUser": user, - // "isViewMode": true - // }, - // ); - // }, - // ), - // ), - // - // MouseRegion( - // cursor: user['is_active'] == "0" - // ? SystemMouseCursors.forbidden - // : SystemMouseCursors.click, - // child: GestureDetector( - // onTap: user['is_active'] == "0" - // ? null - // : () { - // - // }, - // child: Image.asset( - // 'assets/images/IconsImg/edit.png', - // width: 20, - // height: 15), - // ), - // ), - // - // // MouseRegion( - // // cursor: user['is_active'] == "0" - // // ? SystemMouseCursors - // // .forbidden - // // : SystemMouseCursors.click, - // // child: IconButton( - // // icon: Icon(Icons.edit, - // // color: - // // user['is_active'] == - // // "0" - // // ? Colors.grey - // // : Colors.green), - // // onPressed: - // // user['is_active'] == "0" - // // ? null - // // : () { - // // print( - // // "USER: $user"); - // // - // // // final userJson = jsonEncode( - // // // user); // Convert user map to string - // // // final encodedUser = - // // // Uri.encodeComponent( - // // // userJson); - // // - // // context.go( - // // "/CreateUserDetails", - // // extra: { - // // "selectedUser": - // // user, - // // "isViewMode": - // // false - // // }, - // // ); - // // }, - // // ), - // // ), - // ], + ), + DataCell( + // UserActionsMenu( + // user: forex, + // getUserDetails: (id) => + // apiService.getSingleUser(id), // ), + GestureDetector( + child: Image.asset( + 'assets/images/IconsImg/edit.png', + width: 20, + height: 15), + onTap: () async { + // final userId = getUserId(user['user_id']); + // final usersData = await getUserDetails(userId); + // + final forexId = int.tryParse( + forex['forex_perdiem_id'] + .toString()); + + if (forexId != null) { + print("ForexId -- $forexId"); + final data = await apiService + .getForexDetailsFind(forexId); + print("ForexId -- $data"); + + showDialog( + context: context, + builder: (context) => ForexData( + isDesktop: isDesktop, + forexId: forexId, // Pass the ID + forexData: data, + layoutColor: layoutColor!, + // fetchGetForex: fetchGetForex, + fetchGetForex: refreshData, + // role: + // "Travel Agent" + ), + ); + } else { + print("Invalid Forex ID"); + } + }, + ), ), ]); }).toList(), @@ -877,10 +797,43 @@ class ForexDataListState extends State { fontWeight: FontWeight.w700), ), - UserActionsMenu( - user: forex, - getUserDetails: (id) => - apiService.getSingleUser(id), + GestureDetector( + child: Image.asset( + 'assets/images/IconsImg/edit.png', + width: 20, + height: 15), + onTap: () async { + // final userId = getUserId(user['user_id']); + // final usersData = await getUserDetails(userId); + // + final forexId = int.tryParse( + forex['forex_perdiem_id'] + .toString()); + + if (forexId != null) { + print("ForexId -- $forexId"); + final data = await apiService + .getForexDetailsFind(forexId); + print("ForexId -- $data"); + + showDialog( + context: context, + builder: (context) => ForexData( + isDesktop: isDesktop, + forexId: + forexId, // Pass the ID + forexData: data, + layoutColor: layoutColor!, + // fetchGetForex: fetchGetForex, + fetchGetForex: refreshData, + // role: + // "Travel Agent" + ), + ); + } else { + print("Invalid Forex ID"); + } + }, ), // PopupMenuButton( // color: Colors.white, @@ -1027,16 +980,44 @@ class ForexDataListState extends State { children: [ Expanded( child: isDesktop - ? SingleChildScrollView( - scrollDirection: Axis.vertical, - child: table, // <-- your existing table - ) - : buildMobileCardView(paginatedUser), + ? (searchController.text.isNotEmpty && + filteredForex.isEmpty + ? Center( + child: Text( + "No matches found", + style: GoogleFonts.poppins( + fontSize: 14, + color: Colors.grey), + ), + ) + : SingleChildScrollView( + scrollDirection: Axis.vertical, + child: table, + )) + : (searchController.text.isNotEmpty && + filteredForex.isEmpty + ? Center( + child: Text( + "No matches found", + style: GoogleFonts.poppins( + fontSize: 14, + color: Colors.grey), + ), + ) + : buildMobileCardView(paginatedForex)), ), + // Expanded( + // child: isDesktop + // ? SingleChildScrollView( + // scrollDirection: Axis.vertical, + // child: table, // <-- your existing table + // ) + // : buildMobileCardView(paginatedUser), + // ), PaginationControls( currentPage: currentPage, itemsPerPage: itemsPerPage, - totalItems: users.length, + totalItems: forex.length, activeColor: layoutColor, // your theme color onPageChanged: (page) { setState(() { diff --git a/lib/Screens/myTemplates/assets.dart b/lib/Screens/myTemplates/assets.dart new file mode 100644 index 0000000..d5b4d63 --- /dev/null +++ b/lib/Screens/myTemplates/assets.dart @@ -0,0 +1,4 @@ +const kScreenshot1 = 'assets/images/screenshot_1.png'; +const kScreenshot2 = 'assets/images/screenshot_2.png'; +const kScreenshot3 = 'assets/images/screenshot_3.png'; +const kScreenshot4 = 'assets/images/screenshot_4.png'; \ No newline at end of file diff --git a/lib/Screens/myTemplates/custom_toolbar.dart b/lib/Screens/myTemplates/custom_toolbar.dart new file mode 100644 index 0000000..924c09d --- /dev/null +++ b/lib/Screens/myTemplates/custom_toolbar.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; + +/// Custom toolbar that uses the buttons of [`flutter_quill`](https://pub.dev/packages/flutter_quill). +/// +/// See also: [Custom toolbar](https://github.com/singerdmx/flutter-quill/blob/master/doc/custom_toolbar.md). +class CustomToolbar extends StatelessWidget { + const CustomToolbar({super.key, required this.controller}); + + final QuillController controller; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + children: [ + QuillToolbarHistoryButton( + isUndo: true, + controller: controller, + ), + QuillToolbarHistoryButton( + isUndo: false, + controller: controller, + ), + QuillToolbarToggleStyleButton( + options: const QuillToolbarToggleStyleButtonOptions(), + controller: controller, + attribute: Attribute.bold, + ), + QuillToolbarToggleStyleButton( + options: const QuillToolbarToggleStyleButtonOptions(), + controller: controller, + attribute: Attribute.italic, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.underline, + ), + QuillToolbarClearFormatButton( + controller: controller, + ), + const VerticalDivider(), + QuillToolbarImageButton( + controller: controller, + ), + QuillToolbarCameraButton( + controller: controller, + ), + QuillToolbarVideoButton( + controller: controller, + ), + const VerticalDivider(), + QuillToolbarColorButton( + controller: controller, + isBackground: false, + ), + QuillToolbarColorButton( + controller: controller, + isBackground: true, + ), + const VerticalDivider(), + QuillToolbarSelectHeaderStyleDropdownButton( + controller: controller, + ), + const VerticalDivider(), + QuillToolbarSelectLineHeightStyleDropdownButton( + controller: controller, + ), + const VerticalDivider(), + QuillToolbarToggleCheckListButton( + controller: controller, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.ol, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.ul, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.inlineCode, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.blockQuote, + ), + QuillToolbarIndentButton( + controller: controller, + isIncrease: true, + ), + QuillToolbarIndentButton( + controller: controller, + isIncrease: false, + ), + const VerticalDivider(), + QuillToolbarLinkStyleButton(controller: controller), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/Screens/myTemplates/flutter_quill_extensions.dart b/lib/Screens/myTemplates/flutter_quill_extensions.dart new file mode 100644 index 0000000..d68b6cf --- /dev/null +++ b/lib/Screens/myTemplates/flutter_quill_extensions.dart @@ -0,0 +1,21 @@ +library; + +export 'src/common/extensions/controller_ext.dart'; +export 'src/editor/image/config/image_config.dart'; +export 'src/editor/image/config/image_web_config.dart'; +export 'src/editor/image/image_embed.dart'; +export 'src/editor/image/image_embed_types.dart'; +export 'src/editor/image/image_web_embed.dart'; +export 'src/editor/video/config/video_config.dart'; +export 'src/editor/video/config/video_web_config.dart'; +export 'src/editor/video/video_embed.dart'; +export 'src/editor/video/video_web_embed.dart'; +export 'src/flutter_quill_embeds.dart'; +export 'src/toolbar/camera/camera_button.dart'; +export 'src/toolbar/camera/camera_types.dart'; +export 'src/toolbar/camera/config/camera_config.dart'; +export 'src/toolbar/image/config/image_config.dart'; +export 'src/toolbar/image/image_button.dart'; +export 'src/toolbar/video/config/video.dart'; +export 'src/toolbar/video/config/video_config.dart'; +export 'src/toolbar/video/video_button.dart'; \ No newline at end of file diff --git a/lib/Screens/myTemplates/quill_delta_sample.dart b/lib/Screens/myTemplates/quill_delta_sample.dart new file mode 100644 index 0000000..dbe86ea --- /dev/null +++ b/lib/Screens/myTemplates/quill_delta_sample.dart @@ -0,0 +1,295 @@ +import 'assets.dart'; + +const kQuillDefaultSample = [ + { + 'insert': {'image': kScreenshot2}, + 'attributes': { + 'width': '100', + 'height': '100', + 'style': 'width:500px; height:350px;' + } + }, + {'insert': 'Flutter Quill'}, + { + 'attributes': {'header': 1}, + 'insert': '\n' + }, + { + 'insert': { + 'video': + 'https://www.youtube.com/watch?v=V4hgdKhIqtc&list=PLbhaS_83B97s78HsDTtplRTEhcFsqSqIK&index=1' + } + }, + { + 'insert': { + 'video': + 'https://user-images.githubusercontent.com/122956/126238875-22e42501-ad41-4266-b1d6-3f89b5e3b79b.mp4' + } + }, + {'insert': '\nRich text editor for Flutter'}, + { + 'attributes': {'header': 2}, + 'insert': '\n' + }, + {'insert': 'Quill component for Flutter'}, + { + 'attributes': {'header': 3}, + 'insert': '\n' + }, + { + 'attributes': {'link': 'https://bulletjournal.us/home/index.html'}, + 'insert': 'Bullet Journal' + }, + { + 'insert': + ':\nTrack personal and group journals (ToDo, Note, Ledger) from multiple views with timely reminders' + }, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + { + 'insert': + 'Share your tasks and notes with teammates, and see changes as they happen in real-time, across all devices' + }, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'Check out what you and your teammates are working on each day'}, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': '\nSplitting bills with friends can never be easier.'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'Start creating a group and invite your friends to join.'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'Create a BuJo of Ledger type to see expense or balance summary.'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + { + 'insert': + '\nAttach one or multiple labels to tasks, notes or transactions. Later you can track them just using the label(s).' + }, + { + 'attributes': {'blockquote': true}, + 'insert': '\n' + }, + {'insert': "\nvar BuJo = 'Bullet' + 'Journal'"}, + { + 'attributes': {'code-block': true}, + 'insert': '\n' + }, + {'insert': '\nStart tracking in your browser'}, + { + 'attributes': {'indent': 1}, + 'insert': '\n' + }, + {'insert': 'Stop the timer on your phone'}, + { + 'attributes': {'indent': 1}, + 'insert': '\n' + }, + {'insert': 'All your time entries are synced'}, + { + 'attributes': {'indent': 2}, + 'insert': '\n' + }, + {'insert': 'between the phone apps'}, + { + 'attributes': {'indent': 2}, + 'insert': '\n' + }, + {'insert': 'and the website.'}, + { + 'attributes': {'indent': 3}, + 'insert': '\n' + }, + {'insert': '\n'}, + {'insert': '\nCenter Align'}, + { + 'attributes': {'align': 'center'}, + 'insert': '\n' + }, + {'insert': 'Right Align'}, + { + 'attributes': {'align': 'right'}, + 'insert': '\n' + }, + {'insert': 'Justify Align'}, + { + 'attributes': {'align': 'justify'}, + 'insert': '\n' + }, + {'insert': 'Have trouble finding things? '}, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'Just type in the search bar'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'and easily find contents'}, + { + 'attributes': {'indent': 2, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'across projects or folders.'}, + { + 'attributes': {'indent': 2, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'It matches text in your note or task.'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'Enable reminders so that you will get notified by'}, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'email'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'message on your phone'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'popup on the web site'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'Create a BuJo serving as project or folder'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'Organize your'}, + { + 'attributes': {'indent': 1, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'tasks'}, + { + 'attributes': {'indent': 2, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'notes'}, + { + 'attributes': {'indent': 2, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'transactions'}, + { + 'attributes': {'indent': 2, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'under BuJo '}, + { + 'attributes': {'indent': 3, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'See them in Calendar'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'or hierarchical view'}, + { + 'attributes': {'indent': 1, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'this is a check list'}, + { + 'attributes': {'list': 'checked'}, + 'insert': '\n' + }, + {'insert': 'this is a uncheck list'}, + { + 'attributes': {'list': 'unchecked'}, + 'insert': '\n' + }, + {'insert': 'Font '}, + { + 'attributes': {'font': 'sans-serif'}, + 'insert': 'Sans Serif' + }, + {'insert': ' '}, + { + 'attributes': {'font': 'serif'}, + 'insert': 'Serif' + }, + {'insert': ' '}, + { + 'attributes': {'font': 'monospace'}, + 'insert': 'Monospace' + }, + {'insert': ' Size '}, + { + 'attributes': {'size': 'small'}, + 'insert': 'Small' + }, + {'insert': ' '}, + { + 'attributes': {'size': 'large'}, + 'insert': 'Large' + }, + {'insert': ' '}, + { + 'attributes': {'size': 'huge'}, + 'insert': 'Huge' + }, + { + 'attributes': {'size': '15.0'}, + 'insert': 'font size 15' + }, + {'insert': ' '}, + { + 'attributes': {'size': '35'}, + 'insert': 'font size 35' + }, + {'insert': ' '}, + { + 'attributes': {'size': '20'}, + 'insert': 'font size 20' + }, + { + 'attributes': {'token': 'built_in'}, + 'insert': ' diff' + }, + { + 'attributes': {'token': 'operator'}, + 'insert': '-match' + }, + { + 'attributes': {'token': 'literal'}, + 'insert': '-patch' + }, + { + 'insert': { + 'image': + 'https://flutter.github.io/assets-for-api-docs/assets/widgets/owl.jpg' + }, + 'attributes': { + 'width': '230', + 'style': 'display: block; margin: auto; width: 500px;' + } + }, + {'insert': '\n'} +]; \ No newline at end of file diff --git a/lib/Screens/myTemplates/src/common/default_image_insert.dart b/lib/Screens/myTemplates/src/common/default_image_insert.dart new file mode 100644 index 0000000..bec31d9 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/default_image_insert.dart @@ -0,0 +1,30 @@ +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart'; + +import '../editor/image/image_embed_types.dart'; +import 'extensions/controller_ext.dart'; + +OnImageInsertCallback _defaultOnImageInsert() { + return (imageUrl, controller) async { + controller + ..skipRequestKeyboard = true + // ignore: deprecated_member_use_from_same_package + ..insertImageBlock(imageSource: imageUrl); + }; +} + +@internal +Future handleImageInsert( + String imageUrl, { + required QuillController controller, + required OnImageInsertCallback? onImageInsertCallback, + required OnImageInsertedCallback? onImageInsertedCallback, +}) async { + final customOnImageInsert = onImageInsertCallback; + if (customOnImageInsert != null) { + await customOnImageInsert.call(imageUrl, controller); + } else { + await _defaultOnImageInsert().call(imageUrl, controller); + } + await onImageInsertedCallback?.call(imageUrl); +} diff --git a/lib/Screens/myTemplates/src/common/default_video_insert.dart b/lib/Screens/myTemplates/src/common/default_video_insert.dart new file mode 100644 index 0000000..e92d3ce --- /dev/null +++ b/lib/Screens/myTemplates/src/common/default_video_insert.dart @@ -0,0 +1,30 @@ +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart'; + +import '../toolbar/video/config/video.dart'; +import 'extensions/controller_ext.dart'; + +OnVideoInsertCallback _defaultOnVideoInsert() { + return (imageUrl, controller) async { + controller + ..skipRequestKeyboard = true + // ignore: deprecated_member_use_from_same_package + ..insertVideoBlock(videoUrl: imageUrl); + }; +} + +@internal +Future handleVideoInsert( + String videoUrl, { + required QuillController controller, + required OnVideoInsertCallback? onVideoInsertCallback, + required OnVideoInsertedCallback? onVideoInsertedCallback, +}) async { + final customOnVideoInsert = onVideoInsertCallback; + if (customOnVideoInsert != null) { + await customOnVideoInsert.call(videoUrl, controller); + } else { + await _defaultOnVideoInsert().call(videoUrl, controller); + } + await onVideoInsertedCallback?.call(videoUrl); +} diff --git a/lib/Screens/myTemplates/src/common/extensions/attribute.dart b/lib/Screens/myTemplates/src/common/extensions/attribute.dart new file mode 100644 index 0000000..5331b55 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/extensions/attribute.dart @@ -0,0 +1,12 @@ +import 'package:flutter_quill/flutter_quill.dart' + show Attribute, AttributeScope; + +class FlutterAlignmentAttribute extends Attribute { + const FlutterAlignmentAttribute(String? val) + : super('flutterAlignment', AttributeScope.ignore, val); +} + +extension AttributeExt on Attribute { + static const FlutterAlignmentAttribute flutterAlignment = + FlutterAlignmentAttribute(null); +} diff --git a/lib/Screens/myTemplates/src/common/extensions/controller_ext.dart b/lib/Screens/myTemplates/src/common/extensions/controller_ext.dart new file mode 100644 index 0000000..45443c4 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/extensions/controller_ext.dart @@ -0,0 +1,36 @@ +import 'package:flutter_quill/flutter_quill.dart'; + +@Deprecated('Invalid extension') +extension QuillControllerExt on QuillController { + @Deprecated( + 'Invalid extension property and will be removed, use selection.baseOffset instead') + int get index => selection.baseOffset; + @Deprecated( + 'Invalid extension property and will be removed, use selection.extentOffset - selection.baseOffset instead') + int get length => selection.extentOffset - index; + + @Deprecated('Invalid extension method and will be removed.') + void insertImageBlock({ + required String imageSource, + }) { + this + ..skipRequestKeyboard = true + ..replaceText( + index, + length, + BlockEmbed.image(imageSource), + null, + ) + ..moveCursorToPosition(index + 1); + } + + @Deprecated('Invalid extension method and will be removed.') + void insertVideoBlock({ + required String videoUrl, + }) { + this + ..skipRequestKeyboard = true + ..replaceText(index, length, BlockEmbed.video(videoUrl), null) + ..moveCursorToPosition(index + 1); + } +} diff --git a/lib/Screens/myTemplates/src/common/image_video_utils.dart b/lib/Screens/myTemplates/src/common/image_video_utils.dart new file mode 100644 index 0000000..2165665 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/image_video_utils.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' show QuillDialogTheme; +import 'package:flutter_quill/internal.dart'; + +import 'utils/patterns.dart'; + +enum LinkType { + video, + image, +} + +class TypeLinkDialog extends StatefulWidget { + const TypeLinkDialog({ + required this.linkType, + this.dialogTheme, + this.link, + this.linkRegExp, + super.key, + }); + + final QuillDialogTheme? dialogTheme; + final String? link; + final RegExp? linkRegExp; + final LinkType linkType; + + @override + TypeLinkDialogState createState() => TypeLinkDialogState(); +} + +class TypeLinkDialogState extends State { + late String _link; + late TextEditingController _controller; + RegExp? _linkRegExp; + + @override + void initState() { + super.initState(); + _link = widget.link ?? ''; + _controller = TextEditingController(text: _link); + + _linkRegExp = widget.linkRegExp; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: widget.dialogTheme?.dialogBackgroundColor, + content: TextField( + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + maxLines: null, + style: widget.dialogTheme?.inputTextStyle, + decoration: InputDecoration( + labelText: context.loc.pasteLink, + hintText: widget.linkType == LinkType.image + ? context.loc.pleaseEnterAValidImageURL + : context.loc.pleaseEnterAValidVideoURL, + labelStyle: widget.dialogTheme?.labelTextStyle, + floatingLabelStyle: widget.dialogTheme?.labelTextStyle, + ), + autofocus: true, + onChanged: _linkChanged, + controller: _controller, + onEditingComplete: () { + if (!_canPress()) { + return; + } + _applyLink(); + }, + ), + actions: [ + TextButton( + onPressed: _canPress() ? _applyLink : null, + child: Text( + context.loc.ok, + style: widget.dialogTheme?.labelTextStyle, + ), + ), + ], + ); + } + + void _linkChanged(String value) { + setState(() { + _link = value; + }); + } + + void _applyLink() { + Navigator.pop(context, _link.trim()); + } + + RegExp get linkRegExp { + final customRegExp = _linkRegExp; + if (customRegExp != null) { + return customRegExp; + } + switch (widget.linkType) { + case LinkType.video: + if (youtubeRegExp.hasMatch(_link)) { + return youtubeRegExp; + } + return videoRegExp; + case LinkType.image: + return imageRegExp; + } + } + + bool _canPress() { + if (_link.isEmpty) { + return false; + } + if (widget.linkType == LinkType.image) {} + return _link.isNotEmpty && linkRegExp.hasMatch(_link); + } +} diff --git a/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_fake.dart b/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_fake.dart new file mode 100644 index 0000000..c42e2eb --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_fake.dart @@ -0,0 +1,43 @@ +// import 'package:universal_html/html.dart' as html; + +// Fake interface for the logic that this package needs from (web-only) dart:ui. +// This is conditionally exported so the analyzer sees these methods as +// available. + +// typedef PlatroformViewFactory = html.Element Function(int viewId); + +// /// Shim for web_ui engine.PlatformViewRegistry +// /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L62 +// class PlatformViewRegistry { +// /// Shim for registerViewFactory +// /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L72 +// static dynamic registerViewFactory( +// String viewTypeId, PlatroformViewFactory viewFactory) {} +// } + +// /// Shim for web_ui engine.AssetManager +// /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/src/engine/assets.dart#L12 +// class WebOnlyAssetManager { +// static dynamic getAssetUrl(String asset) {} +// } + +class PlatformViewRegistry { + /// Register [viewType] as being created by the given [viewFactory]. + /// + /// [viewFactory] can be any function that takes an integer and optional + /// `params` and returns an `HTMLElement` DOM object. + bool registerViewFactory( + String viewType, + Function viewFactory, { + bool isVisible = true, + }) { + return false; + } + + /// Returns the view previously created for [viewId]. + /// + /// Throws if no view has been created for [viewId]. + Object getViewById(int viewId) { + return ''; + } +} diff --git a/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_real.dart b/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_real.dart new file mode 100644 index 0000000..63c4e48 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_real.dart @@ -0,0 +1 @@ +export 'dart:ui' if (dart.library.js_interop) 'dart:ui_web'; diff --git a/lib/Screens/myTemplates/src/common/utils/element_utils/element_shared_utils.dart b/lib/Screens/myTemplates/src/common/utils/element_utils/element_shared_utils.dart new file mode 100644 index 0000000..c5a7d1f --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/element_utils/element_shared_utils.dart @@ -0,0 +1,84 @@ +import 'package:flutter/widgets.dart' show BuildContext, MediaQuery; + +Map parseCssString(String cssString) { + final result = {}; + final declarations = cssString.split(';'); + + for (final declaration in declarations) { + final parts = declaration.split(':'); + if (parts.length == 2) { + final property = parts[0].trim(); + final value = parts[1].trim(); + result[property] = value; + } + } + + return result; +} + +enum _CssUnit { + px('px'), + percentage('%'), + viewportWidth('vw'), + viewportHeight('vh'), + em('em'), + rem('rem'), + invalid('invalid'); + + const _CssUnit(this.cssName); + + final String cssName; +} + +double? parseCssPropertyAsDouble( + String value, { + required BuildContext context, +}) { + if (value.trim().isEmpty) { + return null; + } + + // Try to parse it in case it's a valid double already + var doubleValue = double.tryParse(value); + + if (doubleValue != null) { + return doubleValue; + } + + // If not then if it's a css numberic value then we will try to parse it + final unit = _CssUnit.values + .where((element) => value.endsWith(element.cssName)) + .firstOrNull; + if (unit == null) { + return null; + } + value = value.replaceFirst(unit.cssName, ''); + doubleValue = double.tryParse(value); + if (doubleValue != null) { + switch (unit) { + case _CssUnit.px: + // Do nothing + break; + case _CssUnit.percentage: + // Not supported yet + doubleValue = null; + break; + case _CssUnit.viewportWidth: + doubleValue = (doubleValue / 100) * MediaQuery.sizeOf(context).width; + break; + case _CssUnit.viewportHeight: + doubleValue = (doubleValue / 100) * MediaQuery.sizeOf(context).height; + break; + case _CssUnit.em: + doubleValue = MediaQuery.textScalerOf(context).scale(doubleValue); + break; + case _CssUnit.rem: + doubleValue = MediaQuery.textScalerOf(context).scale(doubleValue); + break; + case _CssUnit.invalid: + doubleValue = null; + break; + } + } + return doubleValue; +} diff --git a/lib/Screens/myTemplates/src/common/utils/element_utils/element_utils.dart b/lib/Screens/myTemplates/src/common/utils/element_utils/element_utils.dart new file mode 100644 index 0000000..86a95f4 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/element_utils/element_utils.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart' show immutable; +import 'package:flutter/widgets.dart' show Alignment, BuildContext; +import 'package:flutter_quill/flutter_quill.dart' show Attribute, Node; +import 'package:flutter_quill/internal.dart'; + +import 'element_shared_utils.dart'; + +/// Theses properties are not officialy supported by quill js +/// but they are only used in all platforms other than web +/// and they will be stored in css style property so quill js ignore them +enum ExtraElementProperties { + deletable, +} + +( + ElementSize elementSize, + double? margin, + Alignment alignment, +) getElementAttributes( + Node node, + BuildContext context, +) { + var elementSize = const ElementSize(null, null); + var elementAlignment = Alignment.center; + double? elementMargin; + + final heightValue = parseCssPropertyAsDouble( + node.style.attributes[Attribute.height.key]?.value.toString() ?? '', + context: context, + ); + final widthValue = parseCssPropertyAsDouble( + node.style.attributes[Attribute.width.key]?.value.toString() ?? '', + context: context, + ); + + if (heightValue != null) { + elementSize = elementSize.copyWith( + height: heightValue, + ); + } + if (widthValue != null) { + elementSize = elementSize.copyWith( + width: widthValue, + ); + } + + final cssStyle = node.style.attributes['style']; + + if (cssStyle != null) { + // It css value as string but we will try to support it anyway + + final cssAttrs = parseCssString(cssStyle.value.toString()); + + final cssHeightValue = parseCssPropertyAsDouble( + (cssAttrs[Attribute.height.key]) ?? '', + context: context, + ); + final cssWidthValue = parseCssPropertyAsDouble( + (cssAttrs[Attribute.width.key]) ?? '', + context: context, + ); + + // cssHeightValue != null && elementSize.height == null + if (cssHeightValue != null) { + elementSize = elementSize.copyWith(height: cssHeightValue); + } + if (cssWidthValue != null) { + elementSize = elementSize.copyWith(width: cssWidthValue); + } + + elementAlignment = getAlignment(cssAttrs['alignment']); + + final margin = double.tryParse('margin'); + if (margin != null) { + elementMargin = margin; + } + } + + return (elementSize, elementMargin, elementAlignment); +} + +@immutable +class ElementSize { + const ElementSize( + this.width, + this.height, + ); + + /// If non-null, requires the child to have exactly this width. + /// If null, the child is free to choose its own width. + final double? width; + + /// If non-null, requires the child to have exactly this height. + /// If null, the child is free to choose its own height. + final double? height; + + ElementSize copyWith({ + double? width, + double? height, + }) { + return ElementSize( + width ?? this.width, + height ?? this.height, + ); + } +} diff --git a/lib/Screens/myTemplates/src/common/utils/element_utils/element_web_utils.dart b/lib/Screens/myTemplates/src/common/utils/element_utils/element_web_utils.dart new file mode 100644 index 0000000..091f1c3 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/element_utils/element_web_utils.dart @@ -0,0 +1,60 @@ +import 'package:flutter_quill/flutter_quill.dart' show Attribute, Node; + +import 'element_shared_utils.dart'; + +/// Prefer the width, and height from the css style attribute if exits +/// it can be `auto` or `100px` so it's specific to HTML && CSS +/// if not, we will use the one from attributes which is usually just an double +( + String height, + String width, + String margin, + String alignment, +) getWebElementAttributes( + Node node, +) { + var height = 'auto'; + var width = 'auto'; + // TODO(): Add support for margin and alignment + var margin = 'auto'; + const alignment = 'center'; + + final cssStyle = node.style.attributes['style']; + + final heightValue = node.style.attributes[Attribute.height.key]?.value; + final widthValue = node.style.attributes[Attribute.width.key]?.value; + + if (cssStyle != null) { + final attrs = parseCssString(cssStyle.value.toString()); + + final cssHeightValue = attrs[Attribute.height.key]; + + if (cssHeightValue != null) { + height = cssHeightValue; + } else { + height = '${heightValue}px'; + } + final cssWidthValue = attrs[Attribute.width.key]; + if (cssWidthValue != null) { + width = cssWidthValue; + } else if (widthValue != null) { + width = '${widthValue}px'; + } + + final cssMarginValue = attrs['margin']; + if (cssMarginValue != null) { + margin = cssMarginValue; + } + + return (height, width, margin, alignment); + } + + if (heightValue != null) { + height = '${heightValue}px'; + } + if (widthValue != null) { + width = '${widthValue}px'; + } + + return (height, width, margin, alignment); +} diff --git a/lib/Screens/myTemplates/src/common/utils/patterns.dart b/lib/Screens/myTemplates/src/common/utils/patterns.dart new file mode 100644 index 0000000..abb7517 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/patterns.dart @@ -0,0 +1,17 @@ +RegExp base64RegExp = RegExp( + r'^(?:[A-Za-z0-9+\/][A-Za-z0-9+\/][A-Za-z0-9+\/][A-Za-z0-9+\/])*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$', +); + +final imageRegExp = RegExp( + r'https?://.*?\.(?:png|jpe?g|gif|bmp|webp|tiff?)', + caseSensitive: false, +); + +final videoRegExp = RegExp( + r'\bhttps?://\S+\.(mp4|mov|avi|mkv|flv|wmv|webm)\b', + caseSensitive: false, +); +final youtubeRegExp = RegExp( + r'^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(-nocookie)?\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|live\/|v\/)?)([\w\-]+)(\S+)?$', + caseSensitive: false, +); diff --git a/lib/Screens/myTemplates/src/common/utils/string.dart b/lib/Screens/myTemplates/src/common/utils/string.dart new file mode 100644 index 0000000..a13cd34 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/string.dart @@ -0,0 +1,30 @@ +import 'package:flutter_quill/flutter_quill.dart' show Attribute; + +String replaceStyleStringWithSize( + String cssStyle, { + required double width, + required double height, +}) { + final result = {}; + final pairs = cssStyle.split(';'); + for (final pair in pairs) { + final index = pair.indexOf(':'); + if (index < 0) { + continue; + } + final key = pair.substring(0, index).trim(); + result[key] = pair.substring(index + 1).trim(); + } + + result[Attribute.width.key] = width.toString(); + result[Attribute.height.key] = height.toString(); + final sb = StringBuffer(); + for (final pair in result.entries) { + sb + ..write(pair.key) + ..write(': ') + ..write(pair.value) + ..write('; '); + } + return sb.toString(); +} diff --git a/lib/Screens/myTemplates/src/common/utils/utils.dart b/lib/Screens/myTemplates/src/common/utils/utils.dart new file mode 100644 index 0000000..13d7540 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/utils.dart @@ -0,0 +1,30 @@ +import 'patterns.dart'; + +bool isBase64(String str) { + return base64RegExp.hasMatch(str); +} + +bool isHttpUrl(String url) { + try { + final uri = Uri.parse(url.trim()); + return uri.isScheme('HTTP') || uri.isScheme('HTTPS'); + } catch (_) { + return false; + } +} + +bool isImageBase64(String imageUrl) { + return !isHttpUrl(imageUrl) && isBase64(imageUrl); +} + +bool isYouTubeUrl(String videoUrl) { + try { + final uri = Uri.parse(videoUrl); + return uri.host == 'www.youtube.com' || + uri.host == 'youtube.com' || + uri.host == 'youtu.be' || + uri.host == 'www.youtu.be'; + } catch (_) { + return false; + } +} diff --git a/lib/Screens/myTemplates/src/common/utils/web/web.dart b/lib/Screens/myTemplates/src/common/utils/web/web.dart new file mode 100644 index 0000000..d16e54b --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/web/web.dart @@ -0,0 +1 @@ +export './web_stub.dart' if (dart.library.js_interop) './web_real.dart'; diff --git a/lib/Screens/myTemplates/src/common/utils/web/web_real.dart b/lib/Screens/myTemplates/src/common/utils/web/web_real.dart new file mode 100644 index 0000000..da3119b --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/web/web_real.dart @@ -0,0 +1,46 @@ +import 'package:web/web.dart'; +import '../dart_ui/dart_ui_fake.dart' + if (dart.library.js_interop) '../dart_ui/dart_ui_real.dart' as ui; + +void main(List args) { + HTMLImageElement; +} + +void createHtmlImageElement({ + required String src, + required String height, + required String width, + required String margin, + required String alignSelf, +}) { + ui.PlatformViewRegistry().registerViewFactory(src, (viewId) { + return createHtmlImageElement( + src: src, + alignSelf: alignSelf, + width: width, + height: height, + margin: margin, + ); + }); +} + +void createHtmlIFrameElement({ + required String src, + required String height, + required String width, + required String margin, + required String alignSelf, +}) { + ui.PlatformViewRegistry().registerViewFactory( + src, + (id) { + return HTMLIFrameElement() + ..style.width = width + ..style.height = height + ..src = src + ..style.border = 'none' + ..style.margin = margin + ..style.alignSelf = alignSelf; + }, + ); +} diff --git a/lib/Screens/myTemplates/src/common/utils/web/web_stub.dart b/lib/Screens/myTemplates/src/common/utils/web/web_stub.dart new file mode 100644 index 0000000..dac6a97 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/web/web_stub.dart @@ -0,0 +1,19 @@ +void createHtmlImageElement({ + required String src, + required String height, + required String width, + required String margin, + required String alignSelf, +}) => + throw UnimplementedError( + 'A stub method is called, createHtmlImageElement is for web platforms only.'); + +void createHtmlIFrameElement({ + required String src, + required String height, + required String width, + required String margin, + required String alignSelf, +}) => + throw UnimplementedError( + 'A stub method is called, createHtmlIFrameElement is for web platforms only.'); diff --git a/lib/Screens/myTemplates/src/editor/image/config/image_config.dart b/lib/Screens/myTemplates/src/editor/image/config/image_config.dart new file mode 100644 index 0000000..3b8f1c1 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/config/image_config.dart @@ -0,0 +1,165 @@ +import 'dart:io' show File; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_quill/internal.dart'; + +import '../image_embed_types.dart'; + +/// [QuillEditorImageEmbedConfig] for desktop, mobile and +/// other platforms +/// excluding web, it's configurations that is needed for the editor +/// +@immutable +class QuillEditorImageEmbedConfig { + const QuillEditorImageEmbedConfig({ + ImageEmbedBuilderOnRemovedCallback? onImageRemovedCallback, + this.shouldRemoveImageCallback, + this.imageProviderBuilder, + this.imageErrorWidgetBuilder, + this.onImageClicked, + }) : _onImageRemovedCallback = onImageRemovedCallback; + + /// [onImageRemovedCallback] is called when an image is + /// removed from the editor. + /// By default, [onImageRemovedCallback] deletes the + /// temporary image file if + /// the platform is mobile and if it still exists. You + /// can customize this behavior + /// by passing your own function that handles the removal process. + /// + /// Example of [onImageRemovedCallback] customization: + /// ```dart + /// afterRemoveImageFromEditor: (imageFile) async { + /// // Your custom logic here + /// // or leave it empty to do nothing + /// } + /// ``` + /// + /// Default value if the passed value is null: + /// [QuillEditorImageEmbedConfig.defaultOnImageRemovedCallback] + /// + /// so if you want to do nothing make sure to pass a empty callback + /// instead of passing null as value + final ImageEmbedBuilderOnRemovedCallback? _onImageRemovedCallback; + + ImageEmbedBuilderOnRemovedCallback get onImageRemovedCallback { + return _onImageRemovedCallback ?? + QuillEditorImageEmbedConfig.defaultOnImageRemovedCallback; + } + + /// [shouldRemoveImageCallback] is a callback + /// function that is invoked when the + /// user attempts to remove an image from the editor. It allows you to control + /// whether the image should be removed based on your custom logic. + /// + /// Example of [shouldRemoveImageCallback] customization: + /// ```dart + /// shouldRemoveImageFromEditor: (imageFile) async { + /// // Show a confirmation dialog before removing the image + /// final isShouldRemove = await showYesCancelDialog( + /// context: context, + /// options: const YesOrCancelDialogOptions( + /// title: 'Deleting an image', + /// message: 'Are you sure you want' ' to delete this + /// image from the editor?', + /// ), + /// ); + /// + /// // Return `true` to allow image removal if the user confirms, otherwise + /// `false` + /// return isShouldRemove; + /// } + /// ``` + /// + final ImageEmbedBuilderWillRemoveCallback? shouldRemoveImageCallback; + + /// Allows to override the default handling and fallback to the default if `null` was returned. + /// + /// Example of [imageProviderBuilder] customization: + /// ```dart + /// imageProviderBuilder: (imageUrl) async { + /// if (imageUrl.startsWith('assets/')) { + /// // Supports Image assets + /// return AssetImage(imageUrl); + /// } + /// if (imageUrl.startsWith('http')) { + /// // Use https://pub.dev/packages/cached_network_image + /// // for network images to cache them. + /// return CachedNetworkImageProvider(imageUrl); + /// } + /// + /// // Return null to fallback to default handling + /// return null; + /// } + /// ``` + /// + final ImageEmbedBuilderProviderBuilder? imageProviderBuilder; + + /// [imageErrorWidgetBuilder] if you want to show a custom widget based on the + /// exception that happen while loading the image, if it network image or + /// local one, and it will get called on all the images even in the photo + /// preview widget and not just in the quill editor + /// by default the default error from flutter framework will thrown + /// + final ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder; + + /// What should happen when the image is pressed? + /// + /// By default will show `ImageOptionsMenu` dialog. If you want to handle what happens + /// to the image when it's clicked, you can pass a callback to this property. + final void Function(String imageSource)? onImageClicked; + + static ImageEmbedBuilderOnRemovedCallback get defaultOnImageRemovedCallback { + return (imageUrl) async { + if (kIsWeb) { + return; + } + + final mobile = isMobileApp; + // If the platform is not mobile, return void; + // Since the mobile OS gives us a copy of the image + + // Note: We should remove the image on Flutter web + // since the behavior is similar to how it is on mobile, + // but since this builder is not for web, we will ignore it + if (!mobile) { + return; + } + + // On mobile OS (Android, iOS), the system will not give us + // direct access to the image; instead, + // it will give us the image + // in the temp directory of the application. So, we want to + // remove it when we no longer need it. + + // but on desktop we don't want to touch user files + // especially on macOS, where we can't even delete + // it without + // permission + + final dartIoImageFile = File(imageUrl); + + final isFileExists = await dartIoImageFile.exists(); + if (isFileExists) { + await dartIoImageFile.delete(); + } + }; + } + + QuillEditorImageEmbedConfig copyWith({ + ImageEmbedBuilderOnRemovedCallback? onImageRemovedCallback, + ImageEmbedBuilderWillRemoveCallback? shouldRemoveImageCallback, + ImageEmbedBuilderProviderBuilder? imageProviderBuilder, + ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder, + bool? forceUseMobileOptionMenuForImageClick, + }) { + return QuillEditorImageEmbedConfig( + onImageRemovedCallback: onImageRemovedCallback ?? _onImageRemovedCallback, + shouldRemoveImageCallback: + shouldRemoveImageCallback ?? this.shouldRemoveImageCallback, + imageProviderBuilder: imageProviderBuilder ?? this.imageProviderBuilder, + imageErrorWidgetBuilder: + imageErrorWidgetBuilder ?? this.imageErrorWidgetBuilder, + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/config/image_web_config.dart b/lib/Screens/myTemplates/src/editor/image/config/image_web_config.dart new file mode 100644 index 0000000..b1b60b0 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/config/image_web_config.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart' show BoxConstraints; +import 'package:meta/meta.dart' show immutable; + +@immutable +class QuillEditorWebImageEmbedConfig { + const QuillEditorWebImageEmbedConfig({ + this.constraints, + }); + + final BoxConstraints? constraints; +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_embed.dart b/lib/Screens/myTemplates/src/editor/image/image_embed.dart new file mode 100644 index 0000000..eb2c497 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_embed.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + +import '../../common/utils/element_utils/element_utils.dart'; +import 'config/image_config.dart'; +import 'image_menu.dart'; +import 'widgets/image.dart'; + +class QuillEditorImageEmbedBuilder extends EmbedBuilder { + QuillEditorImageEmbedBuilder({ + required this.config, + }); + final QuillEditorImageEmbedConfig config; + + @override + String get key => BlockEmbed.imageType; + + @override + bool get expanded => false; + + @override + Widget build( + BuildContext context, + EmbedContext embedContext, + ) { + final imageSource = standardizeImageUrl(embedContext.node.value.data); + final ((imageSize), margin, alignment) = getElementAttributes( + embedContext.node, + context, + ); + + final width = imageSize.width; + final height = imageSize.height; + + final imageWidget = getImageWidgetByImageSource( + context: context, + imageSource, + imageProviderBuilder: config.imageProviderBuilder, + imageErrorWidgetBuilder: config.imageErrorWidgetBuilder, + alignment: alignment, + height: height, + width: width, + ); + + return GestureDetector( + onTap: () { + final onImageClicked = config.onImageClicked; + if (onImageClicked != null) { + onImageClicked(imageSource); + return; + } + showDialog( + context: context, + builder: (_) => ImageOptionsMenu( + controller: embedContext.controller, + config: config, + imageSource: imageSource, + imageSize: imageSize, + readOnly: embedContext.readOnly, + imageProvider: imageWidget.image, + ), + ); + }, + child: Builder( + builder: (context) { + if (margin != null) { + return Padding( + padding: EdgeInsets.all(margin), + child: imageWidget, + ); + } + return imageWidget; + }, + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_embed_types.dart b/lib/Screens/myTemplates/src/editor/image/image_embed_types.dart new file mode 100644 index 0000000..0ca6369 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_embed_types.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart' + show ImageErrorWidgetBuilder, ImageProvider; +import 'package:flutter/widgets.dart' show BuildContext; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart' show immutable; + +/// When request picking an image, for example when the image button toolbar +/// clicked, it should be null in case the user didn't choose any image or +/// any other reasons, and it should be the image file path as string that is +/// exists in case the user picked the image successfully +/// +/// by default we already have a default implementation that show a dialog +/// request the source for picking the image, from gallery, link or camera +typedef OnRequestPickImage = Future Function( + BuildContext context, +); + +/// A callback will called when inserting a image in the editor +/// it have the logic that will insert the image block using the controller +typedef OnImageInsertCallback = Future Function( + String image, + QuillController controller, +); + +/// When a new image picked this callback will called and you might want to +/// do some logic depending on your use case +typedef OnImageInsertedCallback = Future Function( + String image, +); + +enum InsertImageSource { + gallery, + camera, + link, +} + +/// Configurations for dealing with images, on insert a image +/// on request picking a image +@immutable +class QuillToolbarImageConfig { + const QuillToolbarImageConfig({ + this.onRequestPickImage, + this.onImageInsertedCallback, + this.onImageInsertCallback, + }); + + final OnRequestPickImage? onRequestPickImage; + + final OnImageInsertedCallback? onImageInsertedCallback; + + final OnImageInsertCallback? onImageInsertCallback; +} + +typedef ImageEmbedBuilderWillRemoveCallback = Future Function( + String imageUrl, +); + +typedef ImageEmbedBuilderOnRemovedCallback = Future Function( + String imageUrl, +); + +typedef ImageEmbedBuilderProviderBuilder = ImageProvider? Function( + BuildContext context, + String imageUrl, +); + +typedef ImageEmbedBuilderErrorWidgetBuilder = ImageErrorWidgetBuilder; diff --git a/lib/Screens/myTemplates/src/editor/image/image_load_utils.dart b/lib/Screens/myTemplates/src/editor/image/image_load_utils.dart new file mode 100644 index 0000000..a30af95 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_load_utils.dart @@ -0,0 +1,36 @@ +import 'dart:async' show Completer; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +class ImageLoader { + static ImageLoader _instance = ImageLoader(); + + static ImageLoader get instance => _instance; + + /// Allows overriding the instance for testing + @visibleForTesting + static set instance(ImageLoader newInstance) => _instance = newInstance; + + // TODO(performance): This will load the image again. In case +// this is a network image, then this will be inefficient. + Future loadImageBytesFromImageProvider({ + required ImageProvider imageProvider, + }) async { + final stream = imageProvider.resolve(ImageConfiguration.empty); + final completer = Completer(); + + ImageStreamListener? listener; + listener = ImageStreamListener((info, _) { + completer.complete(info.image); + stream.removeListener(listener!); + }); + + stream.addListener(listener); + + final image = await completer.future; + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + return byteData?.buffer.asUint8List(); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_menu.dart b/lib/Screens/myTemplates/src/editor/image/image_menu.dart new file mode 100644 index 0000000..814f79f --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_menu.dart @@ -0,0 +1,246 @@ +import 'package:flutter/cupertino.dart' show showCupertinoModalPopup; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' + show ImageUrl, QuillController, StyleAttribute, getEmbedNode; +import 'package:flutter_quill/internal.dart'; +import 'package:path/path.dart' as p; +import 'package:url_launcher/url_launcher.dart'; + +import '../../common/utils/element_utils/element_utils.dart'; +import '../../common/utils/string.dart'; +import 'config/image_config.dart'; +import 'image_load_utils.dart'; +import 'image_save_utils.dart'; +import 'widgets/image.dart' show ImageTapWrapper, getImageStyleString; +import 'widgets/image_resizer.dart' show ImageResizer; + +class ImageOptionsMenu extends StatelessWidget { + const ImageOptionsMenu({ + required this.controller, + required this.config, + required this.imageSource, + required this.imageSize, + required this.readOnly, + required this.imageProvider, + this.prefersGallerySave = true, + super.key, + }); + + final QuillController controller; + final QuillEditorImageEmbedConfig config; + final String imageSource; + final ElementSize imageSize; + final bool readOnly; + final ImageProvider imageProvider; + + // TODO(quill_native_bridge): Update this doc comment once saveImageToGallery() + // is supported on Windows too (will be applicable like macOS). See https://pub.dev/packages/quill_native_bridge#-features + /// Determines if the image should be saved to the gallery instead of using the + /// system file save dialog for platforms that support both. + /// + /// Currently, the only platform where this applies is macOS. + /// + /// This is silently ignored on platforms that only support gallery save (Android and iOS) + /// or only image save. + /// + /// For more details, refer to [quill_native_bridge Saving images](https://pub.dev/packages/quill_native_bridge#-saving-images). + final bool prefersGallerySave; + + @override + Widget build(BuildContext context) { + final materialTheme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(50, 0, 50, 0), + child: SimpleDialog( + title: Text(context.loc.image), + children: [ + if (!readOnly) + ListTile( + title: Text(context.loc.resize), + leading: const Icon(Icons.settings_outlined), + onTap: () { + Navigator.pop(context); + showCupertinoModalPopup( + context: context, + builder: (modalContext) { + final screenSize = MediaQuery.sizeOf(modalContext); + return ImageResizer( + onImageResize: (width, height) { + final res = getEmbedNode( + controller, + controller.selection.start, + ); + + final attr = replaceStyleStringWithSize( + getImageStyleString(controller), + width: width, + height: height, + ); + controller + ..skipRequestKeyboard = true + ..formatText( + res.offset, + 1, + StyleAttribute(attr), + ); + }, + imageWidth: imageSize.width, + imageHeight: imageSize.height, + maxWidth: screenSize.width, + maxHeight: screenSize.height, + ); + }, + ); + }, + ), + ListTile( + leading: const Icon(Icons.copy_all_outlined), + title: Text(context.loc.copy), + onTap: () async { + Navigator.of(context).pop(); + controller.copiedImageUrl = ImageUrl( + imageSource, + getImageStyleString(controller), + ); + + final imageBytes = await ImageLoader.instance + .loadImageBytesFromImageProvider( + imageProvider: imageProvider); + if (imageBytes != null) { + await ClipboardServiceProvider.instance.copyImage(imageBytes); + } + }, + ), + if (!readOnly) + ListTile( + leading: Icon( + Icons.delete_forever_outlined, + color: materialTheme.colorScheme.error, + ), + title: Text(context.loc.remove), + onTap: () async { + Navigator.of(context).pop(); + + // Call the remove check callback if set + if (await config.shouldRemoveImageCallback?.call(imageSource) == + false) { + return; + } + + final offset = getEmbedNode( + controller, + controller.selection.start, + ).offset; + controller.replaceText( + offset, + 1, + '', + TextSelection.collapsed(offset: offset), + ); + // Call the post remove callback if set + await config.onImageRemovedCallback.call(imageSource); + }, + ), + ListTile( + leading: const Icon(Icons.save), + title: Text(context.loc.save), + onTap: () async { + final messenger = ScaffoldMessenger.of(context); + final localizations = context.loc; + Navigator.of(context).pop(); + + SaveImageResult? result; + try { + result = await ImageSaver.instance.saveImage( + imageUrl: imageSource, + imageProvider: imageProvider, + prefersGallerySave: prefersGallerySave, + ); + } on GalleryImageSaveAccessDeniedException { + messenger.showSnackBar(SnackBar( + content: Text( + localizations.saveImagePermissionDenied, + ))); + return; + } + + if (result == null) { + messenger.showSnackBar(SnackBar( + content: Text( + localizations.errorUnexpectedSavingImage, + ))); + return; + } + + if (kIsWeb) { + messenger.showSnackBar(SnackBar( + content: Text(localizations.successImageDownloaded))); + return; + } + + if (result.isGallerySave) { + messenger.showSnackBar(SnackBar( + content: Text(localizations.successImageSavedGallery), + action: SnackBarAction( + label: localizations.openGallery, + onPressed: () => + QuillNativeProvider.instance.openGalleryApp(), + ), + )); + return; + } + + if (isDesktopApp) { + final imageFilePath = result.imageFilePath; + if (imageFilePath == null) { + // User canceled the system save dialog. + return; + } + + messenger.showSnackBar( + SnackBar( + content: Text(localizations.successImageSaved), + // On macOS the app only has access to the picked file from the system save + // dialog and not the directory where it was saved. + // Opening the directory of that file requires entitlements on macOS + // See https://pub.dev/packages/url_launcher#macos-file-access-configuration + // Open the saved image file instead of the directory + action: defaultTargetPlatform == TargetPlatform.macOS + ? SnackBarAction( + label: localizations.openFile, + onPressed: () => launchUrl(Uri.file(imageFilePath)), + ) + : SnackBarAction( + label: localizations.openFileLocation, + onPressed: () => launchUrl( + Uri.directory(p.dirname(imageFilePath))), + ), + ), + ); + + return; + } + + throw StateError( + 'Image save result is not handled on $defaultTargetPlatform'); + }, + ), + ListTile( + leading: const Icon(Icons.zoom_in), + title: Text(context.loc.zoom), + onTap: () => Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ImageTapWrapper( + imageUrl: imageSource, + config: config, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_save_utils.dart b/lib/Screens/myTemplates/src/editor/image/image_save_utils.dart new file mode 100644 index 0000000..5203c99 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_save_utils.dart @@ -0,0 +1,254 @@ +@internal +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_quill/internal.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; + +import 'image_load_utils.dart'; + +const defaultImageFileExtension = 'png'; + +// The [imageSourcePath] could be file, asset path or HTTP image URL. +String extractImageFileExtensionFromImageSource(String? imageSourcePath) { + if (imageSourcePath == null || imageSourcePath.isEmpty) { + return defaultImageFileExtension; + } + + if (!imageSourcePath.contains('.')) { + return defaultImageFileExtension; + } + + return p.extension(imageSourcePath).replaceFirst('.', ''); +} + +// The [imageSourcePath] could be file, asset path or HTTP image URL. +String? extractImageNameFromImageSource(String? imageSourcePath) { + if (imageSourcePath == null || imageSourcePath.isEmpty) { + return null; + } + final uri = Uri.parse(imageSourcePath); + final pathWithoutQuery = uri.path; + + final imageName = p.basenameWithoutExtension(pathWithoutQuery); + if (imageName.isEmpty) { + return null; + } + return imageName; +} + +class SaveImageResult { + const SaveImageResult({ + required this.imageFilePath, + required this.isGallerySave, + }); + + /// Returns `null` on web platforms, if [isGallerySave] is `true` + /// or in case the user cancels the save operation on desktop platforms. + final String? imageFilePath; + + final bool isGallerySave; + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other is! SaveImageResult) return false; + return other.imageFilePath == imageFilePath && + other.isGallerySave == isGallerySave; + } + + @override + int get hashCode => Object.hash(imageFilePath, isGallerySave); + + @override + String toString() => + 'SaveImageResult(imageFilePath: $imageFilePath, isGallerySave: $isGallerySave)'; +} + +const String defaultImageFileNamePrefix = 'IMG'; + +String getDefaultImageFileName({required bool isGallerySave}) { + if (kIsWeb) { + // The browser handles name conflicts. + return defaultImageFileNamePrefix; + } + if (isGallerySave) { + // The gallery app handles name conflicts. + return defaultImageFileNamePrefix; + } + if (defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows) { + // Windows and macOS system native save dialog prompts the user to confirm file overwrite. + return defaultImageFileNamePrefix; + } + final uniqueFileName = + '${defaultImageFileNamePrefix}_${DateTime.now().toIso8601String()}'; + if (defaultTargetPlatform == TargetPlatform.linux) { + // IMPORTANT: On Linux, it depends on the desktop environment + // and name conflicts may not be handled. Always provide a unique image file name. + return uniqueFileName; + } + + return uniqueFileName; +} + +Future shouldSaveToGallery({required bool prefersGallerySave}) async { + final supportsGallerySave = await QuillNativeProvider.instance + .isSupported(QuillNativeBridgeFeature.saveImageToGallery); + if (!supportsGallerySave) { + return false; + } + final supportsImageSave = await QuillNativeProvider.instance + .isSupported(QuillNativeBridgeFeature.saveImage); + if (!supportsImageSave) { + return true; + } + + return supportsGallerySave && prefersGallerySave; +} + +/// Thrown when the gallery image save operation is denied +/// due to insufficient or denied permissions. +class GalleryImageSaveAccessDeniedException implements Exception { + GalleryImageSaveAccessDeniedException([this.message]); + + final String? message; + + @override + String toString() => + message ?? + 'Permission to save the image to the gallery was denied or insufficient.'; +} + +class ImageSaver { + ImageSaver._(); + + static ImageSaver _instance = ImageSaver._(); + + static ImageSaver get instance => _instance; + + /// Allows overriding the instance for testing + @visibleForTesting + static set instance(ImageSaver newInstance) => _instance = newInstance; + + /// Saves an image to the user's device based on the platform: + /// + /// - **Web**: Downloads the image using the browser's download functionality. + /// - **Desktop**: Prompts the user to choose a location for the image using + /// native save dialog, defaulting to the user's `Pictures` directory. Or + /// saves the image to the gallery in case [prefersGallerySave] is `true` and + // TODO(quill_native_bridge): Update this doc comment once saveImageToGallery() + // is supported on Windows too (will be applicable like macOS). See https://pub.dev/packages/quill_native_bridge#-features + /// the gallery is supported (currently only macOS is applicable). + /// - **Mobile**: Saves the image to the gallery, requesting permission if needed. + /// + /// The [imageUrl] could be file or network image URL and is used to extract + /// image file extension and the image name. + /// + /// The [imageProvider] is used to load the image bytes from using [ImageLoader]. + /// + /// Returns `null` on failure. + /// + /// Throws [GalleryImageSaveAccessDeniedException] in case permission was denied or insuffeicnet. + Future saveImage({ + required String imageUrl, + required ImageProvider imageProvider, + required bool prefersGallerySave, + }) async { + assert(() { + if (imageUrl.isEmpty) { + throw ArgumentError.value(imageUrl, 'imageUrl', 'cannot be empty'); + } + return true; + }()); + + final imageFileExtension = + extractImageFileExtensionFromImageSource(imageUrl); + final imageName = extractImageNameFromImageSource(imageUrl); + + final imageBytes = await ImageLoader.instance + .loadImageBytesFromImageProvider(imageProvider: imageProvider); + if (imageBytes == null || imageBytes.isEmpty) { + return null; + } + + if (kIsWeb) { + await QuillNativeProvider.instance.saveImage( + imageBytes, + options: ImageSaveOptions( + name: imageName ?? getDefaultImageFileName(isGallerySave: false), + fileExtension: imageFileExtension), + ); + return const SaveImageResult( + imageFilePath: null, + isGallerySave: false, + ); + } + + if (await shouldSaveToGallery(prefersGallerySave: prefersGallerySave)) { + try { + await QuillNativeProvider.instance.saveImageToGallery( + imageBytes, + options: GalleryImageSaveOptions( + name: imageName ?? getDefaultImageFileName(isGallerySave: true), + fileExtension: imageFileExtension, + // Specifying the album name requires read-write permission + // on iOS and macOS on all versions. Pass null to request add-only on + // supported versions (previous versions still use read-write). + albumName: null, + ), + ); + + return const SaveImageResult( + imageFilePath: null, + isGallerySave: true, + ); + } on PlatformException catch (e) { + // TODO(save-image): Part of https://github.com/FlutterQuill/quill-native-bridge/issues/2 + + // Permission request is required only on iOS, macOS and Android API 28 and earlier. + if (e.code == 'PERMISSION_DENIED') { + // macOS imposes security restrictions when running the app + // on sources other than Xcode or the macOS terminal, such as Android Studio or VS Code. + // This is not an issue in production. Throwing [GalleryImageSaveAccessDeniedException] will indicate + // that the user denied the permission, even though it will always deny the permission even if granted. + // Make sure we don't handle that error (it has details) during development to avoid confusion. + // For more details, see https://github.com/flutter/flutter/issues/134191#issuecomment-2506248266 + // and https://pub.dev/packages/quill_native_bridge#-saving-images-to-the-gallery + + final possiblePermissionIssueDuringDevelopmentOnMacOS = + kDebugMode && defaultTargetPlatform == TargetPlatform.macOS; + if (possiblePermissionIssueDuringDevelopmentOnMacOS) { + rethrow; + } + + throw GalleryImageSaveAccessDeniedException(e.toString()); + } + rethrow; + } + } + + if (await QuillNativeProvider.instance + .isSupported(QuillNativeBridgeFeature.saveImage)) { + assert(!isMobileApp, + 'Mobile platforms support saving images to the gallery only'); + + final result = await QuillNativeProvider.instance.saveImage( + imageBytes, + options: ImageSaveOptions( + name: imageName ?? getDefaultImageFileName(isGallerySave: false), + fileExtension: imageFileExtension, + ), + ); + return SaveImageResult( + imageFilePath: result.filePath, + isGallerySave: false, + ); + } + + throw StateError('Image save is not handled on $defaultTargetPlatform'); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_web_embed.dart b/lib/Screens/myTemplates/src/editor/image/image_web_embed.dart new file mode 100644 index 0000000..1b21022 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_web_embed.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/widgets.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + +import '../../common/utils/element_utils/element_web_utils.dart'; +import '../../common/utils/utils.dart'; +import '../../common/utils/web/web.dart'; +import 'config/image_web_config.dart'; + +class QuillEditorWebImageEmbedBuilder extends EmbedBuilder { + const QuillEditorWebImageEmbedBuilder({ + required this.config, + }); + + final QuillEditorWebImageEmbedConfig config; + + @override + String get key => BlockEmbed.imageType; + + @override + bool get expanded => false; + + @override + Widget build( + BuildContext context, + EmbedContext embedContext, + ) { + assert(kIsWeb, 'ImageEmbedBuilderWeb is only for web platform'); + + final (height, width, margin, alignment) = + getWebElementAttributes(embedContext.node); + + var imageSource = embedContext.node.value.data.toString(); + + // This logic make sure if the image is imageBase64 then + // it make sure if the pattern is like + // data:image/png;base64, [base64 encoded image string here] + // if not then it will add the data:image/png;base64, at the first + if (isImageBase64(imageSource)) { + // Sometimes the image base 64 for some reasons + // doesn't displayed with the 'data:image/png;base64' + if (!(imageSource.startsWith('data:image/') && + imageSource.contains('base64'))) { + imageSource = 'data:image/png;base64, $imageSource'; + } + } + + createHtmlImageElement( + src: imageSource, + alignSelf: alignment, + width: width, + height: height, + margin: margin, + ); + + return ConstrainedBox( + constraints: + config.constraints ?? BoxConstraints.loose(const Size(200, 200)), + child: HtmlElementView( + viewType: imageSource, + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/widgets/image.dart b/lib/Screens/myTemplates/src/editor/image/widgets/image.dart new file mode 100644 index 0000000..a1b0dc0 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/widgets/image.dart @@ -0,0 +1,186 @@ +import 'dart:convert' show base64; +import 'dart:io' show File; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:photo_view/photo_view.dart'; + +import '../../../common/utils/utils.dart'; +import '../config/image_config.dart'; +import '../image_embed_types.dart'; + +String getImageStyleString(QuillController controller) { + final String? s = controller + .getAllSelectionStyles() + .firstWhere((s) => s.attributes.containsKey(Attribute.style.key), + orElse: Style.new) + .attributes[Attribute.style.key] + ?.value; + return s ?? ''; +} + +/// [imageProviderBuilder] To override the return value pass value to it +/// [imageSource] The source of the image in the quill delta json document +/// It could be http, file, network, asset, or base 64 image +ImageProvider getImageProviderByImageSource( + String imageSource, { + required ImageEmbedBuilderProviderBuilder? imageProviderBuilder, + required BuildContext context, +}) { + if (imageProviderBuilder != null) { + final imageProvider = imageProviderBuilder(context, imageSource); + if (imageProvider != null) { + return imageProvider; + } + } + + if (isImageBase64(imageSource)) { + return MemoryImage(base64.decode(imageSource)); + } + + if (isHttpUrl(imageSource)) { + return NetworkImage(imageSource); + } + + // File image + if (kIsWeb) { + return NetworkImage(imageSource); + } + return FileImage(File(imageSource)); +} + +Image getImageWidgetByImageSource( + String imageSource, { + required BuildContext context, + required ImageEmbedBuilderProviderBuilder? imageProviderBuilder, + required ImageErrorWidgetBuilder? imageErrorWidgetBuilder, + double? width, + double? height, + AlignmentGeometry alignment = Alignment.center, +}) { + return Image( + image: getImageProviderByImageSource( + context: context, + imageSource, + imageProviderBuilder: imageProviderBuilder, + ), + width: width, + height: height, + alignment: alignment, + errorBuilder: imageErrorWidgetBuilder, + ); +} + +String standardizeImageUrl(String url) { + if (url.contains('base64')) { + return url.split(',')[1]; + } + return url; +} + +const List _imageFileExtensions = [ + '.jpeg', + '.png', + '.jpg', + '.gif', + '.webp', + '.tif', + '.heic' +]; + +/// This is a bug of Gallery Saver Package. +/// It can not save image that's filename does not end with it's file extension +/// like below. +// "https://firebasestorage.googleapis.com/v0/b/eventat-4ba96.appspot.com/o/2019-Metrology-Events.jpg?alt=media&token=bfc47032-5173-4b3f-86bb-9659f46b362a" +/// If imageUrl does not end with it's file extension, +/// file extension is added to image url for saving. +String appendFileExtensionToImageUrl(String url) { + final endsWithImageFileExtension = _imageFileExtensions + .firstWhere((s) => url.toLowerCase().endsWith(s), orElse: () => ''); + if (endsWithImageFileExtension.isNotEmpty) { + return url; + } + + final imageFileExtension = _imageFileExtensions + .firstWhere((s) => url.toLowerCase().contains(s), orElse: () => ''); + + return url + imageFileExtension; +} + +class ImageTapWrapper extends StatelessWidget { + const ImageTapWrapper({ + required this.imageUrl, + required this.config, + super.key, + }); + + final String imageUrl; + final QuillEditorImageEmbedConfig config; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + constraints: BoxConstraints.expand( + height: MediaQuery.sizeOf(context).height, + ), + child: Stack( + children: [ + PhotoView( + imageProvider: getImageProviderByImageSource( + context: context, + imageUrl, + imageProviderBuilder: config.imageProviderBuilder, + ), + errorBuilder: config.imageErrorWidgetBuilder, + loadingBuilder: (context, event) { + return Container( + color: Colors.black, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + }, + ), + Positioned( + right: 10, + top: MediaQuery.paddingOf(context).top + 10.0, + child: InkWell( + onTap: () { + Navigator.pop(context); + }, + child: Stack( + children: [ + Opacity( + opacity: 0.2, + child: Container( + height: 30, + width: 30, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.black87, + ), + ), + ), + Positioned( + top: 0, + bottom: 0, + left: 0, + right: 0, + child: Icon( + Icons.close, + color: Colors.grey[400], + size: 28, + ), + ) + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/widgets/image_resizer.dart b/lib/Screens/myTemplates/src/editor/image/widgets/image_resizer.dart new file mode 100644 index 0000000..5f481f5 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/widgets/image_resizer.dart @@ -0,0 +1,126 @@ +import 'package:flutter/cupertino.dart' + show CupertinoActionSheet, CupertinoActionSheetAction; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart' show SchedulerBinding; +import 'package:flutter_quill/internal.dart'; + +class ImageResizer extends StatefulWidget { + const ImageResizer({ + required this.imageWidth, + required this.imageHeight, + required this.maxWidth, + required this.maxHeight, + required this.onImageResize, + super.key, + }); + + final double? imageWidth; + final double? imageHeight; + final double maxWidth; + final double maxHeight; + final Function(double width, double height) onImageResize; + + @override + ImageResizerState createState() => ImageResizerState(); +} + +class ImageResizerState extends State { + late double _width; + late double _height; + + @override + void initState() { + super.initState(); + _width = widget.imageWidth ?? widget.maxWidth; + _height = widget.imageHeight ?? widget.maxHeight; + } + + @override + Widget build(BuildContext context) { + if (Theme.of(context).isCupertino) { + return _showCupertinoMenu(); + } + return _showMaterialMenu(); + } + + Widget _showMaterialMenu() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _widthSlider(), + _heightSlider(), + ], + ); + } + + Widget _showCupertinoMenu() { + return CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () {}, + child: _widthSlider(), + ), + CupertinoActionSheetAction( + onPressed: () {}, + child: _heightSlider(), + ) + ], + ); + } + + Widget _slider({ + required bool isWidth, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + child: Slider.adaptive( + value: isWidth ? _width : _height, + max: isWidth ? widget.maxWidth : widget.maxHeight, + divisions: 1000, + // Might need to be changed + label: isWidth ? context.loc.width : context.loc.height, + onChanged: (val) { + setState(() { + onChanged(val); + _resizeImage(); + }); + }, + ), + ), + ); + } + + Widget _heightSlider() { + return _slider( + isWidth: false, + onChanged: (value) { + _height = value; + }, + ); + } + + Widget _widthSlider() { + return _slider( + isWidth: true, + onChanged: (value) { + _width = value; + }, + ); + } + + bool _scheduled = false; + + void _resizeImage() { + if (_scheduled) { + return; + } + + _scheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + widget.onImageResize(_width, _height); + _scheduled = false; + }); + } +} diff --git a/lib/Screens/myTemplates/src/editor/video/config/video_config.dart b/lib/Screens/myTemplates/src/editor/video/config/video_config.dart new file mode 100644 index 0000000..876efec --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/config/video_config.dart @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart' show GlobalKey, Widget; +import 'package:meta/meta.dart' show experimental, immutable; + +@immutable +class QuillEditorVideoEmbedConfig { + const QuillEditorVideoEmbedConfig({ + this.onVideoInit, + this.customVideoBuilder, + }); + + /// [onVideoInit] is a callback function that gets triggered when + /// a video is initialized. + /// You can use this to perform actions or setup configurations related + /// to video embedding. + /// + /// + /// Example usage: + /// ```dart + /// onVideoInit: (videoContainerKey) { + /// // Custom video initialization logic + /// }, + /// // Customize other callback functions as needed + /// ``` + final void Function(GlobalKey videoContainerKey)? onVideoInit; + + /// [customVideoBuilder] is a callback function that receives the + /// video URL and a read-only flag. This allows users to define + /// their own logic for rendering video widgets, enabling support + /// for various video platforms, such as YouTube. + /// + /// Example usage: + /// ```dart + /// customVideoBuilder: (videoUrl, readOnly) { + /// // Return `null` to fallback to defualt logic of QuillEditorVideoEmbedBuilder + /// + /// // Return a custom video widget based on the videoUrl + /// return CustomVideoWidget(videoUrl: videoUrl, readOnly: readOnly); + /// }, + /// ``` + /// + /// It's a quick solution as response to https://github.com/singerdmx/flutter-quill/issues/2284 + /// + /// **Might be removed or changed in future releases.** + @experimental + final Widget? Function(String videoUrl, bool readOnly)? customVideoBuilder; +} diff --git a/lib/Screens/myTemplates/src/editor/video/config/video_web_config.dart b/lib/Screens/myTemplates/src/editor/video/config/video_web_config.dart new file mode 100644 index 0000000..0eb6e14 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/config/video_web_config.dart @@ -0,0 +1,6 @@ +import 'package:meta/meta.dart' show immutable; + +@immutable +class QuillEditorWebVideoEmbedConfig { + const QuillEditorWebVideoEmbedConfig(); +} diff --git a/lib/Screens/myTemplates/src/editor/video/video_embed.dart b/lib/Screens/myTemplates/src/editor/video/video_embed.dart new file mode 100644 index 0000000..bc55b23 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/video_embed.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + +import '../../common/utils/element_utils/element_utils.dart'; +import 'config/video_config.dart'; +import 'widgets/video_app.dart'; + +class QuillEditorVideoEmbedBuilder extends EmbedBuilder { + const QuillEditorVideoEmbedBuilder({ + required this.config, + }); + + final QuillEditorVideoEmbedConfig config; + + @override + String get key => BlockEmbed.videoType; + + @override + bool get expanded => false; + + @override + Widget build( + BuildContext context, + EmbedContext embedContext, + ) { + final videoUrl = embedContext.node.value.data; + + final customVideoBuilder = config.customVideoBuilder; + if (customVideoBuilder != null) { + final videoWidget = customVideoBuilder(videoUrl, embedContext.readOnly); + if (videoWidget != null) { + return videoWidget; + } + } + + final ((elementSize), margin, alignment) = getElementAttributes( + embedContext.node, + context, + ); + + final width = elementSize.width; + final height = elementSize.height; + return Container( + width: width, + height: height, + margin: EdgeInsets.all(margin ?? 0.0), + alignment: alignment, + child: VideoApp( + videoUrl: videoUrl, + readOnly: embedContext.readOnly, + onVideoInit: config.onVideoInit, + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/video/video_web_embed.dart b/lib/Screens/myTemplates/src/editor/video/video_web_embed.dart new file mode 100644 index 0000000..f38ceae --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/video_web_embed.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + +import '../../common/utils/element_utils/element_web_utils.dart'; +import '../../common/utils/utils.dart'; +import '../../common/utils/web/web.dart'; +import 'config/video_web_config.dart'; +import 'youtube_video_url.dart'; + +class QuillEditorWebVideoEmbedBuilder extends EmbedBuilder { + const QuillEditorWebVideoEmbedBuilder({ + required this.config, + }); + + final QuillEditorWebVideoEmbedConfig config; + + @override + String get key => BlockEmbed.videoType; + + @override + bool get expanded => false; + + @override + Widget build( + BuildContext context, + EmbedContext embedContext, + ) { + var videoUrl = embedContext.node.value.data; + if (isYouTubeUrl(videoUrl)) { + // ignore: deprecated_member_use_from_same_package + final youtubeID = convertVideoUrlToId(videoUrl); + if (youtubeID != null) { + videoUrl = 'https://www.youtube.com/embed/$youtubeID'; + } + } + + final (height, width, margin, alignment) = + getWebElementAttributes(embedContext.node); + + createHtmlIFrameElement( + src: videoUrl, + width: width, + height: height, + margin: margin, + alignSelf: alignment, + ); + + return SizedBox( + height: 500, + child: HtmlElementView( + viewType: videoUrl, + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/video/widgets/video_app.dart b/lib/Screens/myTemplates/src/editor/video/widgets/video_app.dart new file mode 100644 index 0000000..6e86cb4 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/widgets/video_app.dart @@ -0,0 +1,122 @@ +import 'dart:io' show File; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../common/utils/utils.dart'; + +/// Widget for playing back video +/// Refer to https://github.com/flutter/plugins/tree/master/packages/video_player/video_player +class VideoApp extends StatefulWidget { + const VideoApp({ + required this.videoUrl, + required this.readOnly, + super.key, + this.onVideoInit, + }); + + final String videoUrl; + final bool readOnly; + final void Function(GlobalKey videoContainerKey)? onVideoInit; + + @override + VideoAppState createState() => VideoAppState(); +} + +class VideoAppState extends State { + late VideoPlayerController _controller; + GlobalKey videoContainerKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + _controller = isHttpUrl(widget.videoUrl) + ? VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)) + : VideoPlayerController.file(File(widget.videoUrl)) + ..initialize().then((_) { + // Ensure the first frame is shown after the video is initialized, + // even before the play button has been pressed. + setState(() {}); + if (widget.onVideoInit != null) { + widget.onVideoInit?.call(videoContainerKey); + } + }).catchError((error) { + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + final defaultStyles = DefaultStyles.getInstance(context); + if (_controller.value.hasError) { + if (widget.readOnly) { + return RichText( + text: TextSpan( + text: widget.videoUrl, + style: defaultStyles.link, + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl( + Uri.parse(widget.videoUrl), + ), + ), + ); + } + + return RichText( + text: TextSpan( + text: widget.videoUrl, + style: defaultStyles.link, + ), + ); + } else if (!_controller.value.isInitialized) { + return VideoProgressIndicator( + _controller, + allowScrubbing: true, + colors: const VideoProgressColors(playedColor: Colors.blue), + ); + } + + return Container( + key: videoContainerKey, + child: InkWell( + onTap: () { + setState(() { + _controller.value.isPlaying + ? _controller.pause() + : _controller.play(); + }); + }, + child: Stack( + alignment: Alignment.center, + children: [ + Center( + child: AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + )), + _controller.value.isPlaying + ? const SizedBox.shrink() + : Container( + color: const Color(0xfff5f5f5), + child: const Icon( + Icons.play_arrow, + size: 60, + color: Colors.blueGrey, + ), + ) + ], + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/Screens/myTemplates/src/editor/video/youtube_video_url.dart b/lib/Screens/myTemplates/src/editor/video/youtube_video_url.dart new file mode 100644 index 0000000..379f9fd --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/youtube_video_url.dart @@ -0,0 +1,32 @@ +import 'package:meta/meta.dart'; + +/// Function copied from https://github.com/sarbagyastha/youtube_player_flutter/blob/f8e1e79991066bcc70f0a7c93941ca0d54b7370e/packages/youtube_player_flutter/lib/src/player/youtube_player.dart#L154 +/// and is not written as part of this project. +/// +/// Used as quick response for https://github.com/singerdmx/flutter-quill/issues/2284 +@experimental +@internal +@Deprecated( + 'Will be removed in future releases, for now included as quick response to https://github.com/singerdmx/flutter-quill/issues/2284', +) +String? convertVideoUrlToId(String url, {bool trimWhitespaces = true}) { + if (!url.contains('http') && (url.length == 11)) return url; + if (trimWhitespaces) url = url.trim(); + + for (final exp in [ + RegExp( + r'^https:\/\/(?:www\.|m\.)?youtube\.com\/watch\?v=([_\-a-zA-Z0-9]{11}).*$'), + RegExp( + r'^https:\/\/(?:music\.)?youtube\.com\/watch\?v=([_\-a-zA-Z0-9]{11}).*$'), + RegExp( + r'^https:\/\/(?:www\.|m\.)?youtube\.com\/shorts\/([_\-a-zA-Z0-9]{11}).*$'), + RegExp( + r'^https:\/\/(?:www\.|m\.)?youtube(?:-nocookie)?\.com\/embed\/([_\-a-zA-Z0-9]{11}).*$'), + RegExp(r'^https:\/\/youtu\.be\/([_\-a-zA-Z0-9]{11}).*$') + ]) { + final Match? match = exp.firstMatch(url); + if (match != null && match.groupCount >= 1) return match.group(1); + } + + return null; +} diff --git a/lib/Screens/myTemplates/src/flutter_quill_embeds.dart b/lib/Screens/myTemplates/src/flutter_quill_embeds.dart new file mode 100644 index 0000000..bd46aa7 --- /dev/null +++ b/lib/Screens/myTemplates/src/flutter_quill_embeds.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_quill/flutter_quill.dart'; + +import 'editor/image/config/image_config.dart'; +import 'editor/image/image_embed.dart'; +import 'editor/video/config/video_config.dart'; +import 'editor/video/config/video_web_config.dart'; +import 'editor/video/video_embed.dart'; +import 'editor/video/video_web_embed.dart'; +import 'toolbar/camera/camera_button.dart'; +import 'toolbar/camera/config/camera_config.dart'; +import 'toolbar/image/config/image_config.dart'; +import 'toolbar/image/image_button.dart'; +import 'toolbar/video/config/video_config.dart'; +import 'toolbar/video/video_button.dart'; + +abstract final class FlutterQuillEmbeds { + /// Returns a list of embed builders for [QuillEditor] + /// to provide basic support for loading images and videos. + /// + static List editorBuilders({ + QuillEditorImageEmbedConfig? imageEmbedConfig = + const QuillEditorImageEmbedConfig(), + QuillEditorVideoEmbedConfig? videoEmbedConfig = + const QuillEditorVideoEmbedConfig(), + }) { + return [ + if (imageEmbedConfig != null) + QuillEditorImageEmbedBuilder( + config: imageEmbedConfig, + ), + if (videoEmbedConfig != null) + QuillEditorVideoEmbedBuilder( + config: videoEmbedConfig, + ), + ]; + } + + /// Returns a list of embed builders specifically designed for web support + /// to load images and videos. + /// + static List editorWebBuilders({ + QuillEditorImageEmbedConfig? imageEmbedConfig = + const QuillEditorImageEmbedConfig(), + QuillEditorWebVideoEmbedConfig? videoEmbedConfig = + const QuillEditorWebVideoEmbedConfig(), + }) { + if (!kIsWeb) { + throw UnsupportedError( + 'The ${FlutterQuillEmbeds.editorWebBuilders} is for web, use ${FlutterQuillEmbeds.editorBuilders} ' + 'instead for non-web platforms', + ); + } + return [ + if (imageEmbedConfig != null) + QuillEditorImageEmbedBuilder( + config: imageEmbedConfig, + ), + if (videoEmbedConfig != null) + QuillEditorWebVideoEmbedBuilder( + config: videoEmbedConfig, + ), + ]; + } + + /// Returns a list of embed builders for [QuillEditor]. + /// + /// It will use [editorWebBuilders] for web and [editorBuilders] for non-web platforms. + static List defaultEditorBuilders() { + return kIsWeb ? editorWebBuilders() : editorBuilders(); + } + + /// Returns a list of embed button builders to support images and videos. + /// + /// Pass `null` to options of a button to not show it. + static List toolbarButtons({ + QuillToolbarImageButtonOptions? imageButtonOptions = + const QuillToolbarImageButtonOptions(), + QuillToolbarVideoButtonOptions? videoButtonOptions = + const QuillToolbarVideoButtonOptions(), + QuillToolbarCameraButtonOptions? cameraButtonOptions, + }) => + [ + if (imageButtonOptions != null) + (context, embedContext) => QuillToolbarImageButton( + controller: embedContext.controller, + options: imageButtonOptions, + // ignore: invalid_use_of_internal_member + baseOptions: embedContext.baseButtonOptions, + ), + if (videoButtonOptions != null) + (context, embedContext) => QuillToolbarVideoButton( + controller: embedContext.controller, + options: videoButtonOptions, + // ignore: invalid_use_of_internal_member + baseOptions: embedContext.baseButtonOptions, + ), + if (cameraButtonOptions != null) + (context, embedContext) => QuillToolbarCameraButton( + controller: embedContext.controller, + options: cameraButtonOptions, + // ignore: invalid_use_of_internal_member + baseOptions: embedContext.baseButtonOptions, + ), + ]; +} diff --git a/lib/Screens/myTemplates/src/toolbar/camera/camera_button.dart b/lib/Screens/myTemplates/src/toolbar/camera/camera_button.dart new file mode 100644 index 0000000..e9b18ab --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/camera/camera_button.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/internal.dart'; + +import 'package:image_picker/image_picker.dart'; +import '../../common/default_image_insert.dart'; +import '../../common/default_video_insert.dart'; +import '../quill_simple_toolbar_api.dart'; +import 'camera_types.dart'; +import 'config/camera_config.dart'; +import 'select_camera_action.dart'; + +// ignore: invalid_use_of_internal_member +class QuillToolbarCameraButton extends QuillToolbarBaseButtonStateless { + const QuillToolbarCameraButton({ + required super.controller, + QuillToolbarCameraButtonOptions? options, + + /// Shares common options between all buttons, prefer the [options] + /// over the [baseOptions]. + super.baseOptions, + super.key, + }) : _options = options, + super(options: options); + + final QuillToolbarCameraButtonOptions? _options; + + @override + QuillToolbarCameraButtonOptions? get options => _options; + + void _sharedOnPressed(BuildContext context) { + _onPressedHandler( + context, + controller, + ); + afterButtonPressed(context); + } + + Future _getCameraAction(BuildContext context) async { + final customCallback = options?.cameraConfig?.onRequestCameraActionCallback; + if (customCallback != null) { + return await customCallback(context); + } + final cameraAction = await showSelectCameraActionDialog( + context: context, + ); + + return cameraAction; + } + + Future _onPressedHandler( + BuildContext context, + QuillController controller, + ) async { + final cameraAction = await _getCameraAction(context); + + if (cameraAction == null) { + return; + } + + switch (cameraAction) { + case CameraAction.video: + final videoFile = + await ImagePicker().pickVideo(source: ImageSource.camera); + if (videoFile == null) { + return; + } + await handleVideoInsert( + videoFile.path, + controller: controller, + onVideoInsertCallback: options?.cameraConfig?.onVideoInsertCallback, + onVideoInsertedCallback: + options?.cameraConfig?.onVideoInsertedCallback, + ); + case CameraAction.image: + final imageFile = + await ImagePicker().pickImage(source: ImageSource.camera); + if (imageFile == null) { + return; + } + await handleImageInsert( + imageFile.path, + controller: controller, + onImageInsertCallback: options?.cameraConfig?.onImageInsertCallback, + onImageInsertedCallback: + options?.cameraConfig?.onImageInsertedCallback, + ); + } + } + + @override + Widget buildButton(BuildContext context) { + return QuillToolbarIconButton( + icon: Icon( + iconData(context), + size: iconButtonFactor(context) * iconSize(context), + ), + tooltip: tooltip(context), + isSelected: false, + onPressed: () => _sharedOnPressed(context), + iconTheme: iconTheme(context), + ); + } + + @override + Widget? buildCustomChildBuilder(BuildContext context) { + return childBuilder?.call( + QuillToolbarCameraButtonOptions( + afterButtonPressed: afterButtonPressed(context), + iconData: iconData(context), + iconSize: iconSize(context), + iconButtonFactor: iconButtonFactor(context), + iconTheme: options?.iconTheme, + tooltip: tooltip(context), + cameraConfig: options?.cameraConfig, + ), + QuillToolbarCameraButtonExtraOptions( + controller: controller, + context: context, + onPressed: () => _sharedOnPressed(context), + ), + ); + } + + @override + IconData Function(BuildContext context) get getDefaultIconData => + (context) => Icons.photo_camera; + + @override + String Function(BuildContext context) get getDefaultTooltip => + (context) => context.loc.camera; +} diff --git a/lib/Screens/myTemplates/src/toolbar/camera/camera_types.dart b/lib/Screens/myTemplates/src/toolbar/camera/camera_types.dart new file mode 100644 index 0000000..04822fd --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/camera/camera_types.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart' show BuildContext; +import 'package:meta/meta.dart' show immutable; + +import '../../editor/image/image_embed_types.dart'; +import '../video/config/video.dart'; + +enum CameraAction { + video, + image, +} + +/// When the user click the camera button, should we take a photo or record +/// a video using the camera +/// +/// by default will show a dialog that ask the user which option he/she wants +typedef OnRequestCameraActionCallback = Future Function( + BuildContext context, +); + +@immutable +class QuillToolbarCameraConfig { + const QuillToolbarCameraConfig({ + this.onRequestCameraActionCallback, + this.onImageInsertCallback, + this.onImageInsertedCallback, + this.onVideoInsertedCallback, + this.onVideoInsertCallback, + }); + + final OnRequestCameraActionCallback? onRequestCameraActionCallback; + + final OnImageInsertedCallback? onImageInsertedCallback; + + final OnImageInsertCallback? onImageInsertCallback; + + final OnVideoInsertedCallback? onVideoInsertedCallback; + + final OnVideoInsertCallback? onVideoInsertCallback; +} diff --git a/lib/Screens/myTemplates/src/toolbar/camera/config/camera_config.dart b/lib/Screens/myTemplates/src/toolbar/camera/config/camera_config.dart new file mode 100644 index 0000000..e498446 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/camera/config/camera_config.dart @@ -0,0 +1,28 @@ +import 'package:flutter_quill/flutter_quill.dart'; + +import '../camera_types.dart'; + +class QuillToolbarCameraButtonExtraOptions + extends QuillToolbarBaseButtonExtraOptions { + const QuillToolbarCameraButtonExtraOptions({ + required super.controller, + required super.context, + required super.onPressed, + }); +} + +class QuillToolbarCameraButtonOptions extends QuillToolbarBaseButtonOptions< + QuillToolbarCameraButtonOptions, QuillToolbarCameraButtonExtraOptions> { + const QuillToolbarCameraButtonOptions({ + this.cameraConfig, + super.iconSize, + super.iconButtonFactor, + super.iconData, + super.afterButtonPressed, + super.tooltip, + super.iconTheme, + super.childBuilder, + }); + + final QuillToolbarCameraConfig? cameraConfig; +} diff --git a/lib/Screens/myTemplates/src/toolbar/camera/select_camera_action.dart b/lib/Screens/myTemplates/src/toolbar/camera/select_camera_action.dart new file mode 100644 index 0000000..2c0c3f8 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/camera/select_camera_action.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/internal.dart'; + +import 'camera_types.dart'; + +class SelectCameraActionDialog extends StatelessWidget { + const SelectCameraActionDialog({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 150, + width: double.infinity, + child: SingleChildScrollView( + child: Column( + children: [ + ListTile( + title: Text(context.loc.photo), + subtitle: Text( + context.loc.takeAPhotoUsingYourCamera, + ), + leading: const Icon(Icons.photo_sharp), + enabled: !isDesktopApp, + onTap: () => Navigator.of(context).pop(CameraAction.image), + ), + ListTile( + title: Text(context.loc.video), + subtitle: Text( + context.loc.recordAVideoUsingYourCamera, + ), + leading: const Icon(Icons.camera), + enabled: !isDesktopApp, + onTap: () => Navigator.of(context).pop(CameraAction.video), + ), + ], + ), + ), + ); + } +} + +Future showSelectCameraActionDialog({ + required BuildContext context, +}) async { + final imageSource = await showModalBottomSheet( + showDragHandle: true, + context: context, + constraints: const BoxConstraints(maxWidth: 640), + builder: (context) => const SelectCameraActionDialog(), + ); + return imageSource; +} diff --git a/lib/Screens/myTemplates/src/toolbar/image/config/image_config.dart b/lib/Screens/myTemplates/src/toolbar/image/config/image_config.dart new file mode 100644 index 0000000..06b3103 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/image/config/image_config.dart @@ -0,0 +1,39 @@ +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart' show immutable; + +import '../../../editor/image/image_embed_types.dart'; + +class QuillToolbarImageButtonExtraOptions + extends QuillToolbarBaseButtonExtraOptions { + const QuillToolbarImageButtonExtraOptions({ + required super.controller, + required super.context, + required super.onPressed, + }); +} + +@immutable +class QuillToolbarImageButtonOptions extends QuillToolbarBaseButtonOptions< + QuillToolbarImageButtonOptions, QuillToolbarImageButtonExtraOptions> { + const QuillToolbarImageButtonOptions({ + super.iconData, + super.iconSize, + super.iconButtonFactor, + + /// specifies the tooltip text for the image button. + super.tooltip, + super.afterButtonPressed, + super.childBuilder, + super.iconTheme, + this.dialogTheme, + this.linkRegExp, + this.imageButtonConfig = const QuillToolbarImageConfig(), + }); + + final QuillDialogTheme? dialogTheme; + + /// [imageLinkRegExp] is a regular expression to identify image links. + final RegExp? linkRegExp; + + final QuillToolbarImageConfig? imageButtonConfig; +} diff --git a/lib/Screens/myTemplates/src/toolbar/image/image_button.dart b/lib/Screens/myTemplates/src/toolbar/image/image_button.dart new file mode 100644 index 0000000..65a6510 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/image/image_button.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/internal.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../common/default_image_insert.dart'; +import '../../common/image_video_utils.dart'; +import '../../editor/image/image_embed_types.dart'; +import '../quill_simple_toolbar_api.dart'; +import 'config/image_config.dart'; +import 'select_image_source.dart'; + +// ignore: invalid_use_of_internal_member +class QuillToolbarImageButton extends QuillToolbarBaseButtonStateless { + const QuillToolbarImageButton({ + required super.controller, + QuillToolbarImageButtonOptions? options, + + /// Shares common options between all buttons, prefer the [options] + /// over the [baseOptions]. + super.baseOptions, + super.key, + }) : _options = options, + super(options: options); + + final QuillToolbarImageButtonOptions? _options; + + @override + QuillToolbarImageButtonOptions? get options => _options; + + void _sharedOnPressed(BuildContext context) { + _onPressedHandler(context); + afterButtonPressed(context); + } + + Future _handleImageInsert(String imageUrl) async { + await handleImageInsert( + imageUrl, + controller: controller, + onImageInsertCallback: options?.imageButtonConfig?.onImageInsertCallback, + onImageInsertedCallback: + options?.imageButtonConfig?.onImageInsertedCallback, + ); + } + + Future _onPressedHandler(BuildContext context) async { + final onRequestPickImage = options?.imageButtonConfig?.onRequestPickImage; + if (onRequestPickImage != null) { + final imageUrl = await onRequestPickImage( + context, + ); + if (imageUrl != null) { + await _handleImageInsert(imageUrl); + } + return; + } + final source = await showSelectImageSourceDialog( + context: context, + ); + if (source == null) { + return; + } + + final imageUrl = switch (source) { + InsertImageSource.gallery => + (await ImagePicker().pickImage(source: ImageSource.gallery))?.path, + InsertImageSource.link => + context.mounted ? await _typeLink(context) : null, + InsertImageSource.camera => + (await ImagePicker().pickImage(source: ImageSource.camera))?.path, + }; + if (imageUrl == null) { + return; + } + if (imageUrl.trim().isNotEmpty) { + await _handleImageInsert(imageUrl); + } + } + + Future _typeLink(BuildContext context) async { + final value = await showDialog( + context: context, + builder: (_) => TypeLinkDialog( + dialogTheme: options?.dialogTheme, + linkRegExp: options?.linkRegExp, + linkType: LinkType.image, + ), + ); + return value; + } + + @override + Widget buildButton(BuildContext context) { + return QuillToolbarIconButton( + icon: Icon( + iconData(context), + size: iconButtonFactor(context) * iconSize(context), + ), + tooltip: tooltip(context), + isSelected: false, + onPressed: () => _sharedOnPressed(context), + iconTheme: iconTheme(context), + ); + } + + @override + Widget? buildCustomChildBuilder(BuildContext context) { + return childBuilder?.call( + QuillToolbarImageButtonOptions( + afterButtonPressed: afterButtonPressed(context), + iconData: iconData(context), + iconSize: iconSize(context), + iconButtonFactor: iconButtonFactor(context), + dialogTheme: options?.dialogTheme, + iconTheme: options?.iconTheme, + linkRegExp: options?.linkRegExp, + tooltip: tooltip(context), + imageButtonConfig: options?.imageButtonConfig, + ), + QuillToolbarImageButtonExtraOptions( + context: context, + controller: controller, + onPressed: () => _sharedOnPressed(context), + ), + ); + } + + @override + IconData Function(BuildContext context) get getDefaultIconData => + (context) => Icons.image; + + @override + String Function(BuildContext context) get getDefaultTooltip => + (context) => context.loc.insertImage; +} diff --git a/lib/Screens/myTemplates/src/toolbar/image/select_image_source.dart b/lib/Screens/myTemplates/src/toolbar/image/select_image_source.dart new file mode 100644 index 0000000..80f846a --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/image/select_image_source.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/internal.dart'; + +import '../../editor/image/image_embed_types.dart'; + +class SelectImageSourceDialog extends StatelessWidget { + const SelectImageSourceDialog({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minHeight: 200), + width: double.infinity, + child: SingleChildScrollView( + child: Column( + children: [ + ListTile( + title: Text(context.loc.gallery), + subtitle: Text( + context.loc.pickAPhotoFromYourGallery, + ), + leading: const Icon(Icons.photo_sharp), + onTap: () => Navigator.of(context).pop(InsertImageSource.gallery), + ), + ListTile( + title: Text(context.loc.camera), + subtitle: Text( + context.loc.takeAPhotoUsingYourCamera, + ), + leading: const Icon(Icons.camera), + enabled: !isDesktopApp, + onTap: () => Navigator.of(context).pop(InsertImageSource.camera), + ), + ListTile( + title: Text(context.loc.link), + subtitle: Text( + context.loc.pasteAPhotoUsingALink, + ), + leading: const Icon(Icons.link), + onTap: () => Navigator.of(context).pop(InsertImageSource.link), + ), + ], + ), + ), + ); + } +} + +Future showSelectImageSourceDialog({ + required BuildContext context, +}) async { + final imageSource = await showModalBottomSheet( + showDragHandle: true, + context: context, + constraints: const BoxConstraints(maxWidth: 640), + builder: (_) => const SelectImageSourceDialog(), + ); + return imageSource; +} diff --git a/lib/Screens/myTemplates/src/toolbar/quill_simple_toolbar_api.dart b/lib/Screens/myTemplates/src/toolbar/quill_simple_toolbar_api.dart new file mode 100644 index 0000000..3f9083d --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/quill_simple_toolbar_api.dart @@ -0,0 +1,12 @@ +/// APIs that are meant to be used by the `flutter_quil_extensions` only. +/// +/// Breaking changes can be introduced from `flutter_quill` in minor versions, +/// the `flutter_quill_extensions` will be updated and published at the same time. +/// +/// Update both packages and use the same version for compatibility by running `flutter pub upgrade`. +@internal +library; + +import 'package:meta/meta.dart'; + +export 'package:flutter_quill/src/toolbar/base_button/stateless_base_button.dart'; diff --git a/lib/Screens/myTemplates/src/toolbar/video/config/video.dart b/lib/Screens/myTemplates/src/toolbar/video/config/video.dart new file mode 100644 index 0000000..0a3c2bf --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/video/config/video.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart' show BuildContext; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart' show immutable; + +/// When request picking an video, for example when the video button toolbar +/// clicked, it should be null in case the user didn't choose any video or +/// any other reasons, and it should be the video file path as string that is +/// exists in case the user picked the video successfully +/// +/// by default we already have a default implementation that show a dialog +/// request the source for picking the video, from gallery, link or camera +typedef OnRequestPickVideo = Future Function( + BuildContext context, +); + +/// A callback will called when inserting a video in the editor +/// it have the logic that will insert the video block using the controller +typedef OnVideoInsertCallback = Future Function( + String video, + QuillController controller, +); + +/// When a new video picked this callback will called and you might want to +/// do some logic depending on your use case +typedef OnVideoInsertedCallback = Future Function( + String video, +); + +enum InsertVideoSource { + gallery, + camera, + link, +} + +/// Configurations for dealing with videos, on insert a video +/// on request picking a video +@immutable +class QuillToolbarVideoConfig { + const QuillToolbarVideoConfig({ + this.onRequestPickVideo, + this.onVideoInsertedCallback, + this.onVideoInsertCallback, + }); + + final OnRequestPickVideo? onRequestPickVideo; + + final OnVideoInsertedCallback? onVideoInsertedCallback; + + final OnVideoInsertCallback? onVideoInsertCallback; +} diff --git a/lib/Screens/myTemplates/src/toolbar/video/config/video_config.dart b/lib/Screens/myTemplates/src/toolbar/video/config/video_config.dart new file mode 100644 index 0000000..268a4bd --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/video/config/video_config.dart @@ -0,0 +1,32 @@ +import 'package:flutter_quill/flutter_quill.dart'; + +import 'video.dart'; + +class QuillToolbarVideoButtonExtraOptions + extends QuillToolbarBaseButtonExtraOptions { + const QuillToolbarVideoButtonExtraOptions({ + required super.controller, + required super.context, + required super.onPressed, + }); +} + +class QuillToolbarVideoButtonOptions extends QuillToolbarBaseButtonOptions< + QuillToolbarVideoButtonOptions, QuillToolbarVideoButtonExtraOptions> { + const QuillToolbarVideoButtonOptions({ + this.linkRegExp, + this.dialogTheme, + super.iconSize, + super.iconButtonFactor, + super.iconData, + super.afterButtonPressed, + super.tooltip, + super.iconTheme, + super.childBuilder, + this.videoConfig, + }); + + final RegExp? linkRegExp; + final QuillDialogTheme? dialogTheme; + final QuillToolbarVideoConfig? videoConfig; +} diff --git a/lib/Screens/myTemplates/src/toolbar/video/select_video_source.dart b/lib/Screens/myTemplates/src/toolbar/video/select_video_source.dart new file mode 100644 index 0000000..4020fc8 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/video/select_video_source.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/internal.dart'; + +import 'config/video.dart'; + +class SelectVideoSourceDialog extends StatelessWidget { + const SelectVideoSourceDialog({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minHeight: 200), + width: double.infinity, + child: SingleChildScrollView( + child: Column( + children: [ + ListTile( + title: Text(context.loc.gallery), + subtitle: Text( + context.loc.pickAVideoFromYourGallery, + ), + leading: const Icon(Icons.photo_sharp), + onTap: () => Navigator.of(context).pop(InsertVideoSource.gallery), + ), + ListTile( + title: Text(context.loc.camera), + subtitle: Text(context.loc.recordAVideoUsingYourCamera), + leading: const Icon(Icons.camera), + enabled: !isDesktopApp, + onTap: () => Navigator.of(context).pop(InsertVideoSource.camera), + ), + ListTile( + title: Text(context.loc.link), + subtitle: Text( + context.loc.pasteAVideoUsingALink, + ), + leading: const Icon(Icons.link), + onTap: () => Navigator.of(context).pop(InsertVideoSource.link), + ), + ], + ), + ), + ); + } +} + +Future showSelectVideoSourceDialog({ + required BuildContext context, +}) async { + final imageSource = await showModalBottomSheet( + showDragHandle: true, + context: context, + constraints: const BoxConstraints(maxWidth: 640), + builder: (context) => const SelectVideoSourceDialog(), + ); + return imageSource; +} diff --git a/lib/Screens/myTemplates/src/toolbar/video/video_button.dart b/lib/Screens/myTemplates/src/toolbar/video/video_button.dart new file mode 100644 index 0000000..33d160b --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/video/video_button.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/internal.dart'; + +import 'package:image_picker/image_picker.dart'; + +import '../../common/default_video_insert.dart'; +import '../../common/image_video_utils.dart'; +import '../quill_simple_toolbar_api.dart'; + +import 'config/video.dart'; +import 'config/video_config.dart'; +import 'select_video_source.dart'; + +// ignore: invalid_use_of_internal_member +class QuillToolbarVideoButton extends QuillToolbarBaseButtonStateless { + const QuillToolbarVideoButton({ + required super.controller, + QuillToolbarVideoButtonOptions? options, + + /// Shares common options between all buttons, prefer the [options] + /// over the [baseOptions]. + super.baseOptions, + super.key, + }) : _options = options, + super(options: options); + + final QuillToolbarVideoButtonOptions? _options; + + @override + QuillToolbarVideoButtonOptions? get options => _options; + + void _sharedOnPressed(BuildContext context) { + _onPressedHandler(context); + afterButtonPressed(context); + } + + Future _handleVideoInsert(String videoUrl) async { + await handleVideoInsert( + videoUrl, + controller: controller, + onVideoInsertCallback: options?.videoConfig?.onVideoInsertCallback, + onVideoInsertedCallback: options?.videoConfig?.onVideoInsertedCallback, + ); + } + + Future _onPressedHandler(BuildContext context) async { + final onRequestPickVideo = options?.videoConfig?.onRequestPickVideo; + if (onRequestPickVideo != null) { + final videoUrl = await onRequestPickVideo(context); + if (videoUrl != null) { + await _handleVideoInsert(videoUrl); + } + return; + } + + final imageSource = await showSelectVideoSourceDialog(context: context); + + if (imageSource == null) { + return; + } + + final videoUrl = switch (imageSource) { + InsertVideoSource.gallery => + (await ImagePicker().pickVideo(source: ImageSource.gallery))?.path, + InsertVideoSource.camera => + (await ImagePicker().pickVideo(source: ImageSource.camera))?.path, + InsertVideoSource.link => + context.mounted ? await _typeLink(context) : null, + }; + if (videoUrl == null) { + return; + } + + if (videoUrl.trim().isNotEmpty) { + _handleVideoInsert(videoUrl); + } + } + + Future _typeLink(BuildContext context) async { + final value = await showDialog( + context: context, + builder: (_) => TypeLinkDialog( + dialogTheme: options?.dialogTheme, + linkType: LinkType.video, + ), + ); + return value; + } + + @override + Widget buildButton(BuildContext context) { + return QuillToolbarIconButton( + icon: Icon( + iconData(context), + size: iconSize(context) * iconButtonFactor(context), + ), + tooltip: tooltip(context), + isSelected: false, + onPressed: () => _sharedOnPressed(context), + iconTheme: iconTheme(context), + ); + } + + @override + Widget? buildCustomChildBuilder(BuildContext context) { + return childBuilder?.call( + QuillToolbarVideoButtonOptions( + afterButtonPressed: afterButtonPressed(context), + iconData: iconData(context), + dialogTheme: options?.dialogTheme, + iconSize: iconSize(context), + iconButtonFactor: iconButtonFactor(context), + linkRegExp: options?.linkRegExp, + tooltip: tooltip(context), + iconTheme: options?.iconTheme, + videoConfig: options?.videoConfig, + ), + QuillToolbarVideoButtonExtraOptions( + context: context, + controller: controller, + onPressed: () => _sharedOnPressed(context), + ), + ); + } + + @override + IconData Function(BuildContext context) get getDefaultIconData => + (context) => Icons.movie_creation; + + @override + String Function(BuildContext context) get getDefaultTooltip => + (context) => context.loc.insertVideo; +} diff --git a/lib/Screens/myTemplates/template.dart b/lib/Screens/myTemplates/template.dart new file mode 100644 index 0000000..31cecb7 --- /dev/null +++ b/lib/Screens/myTemplates/template.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; +import 'dart:io' as io show Directory, File; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_quill/quill_delta.dart'; +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; +import 'package:path/path.dart' as path; +import 'package:responsive_builder/responsive_builder.dart'; + +import '../../routes/custom_appBar.dart'; +import '../../routes/custom_drawer.dart'; +import '../../services/apiService.dart'; + +/// Custom Embed Definition +class TimeStampEmbed { + static const String type = 'timeStamp'; +} + +class Template extends StatefulWidget { + @override + TemplateState createState() => TemplateState(); +} + +class TemplateState extends State