feature/payment #77

Closed
manish.zure wants to merge 25 commits from feature/payment into main
11 changed files with 660 additions and 95 deletions
Showing only changes of commit ff9c712a7e - Show all commits

BIN
lib.zip Normal file

Binary file not shown.

View File

@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:razorpay_flutter/razorpay_flutter.dart';
import 'package:marco/helpers/services/payment_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
class PaymentController with ChangeNotifier {
final Razorpay _razorpay = Razorpay();
final PaymentService _paymentService = PaymentService();
bool isProcessing = false;
BuildContext? _context; // For showing dialogs/snackbars
PaymentController() {
// Razorpay event listeners
_razorpay.on(Razorpay.EVENT_PAYMENT_SUCCESS, _handlePaymentSuccess);
_razorpay.on(Razorpay.EVENT_PAYMENT_ERROR, _handlePaymentError);
_razorpay.on(Razorpay.EVENT_EXTERNAL_WALLET, _handleExternalWallet);
}
void disposeController() {
_razorpay.clear();
}
/// ==============================
/// START PAYMENT
/// ==============================
Future<void> startPayment({
required double amount,
required String description,
required BuildContext context,
}) async {
_context = context;
isProcessing = true;
notifyListeners();
logSafe("🟢 Starting payment for ₹$amount - $description");
// Call backend to create Razorpay order
final result = await _paymentService.createOrder(amount);
if (result == null) {
_showError(context, "Failed to connect to server.");
isProcessing = false;
notifyListeners();
return;
}
final orderId = result['orderId'];
final key = result['key'];
if (orderId == null || key == null) {
_showError(context, "Invalid response from server.");
isProcessing = false;
notifyListeners();
return;
}
var options = {
'key': key,
'amount': (amount * 100).toInt(), // Razorpay takes amount in paise
'name': 'Your Company Name',
'description': description,
'order_id': orderId,
'theme': {'color': '#0D47A1'},
'timeout': 120, // 2 minutes timeout
};
try {
logSafe("🟠 Opening Razorpay with options: $options");
_razorpay.open(options);
} catch (e) {
logSafe("❌ Error opening Razorpay: $e", level: LogLevel.error);
_showError(context, "Error opening payment gateway.");
isProcessing = false;
notifyListeners();
}
}
/// ==============================
/// EVENT HANDLERS
/// ==============================
void _handlePaymentSuccess(PaymentSuccessResponse response) async {
logSafe("✅ Payment Success: ${response.paymentId}");
isProcessing = true;
notifyListeners();
final result = await _paymentService.verifyPayment(
paymentId: response.paymentId!,
orderId: response.orderId!,
signature: response.signature!,
);
isProcessing = false;
notifyListeners();
if (result != null && result['verified'] == true) {
_showDialog(
title: "Payment Successful 🎉",
message: "Your payment was verified successfully.",
success: true,
);
} else {
_showDialog(
title: "Verification Failed ❌",
message: "Payment completed but could not be verified.",
success: false,
);
}
}
void _handlePaymentError(PaymentFailureResponse response) {
logSafe("❌ Payment Failed: ${response.message}");
isProcessing = false;
notifyListeners();
_showDialog(
title: "Payment Failed ❌",
message: "Reason: ${response.message ?? 'Unknown error'}",
success: false,
);
}
void _handleExternalWallet(ExternalWalletResponse response) {
logSafe(" External Wallet Used: ${response.walletName}");
}
/// ==============================
/// HELPER DIALOGS / SNACKBARS
/// ==============================
void _showDialog({
required String title,
required String message,
required bool success,
}) {
if (_context == null) return;
showDialog(
context: _context!,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
child: const Text("OK"),
onPressed: () {
Navigator.of(ctx).pop();
if (success) {
Navigator.of(_context!).pop(true); // Return success
}
},
),
],
),
);
}
void _showError(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
}
}

View File

