Line data Source code
1 : // Copyright 2026 The terCAD team. All rights reserved.
2 : // Use of this source code is governed by a CC BY-NC-ND 4.0 license that can be found in the LICENSE file.
3 :
4 : import 'package:app_finance/_classes/controller/encryption_handler.dart';
5 : import 'package:app_finance/_classes/controller/focus_controller.dart';
6 : import 'package:app_finance/_classes/herald/app_design.dart';
7 : import 'package:app_finance/_classes/herald/app_locale.dart';
8 : import 'package:app_finance/_classes/storage/app_preferences.dart';
9 : import 'package:app_finance/_configs/custom_color_scheme.dart';
10 : import 'package:app_finance/_configs/theme_helper.dart';
11 : import 'package:app_finance/_ext/build_context_ext.dart';
12 : import 'package:app_finance/design/button/full_sized_button_widget.dart';
13 : import 'package:app_finance/design/form/checkbox_input.dart';
14 : import 'package:app_finance/design/generic/notification_bar.dart';
15 : import 'package:app_finance/design/wrapper/input_wrapper.dart';
16 : import 'package:app_finance/design/wrapper/single_scroll_wrapper.dart';
17 : import 'package:app_finance/pages/_interfaces/abstract_page_state.dart';
18 : import 'package:flutter/foundation.dart';
19 : import 'package:material_ui/material_ui.dart';
20 : import 'package:flutter/services.dart';
21 : import 'package:local_auth/local_auth.dart';
22 : import 'package:simple_totp_auth/simple_totp_auth.dart';
23 :
24 : class SecurityPage extends StatefulWidget {
25 2 : const SecurityPage({super.key});
26 :
27 0 : @override
28 0 : SecurityPageState createState() => SecurityPageState();
29 : }
30 :
31 : class SecurityPageState extends AbstractPageState<SecurityPage> {
32 : late FocusController focus;
33 : late TextEditingController password;
34 : late TextEditingController passwordRepeat;
35 : late TextEditingController otpCode;
36 : bool isBio = AppPreferences.get(AppPreferences.prefIsBio) == AppPreferences.isActive;
37 : bool canAuthenticateBio = false;
38 :
39 0 : @override
40 : void initState() {
41 0 : focus = FocusController();
42 0 : password = TextEditingController();
43 0 : passwordRepeat = TextEditingController();
44 0 : otpCode = TextEditingController();
45 0 : super.initState();
46 : }
47 :
48 0 : @override
49 : void dispose() {
50 0 : focus.dispose();
51 0 : password.dispose();
52 0 : passwordRepeat.dispose();
53 0 : otpCode.dispose();
54 0 : super.dispose();
55 : }
56 :
57 0 : bool get isOtpActive => AppPreferences.get(AppPreferences.prefIsOTP) == AppPreferences.isActive;
58 :
59 0 : Future<void> checkBioAuthentication() async {
60 0 : final bio = LocalAuthentication();
61 : bool isAvailable = false;
62 : try {
63 0 : final canCheckBiometrics = await bio.canCheckBiometrics;
64 0 : final isDeviceSupported = await bio.isDeviceSupported();
65 : isAvailable = canCheckBiometrics && isDeviceSupported;
66 0 : } on Exception catch (_) {
67 : isAvailable = false;
68 : }
69 0 : setState(() => canAuthenticateBio = isAvailable);
70 : }
71 :
72 0 : @override
73 : String getButtonName() {
74 0 : return isOtpActive ? AppLocale.labels.deactivateAction : AppLocale.labels.saveSettingsTooltip;
75 : }
76 :
77 0 : @override
78 0 : Widget buildButton(BuildContext context, BoxConstraints constraints) => FullSizedButtonWidget(
79 : constraints: constraints,
80 0 : controller: focus,
81 0 : onPressed: () => isOtpActive ? triggerDeactivateButton() : triggerActionButton(),
82 0 : title: getButtonName(),
83 0 : icon: isOtpActive ? Icons.do_disturb_alt : Icons.save,
84 : );
85 :
86 0 : void triggerActionButton() {
87 0 : if (password.text.isEmpty) {
88 0 : NotificationBar.showSnackBar(context, AppLocale.labels.securePasswordEmpty, true);
89 : return;
90 : }
91 0 : if (password.text != passwordRepeat.text) {
92 0 : NotificationBar.showSnackBar(context, AppLocale.labels.securePasswordNotMatch, true);
93 : return;
94 : }
95 :
96 0 : final secret = AppPreferences.get(AppPreferences.prefPrivacyKey) ?? '';
97 0 : final totp = TOTP(secret: secret);
98 0 : final code = totp.now();
99 0 : if (otpCode.text.isNotEmpty && otpCode.text != code) {
100 0 : NotificationBar.showSnackBar(context, AppLocale.labels.secureOtpCodeInvalid, true);
101 : return;
102 : }
103 :
104 0 : AppPreferences.set(AppPreferences.prefIsOTP, AppPreferences.isActive);
105 0 : AppPreferences.set(AppPreferences.prefIsBio, isBio ? AppPreferences.isActive : AppPreferences.isInactive);
106 0 : AppPreferences.set(AppPreferences.prefRecoveryKey, EncryptionHandler.getHashString(password.text));
107 0 : setState(() {
108 0 : password.clear();
109 0 : passwordRepeat.clear();
110 0 : otpCode.clear();
111 : });
112 0 : NotificationBar.showSnackBar(context, AppLocale.labels.settingsSaved);
113 : }
114 :
115 0 : void triggerDeactivateButton() {
116 0 : if (password.text.isEmpty) {
117 0 : NotificationBar.showSnackBar(context, AppLocale.labels.securePasswordEmpty, true);
118 : return;
119 : }
120 0 : final key = AppPreferences.get(AppPreferences.prefRecoveryKey) ?? '';
121 0 : if (EncryptionHandler.getHashString(password.text) != key) {
122 0 : NotificationBar.showSnackBar(context, AppLocale.labels.securePasswordNotMatch, true);
123 : return;
124 : }
125 0 : AppPreferences.set(AppPreferences.prefIsOTP, AppPreferences.isInactive);
126 0 : AppPreferences.set(AppPreferences.prefIsBio, AppPreferences.isInactive);
127 0 : setState(() {
128 0 : isBio = false;
129 0 : password.clear();
130 : });
131 0 : NotificationBar.showSnackBar(context, AppLocale.labels.secureOtpDeactivated);
132 : }
133 :
134 0 : @override
135 0 : String getTitle() => AppLocale.labels.securityHeadline;
136 :
137 0 : @override
138 : Widget buildContent(BuildContext context, BoxConstraints constraints) {
139 0 : WidgetsBinding.instance.addPostFrameCallback((_) => checkBioAuthentication());
140 0 : final indent = ThemeHelper.getIndent();
141 0 : final textTheme = context.textTheme;
142 0 : final secret = AppPreferences.get(AppPreferences.prefPrivacyKey) ?? '';
143 0 : final totp = TOTP(secret: secret);
144 0 : final totpUrl = totp.generateOTPAuthURI(issuer: 'Fingrom', account: 'support@tercad.pl');
145 0 : return SingleScrollWrapper(
146 0 : controller: focus,
147 0 : child: Padding(
148 0 : padding: EdgeInsets.all(indent),
149 0 : child: Column(
150 0 : crossAxisAlignment: AppDesign.getAlignment(),
151 0 : children: [
152 0 : InputWrapper.text(
153 0 : title: AppLocale.labels.securePassword,
154 0 : controller: password,
155 : isRequired: true,
156 : inputType: TextInputType.visiblePassword,
157 : ),
158 0 : if (!isOtpActive) ...[
159 0 : InputWrapper.text(
160 0 : title: AppLocale.labels.securePasswordRepeat,
161 0 : controller: passwordRepeat,
162 : isRequired: true,
163 : inputType: TextInputType.visiblePassword,
164 : ),
165 : if (!kIsWeb)
166 0 : CheckboxInput(
167 0 : label: AppLocale.labels.secureBioPromptTitle,
168 0 : value: isBio,
169 0 : onChanged: (value) => setState(() => isBio = value),
170 : constraints: constraints,
171 0 : enabled: canAuthenticateBio,
172 : ),
173 0 : TOTPQrWidget(
174 : secret: secret,
175 : issuer: 'Fingrom',
176 : accountName: 'support@tercad.pl',
177 : width: 200,
178 : height: 200,
179 : color: Colors.white,
180 : ),
181 0 : Container(
182 0 : padding: EdgeInsets.all(indent),
183 0 : color: context.colorScheme.fieldBackground,
184 0 : child: SelectableText(
185 : totpUrl,
186 0 : style: textTheme.bodyLarge,
187 0 : onTap: () {
188 0 : Clipboard.setData(ClipboardData(text: totpUrl));
189 0 : ScaffoldMessenger.of(context).showSnackBar(
190 0 : SnackBar(content: Text(AppLocale.labels.copiedToClipboard)),
191 : );
192 : },
193 : ),
194 : ),
195 0 : ThemeHelper.hIndent2x,
196 0 : InputWrapper.text(
197 0 : title: AppLocale.labels.secureOtpCode,
198 0 : controller: otpCode,
199 : ),
200 0 : ThemeHelper.hIndent2x,
201 : ],
202 : ],
203 : ),
204 : ),
205 : );
206 : }
207 : }
|