@ -0,0 +1,38 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
class SubscriptionController extends GetxController {
var plans = <Map<String, dynamic>>[].obs;
var isLoading = true.obs;
// Frequency tabs
final frequencies = ['monthly', 'quarterly', 'halfyearly', 'yearly'];
var selectedFrequency = 'monthly'.obs;
@override
void onInit() {
super.onInit();
fetchPlans(selectedFrequency.value);
}
Future<void> fetchPlans(String frequency) async {
try {
isLoading.value = true;
selectedFrequency.value = frequency;
final response = await ApiService.getSubscriptionPlans(frequency);
if (response != null &&
response['success'] == true &&
response['data'] != null) {
plans.value = List<Map<String, dynamic>>.from(response['data']);
} else {
plans.clear();
}
} catch (e) {
print("Error fetching plans: $e");
plans.clear();
} finally {
isLoading.value = false;
}
}
}

View File

@ -100,4 +100,8 @@ class ApiEndpoints {
static const getAllOrganizations = "/organization/list";
static const String getAssignedServices = "/Project/get/assigned/services";
// Payment Module API Endpoints
static const String createOrder = "/api/payment/create-order";
static const String verifyPayment = "/api/payment/verify-payment";
}

View File

@ -1824,7 +1824,7 @@ class ApiService {
_log("Deleting directory contact at $uri");
final response = await _deleteRequest(
"$endpoint?active=false",
"$endpoint?active=false",
);
if (response != null && response.statusCode == 200) {
@ -1957,6 +1957,52 @@ class ApiService {
? _parseResponseForAllData(res, label: 'Contact Bucket List')
: null);
/// ==============================
/// SUBSCRIPTION API
/// ==============================
static Future<Map<String, dynamic>?> getSubscriptionPlans(
String frequency) async {
try {
final endpoint =
"/api/market/list/subscription-plan?frequency=$frequency";
logSafe("Fetching subscription plans for frequency: $frequency");
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Subscription plans request failed: null response",
level: LogLevel.error);
return null;
}
final body = response.body.trim();
if (body.isEmpty) {
logSafe("Subscription plans response body is empty",
level: LogLevel.warning);
return null;
}
final jsonResponse = jsonDecode(body);
if (jsonResponse is Map<String, dynamic>) {
if (jsonResponse['success'] == true) {
logSafe("Subscription plans fetched successfully");
return jsonResponse; // Return full JSON, PaymentController will handle parsing
} else {
logSafe(
"Failed to fetch subscription plans: ${jsonResponse['message'] ?? 'Unknown error'}",
level: LogLevel.warning);
}
} else {
logSafe("Unexpected subscription response format: $jsonResponse",
level: LogLevel.error);
}
} catch (e, stack) {
logSafe("❌ Exception during getSubscriptionPlans: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
// === Attendance APIs ===
static Future<List<dynamic>?> getProjects() async =>
@ -2085,6 +2131,40 @@ class ApiService {
return "${employeeId}_${dateStr}_$imageNumber.jpg";
}
/// Create a payment order
static Future<Map<String, dynamic>?> createPaymentOrder(double amount) async {
const endpoint = ApiEndpoints.createOrder; // endpoint for order creation
try {
final response = await _postRequest(endpoint, {'amount': amount});
if (response == null) return null;
return _parseResponse(response, label: "Create Payment Order");
} catch (e) {
logSafe("Exception during createPaymentOrder: $e", level: LogLevel.error);
return null;
}
}
/// Verify a payment
static Future<Map<String, dynamic>?> verifyPayment({
required String orderId,
required String paymentId,
required String signature,
}) async {
const endpoint = ApiEndpoints.verifyPayment;
try {
final response = await _postRequest(endpoint, {
'orderId': orderId,
'paymentId': paymentId,
'signature': signature,
});
if (response == null) return null;
return _parseResponse(response, label: "Verify Payment");
} catch (e) {
logSafe("Exception during verifyPayment: $e", level: LogLevel.error);
return null;
}
}
// === Employee APIs ===
/// Search employees by first name and last name only (not middle name)
/// Returns a list of up to 10 employee records matching the search string.

View File

@ -0,0 +1,37 @@
// payment_service.dart
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
class PaymentService {
/// Create a Razorpay order on backend
Future<Map<String, dynamic>?> createOrder(double amount) async {
try {
logSafe("🟢 Calling createPaymentOrder API with amount: ₹$amount");
final response = await ApiService.createPaymentOrder(amount);
return response;
} catch (e) {
logSafe("❌ Error in createOrder: $e", level: LogLevel.error);
return null;
}
}
/// Verify Razorpay payment signature
Future<Map<String, dynamic>?> verifyPayment({
required String orderId,
required String paymentId,
required String signature,
}) async {
try {
logSafe("🟢 Calling verifyPayment API: orderId=$orderId, paymentId=$paymentId");
final response = await ApiService.verifyPayment(
orderId: orderId,
paymentId: paymentId,
signature: signature,
);
return response;
} catch (e) {
logSafe("❌ Error in verifyPayment: $e", level: LogLevel.error);
return null;
}
}
}

View File

@ -2,124 +2,76 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/view/auth/forgot_password_screen.dart';
// Screens
import 'package:marco/view/auth/login_option_screen.dart';
import 'package:marco/view/auth/login_screen.dart';
import 'package:marco/view/auth/register_account_screen.dart';
import 'package:marco/view/auth/reset_password_screen.dart';
import 'package:marco/view/error_pages/coming_soon_screen.dart';
import 'package:marco/view/error_pages/error_404_screen.dart';
import 'package:marco/view/error_pages/error_500_screen.dart';
import 'package:marco/view/auth/forgot_password_screen.dart';
import 'package:marco/view/auth/mpin_screen.dart';
import 'package:marco/view/auth/mpin_auth_screen.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart';
import 'package:marco/view/Attendence/attendance_screen.dart';
import 'package:marco/view/taskPlanning/daily_task_planning.dart';
import 'package:marco/view/taskPlanning/daily_progress_report.dart';
import 'package:marco/view/employees/employees_screen.dart';
import 'package:marco/view/auth/login_option_screen.dart';
import 'package:marco/view/auth/mpin_screen.dart';
import 'package:marco/view/auth/mpin_auth_screen.dart';
import 'package:marco/view/directory/directory_main_screen.dart';
import 'package:marco/view/expense/expense_screen.dart';
import 'package:marco/view/document/user_document_screen.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart';
import 'package:marco/view/directory/directory_main_screen.dart';
import 'package:marco/view/error_pages/error_404_screen.dart';
import 'package:marco/view/error_pages/error_500_screen.dart';
import 'package:marco/view/error_pages/coming_soon_screen.dart';
import 'package:marco/view/payment/payment_screen.dart';
import 'package:marco/view/subscriptions/subscriptions_screen.dart';
class AuthMiddleware extends GetMiddleware {
@override
RouteSettings? redirect(String? route) {
if (!AuthService.isLoggedIn) {
if (route != '/auth/login-option') {
return const RouteSettings(name: '/auth/login-option');
}
return const RouteSettings(name: '/auth/login-option');
} else if (!TenantService.isTenantSelected) {
if (route != '/select-tenant') {
return const RouteSettings(name: '/select-tenant');
}
return const RouteSettings(name: '/select-tenant');
}
return null;
}
}
getPageRoute() {
var routes = [
GetPage(
name: '/',
page: () => DashboardScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard',
page: () => DashboardScreen(), // or your actual home screen
middlewares: [AuthMiddleware()],
),
GetPage(
name: '/select-tenant',
page: () => const TenantSelectionScreen(),
middlewares: [AuthMiddleware()]),
List<GetPage> getPageRoute() {
return [
GetPage(name: '/', page: () => DashboardScreen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/dashboard', page: () => DashboardScreen(), middlewares: [AuthMiddleware()]),
// Dashboard
GetPage(
name: '/dashboard/attendance',
page: () => AttendanceScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard',
page: () => DashboardScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard/employees',
page: () => EmployeesScreen(),
middlewares: [AuthMiddleware()]),
// Daily Task Planning
GetPage(
name: '/dashboard/daily-task-Planning',
page: () => DailyTaskPlanningScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard/daily-task-progress',
page: () => DailyProgressReportScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard/directory-main-page',
page: () => DirectoryMainScreen(),
middlewares: [AuthMiddleware()]),
// Expense
GetPage(
name: '/dashboard/expense-main-page',
page: () => ExpenseMainScreen(),
middlewares: [AuthMiddleware()]),
// Documents
GetPage(
name: '/dashboard/document-main-page',
page: () => UserDocumentsPage(),
middlewares: [AuthMiddleware()]),
// Authentication
// Tenant
GetPage(name: '/select-tenant', page: () => TenantSelectionScreen(), middlewares: [AuthMiddleware()]),
// Modules
GetPage(name: '/dashboard/attendance', page: () => AttendanceScreen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/dashboard/employees', page: () => EmployeesScreen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/dashboard/daily-task-planning', page: () => DailyTaskPlanningScreen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/dashboard/daily-task-progress', page: () => DailyProgressReportScreen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/dashboard/directory-main-page', page: () => DirectoryMainScreen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/dashboard/expense-main-page', page: () => ExpenseMainScreen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/dashboard/document-main-page', page: () => UserDocumentsPage(), middlewares: [AuthMiddleware()]),
// Auth
GetPage(name: '/auth/login', page: () => LoginScreen()),
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
GetPage(name: '/auth/register_account', page: () => RegisterAccountScreen()),
GetPage(name: '/auth/mpin', page: () => MPINScreen()),
GetPage(name: '/auth/mpin-auth', page: () => MPINAuthScreen()),
GetPage(
name: '/auth/register_account',
page: () => const RegisterAccountScreen()),
GetPage(name: '/auth/forgot_password', page: () => ForgotPasswordScreen()),
GetPage(
name: '/auth/reset_password', page: () => const ResetPasswordScreen()),
// Error
GetPage(
name: '/error/coming_soon',
page: () => ComingSoonScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/error/500',
page: () => Error500Screen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/error/404',
page: () => Error404Screen(),
middlewares: [AuthMiddleware()]),
GetPage(name: '/auth/reset_password', page: () => ResetPasswordScreen()),
// Payment
GetPage(name: '/dashboard/payment', page: () => PaymentScreen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/subscription', page: () => SubscriptionScreen(), middlewares: [AuthMiddleware()]),
// Error Pages
GetPage(name: '/error/404', page: () => Error404Screen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/error/500', page: () => Error500Screen(), middlewares: [AuthMiddleware()]),
GetPage(name: '/error/coming_soon', page: () => ComingSoonScreen(), middlewares: [AuthMiddleware()]),
];
return routes
.map((e) => GetPage(
name: e.name,
page: e.page,
middlewares: e.middlewares,
transition: Transition.noTransition))
.toList();
}

View File

@ -316,6 +316,14 @@ class _UserProfileBarState extends State<UserProfileBar>
onTap: _onProfileTap,
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.bell,
label: 'Subscribe',
onTap: _onSubscribeTap,
iconColor: Colors.green.shade600,
textColor: Colors.green.shade800,
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.settings,
label: 'Settings',
@ -325,9 +333,7 @@ class _UserProfileBarState extends State<UserProfileBar>
});
},
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.badge_alert,
label: 'Support',
@ -387,6 +393,11 @@ class _UserProfileBarState extends State<UserProfileBar>
));
}
void _onSubscribeTap() {
Get.toNamed("/subscription");
}
void _onMpinTap() {
final controller = Get.put(MPINController());
if (hasMpin) controller.setChangeMpinMode();

View File

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:marco/controller/payment/payment_controller.dart';
import 'package:get/get.dart';
class PaymentScreen extends StatelessWidget {
final double amount;
final String description;
const PaymentScreen({
super.key,
this.amount = 0.0,
this.description = "No description",
});
@override
Widget build(BuildContext context) {
final args = (Get.arguments ?? {}) as Map<String, dynamic>;
final double finalAmount = args['amount'] ?? amount;
final String finalDescription = args['description'] ?? description;
final paymentController = Provider.of<PaymentController>(context);
return Scaffold(
appBar: AppBar(
title: const Text("Payment"),
backgroundColor: Colors.blueAccent,
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Description: $finalDescription",
style: const TextStyle(fontSize: 18),
),
const SizedBox(height: 8),
Text(
"Amount: ₹${finalAmount.toStringAsFixed(2)}",
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 40),
// Show loader when payment processing
Center(
child: paymentController.isProcessing
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: () async {
await paymentController.startPayment(
amount: finalAmount,
description: finalDescription,
context: context,
);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 40, vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
backgroundColor: Colors.green,
),
child: const Text(
"Pay Now",
style: TextStyle(fontSize: 18, color: Colors.white),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/subscriptions/subscription_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class SubscriptionScreen extends StatelessWidget {
final SubscriptionController controller = Get.put(SubscriptionController());
SubscriptionScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Subscription Plans'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new),
onPressed: () => Get.back(),
),
),
body: SafeArea(
child: Column(
children: [
_buildFrequencyTabs(),
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.plans.isEmpty) {
return const Center(child: Text("No Plans Available"));
}
return RefreshIndicator(
onRefresh: () => controller
.fetchPlans(controller.selectedFrequency.value),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
children: controller.plans.map((plan) {
final features = _extractFeatures(plan);
final currency =
plan['currency']?['symbol'] ?? '';
return Card(
margin: const EdgeInsets.only(bottom: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 3,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
plan['planName'] ?? 'Plan',
fontWeight: 700,
),
MySpacing.height(4),
MyText.bodySmall(
plan['description'] ?? '',
color: Colors.grey[700],
),
MySpacing.height(8),
Text(
"$currency${plan['price'] ?? 0}",
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
MySpacing.height(8),
Text(
"Trial: ${plan['trialDays'] ?? 0} days",
style: const TextStyle(color: Colors.grey),
),
MySpacing.height(12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: features
.map((f) => Row(
children: [
const Icon(Icons.check,
size: 16,
color: Colors.green),
const SizedBox(width: 6),
Expanded(
child: Text(
f,
style: const TextStyle(
fontSize: 13),
)),
],
))
.toList(),
),
MySpacing.height(16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Get.toNamed('/payment', arguments: {
'amount': plan['price'] ?? 0,
'description':
plan['planName'] ?? 'Subscription',
'planId': plan['id'] ?? '',
});
},
child: MyText.bodyMedium(
'Subscribe for $currency${plan['price']}',
color: Colors.white,
),
),
),
],
),
),
);
}).toList(),
),
),
);
}),
),
],
),
),
);
}
// --- Frequency Tab Bar ---
Widget _buildFrequencyTabs() {
return Obx(() {
return Container(
color: Colors.blue[50],
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: controller.frequencies.map((freq) {
final isSelected =
controller.selectedFrequency.value == freq;
return GestureDetector(
onTap: () => controller.fetchPlans(freq),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? Colors.blue : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? Colors.blue : Colors.grey.shade300),
),
child: Text(
_capitalize(freq),
style: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
),
),
),
);
}).toList(),
),
);
});
}
// --- Helper to extract feature names dynamically ---
List<String> _extractFeatures(Map<String, dynamic> plan) {
final features = <String>[];
try {
final modules = plan['features']?['modules'] ?? {};
modules.forEach((key, value) {
if (value is Map && value['enabled'] == true) {
features.add(value['name'] ?? key);
}
});
final supports = plan['features']?['supports'] ?? {};
supports.forEach((k, v) {
if (v == true) {
features.add(k.toString().replaceAll(RegExp(r'([a-z])([A-Z])'), r'\1 \2'));
}
});
} catch (e) {
print("Feature parse error: $e");
}
return features;
}
String _capitalize(String str) =>
str.isEmpty ? '' : str[0].toUpperCase() + str.substring(1);
}

View File

@ -33,6 +33,8 @@ dependencies:
flutter_localizations:
sdk: flutter
razorpay_flutter: 1.4.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
@ -145,3 +147,5 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package