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 'dart:async';
5 :
6 : import 'package:app_finance/_classes/herald/app_locale.dart';
7 : import 'package:app_finance/_configs/theme_helper.dart';
8 : import 'package:app_finance/_ext/build_context_ext.dart';
9 : import 'package:fints/fints.dart';
10 : import 'package:material_ui/material_ui.dart';
11 : import 'package:flutter/services.dart';
12 :
13 : class FinTsDialogs {
14 : final BuildContext Function() context;
15 :
16 0 : const FinTsDialogs(this.context);
17 :
18 0 : BuildContext? get _context {
19 0 : final ctx = context();
20 0 : return ctx.mounted ? ctx : null;
21 : }
22 :
23 0 : Future<String?> askPin() async {
24 0 : final ctx = _context;
25 : if (ctx == null) {
26 : return null;
27 : }
28 0 : final pin = await showDialog<String>(
29 : context: ctx,
30 : barrierDismissible: false,
31 0 : builder: (_) => const FinTsPinDialog(),
32 : );
33 0 : return pin == null || pin.isEmpty ? null : pin;
34 : }
35 :
36 0 : Future<bool> confirm(String text) async {
37 0 : final ctx = _context;
38 : if (ctx == null) {
39 : return false;
40 : }
41 0 : final result = await showDialog<bool>(
42 : context: ctx,
43 0 : builder: (context) => AlertDialog(
44 0 : title: Text(AppLocale.labels.confirmHeader),
45 0 : content: Text(text),
46 0 : actions: [
47 0 : TextButton(onPressed: () => Navigator.pop(context, false), child: Text(AppLocale.labels.closeTooltip)),
48 0 : FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(AppLocale.labels.ok)),
49 : ],
50 : ),
51 : );
52 : return result ?? false;
53 : }
54 :
55 0 : Future<TanMethod?> selectTanMethod(List<TanMethod> methods) => _select<TanMethod>(
56 0 : AppLocale.labels.finTsTanMethod,
57 : methods,
58 0 : (method) => method.name,
59 : );
60 :
61 0 : Future<TanMedium?> selectTanMedium(List<TanMedium> media) => _select<TanMedium>(
62 0 : AppLocale.labels.finTsTanMedium,
63 : media,
64 0 : (medium) => medium.name ?? medium.mobileNumberMasked ?? medium.cardNumber ?? medium.mediumClass,
65 : );
66 :
67 0 : Future<T?> _select<T>(String title, List<T> options, String Function(T) label) async {
68 0 : final ctx = _context;
69 : if (ctx == null) {
70 : return null;
71 : }
72 0 : return showDialog<T>(
73 : context: ctx,
74 : barrierDismissible: false,
75 0 : builder: (context) => SimpleDialog(
76 0 : title: Text(title),
77 0 : children: [
78 0 : for (final option in options)
79 0 : SimpleDialogOption(
80 0 : onPressed: () => Navigator.pop(context, option),
81 0 : child: Text(label(option)),
82 : ),
83 : ],
84 : ),
85 : );
86 : }
87 :
88 0 : Future<TanAnswer> askTan(TanChallenge challenge) async {
89 0 : final ctx = _context;
90 : if (ctx == null) {
91 : return TanAnswer.cancelled;
92 : }
93 0 : final answer = await showDialog<TanAnswer>(
94 : context: ctx,
95 : barrierDismissible: false,
96 0 : builder: (_) => FinTsTanDialog(challenge: challenge),
97 : );
98 : return answer ?? TanAnswer.cancelled;
99 : }
100 : }
101 :
102 : class FinTsPinDialog extends StatefulWidget {
103 1 : const FinTsPinDialog({super.key});
104 :
105 0 : @override
106 0 : FinTsPinDialogState createState() => FinTsPinDialogState();
107 : }
108 :
109 : class FinTsPinDialogState extends State<FinTsPinDialog> {
110 : final controller = TextEditingController();
111 :
112 0 : @override
113 : void dispose() {
114 0 : controller.dispose();
115 0 : super.dispose();
116 : }
117 :
118 0 : @override
119 : Widget build(BuildContext context) {
120 0 : return AlertDialog(
121 0 : title: Text(AppLocale.labels.finTsPin),
122 0 : content: TextField(
123 : key: const ValueKey('fints_pin'),
124 0 : controller: controller,
125 : obscureText: true,
126 : autofocus: true,
127 : enableSuggestions: false,
128 : autocorrect: false,
129 0 : decoration: InputDecoration(helperText: AppLocale.labels.finTsPinHint, helperMaxLines: 2),
130 0 : onSubmitted: (value) => Navigator.pop(context, value),
131 : ),
132 0 : actions: [
133 0 : TextButton(onPressed: () => Navigator.pop(context), child: Text(AppLocale.labels.closeTooltip)),
134 0 : FilledButton(
135 0 : onPressed: () => Navigator.pop(context, controller.text),
136 0 : child: Text(AppLocale.labels.integrationConnect),
137 : ),
138 : ],
139 : );
140 : }
141 : }
142 :
143 : class FinTsTanDialog extends StatefulWidget {
144 : final TanChallenge challenge;
145 :
146 0 : const FinTsTanDialog({super.key, required this.challenge});
147 :
148 0 : @override
149 0 : FinTsTanDialogState createState() => FinTsTanDialogState();
150 : }
151 :
152 : class FinTsTanDialogState extends State<FinTsTanDialog> {
153 : final controller = TextEditingController();
154 :
155 0 : @override
156 : void initState() {
157 0 : super.initState();
158 0 : widget.challenge.resolved?.then((_) {
159 0 : if (mounted) {
160 0 : Navigator.pop(context, TanAnswer.approved);
161 : }
162 : });
163 : }
164 :
165 0 : @override
166 : void dispose() {
167 0 : controller.dispose();
168 0 : super.dispose();
169 : }
170 :
171 0 : @override
172 : Widget build(BuildContext context) {
173 0 : final challenge = widget.challenge;
174 0 : final method = challenge.method;
175 0 : final image = challenge.image;
176 0 : final flicker = challenge.flickerCode;
177 0 : return AlertDialog(
178 0 : title: Text(AppLocale.labels.finTsTanHeadline),
179 0 : content: SingleChildScrollView(
180 0 : child: Column(
181 : mainAxisSize: MainAxisSize.min,
182 : crossAxisAlignment: CrossAxisAlignment.start,
183 0 : children: [
184 0 : Text(method.name, style: context.textTheme.titleSmall),
185 : ThemeHelper.hIndent,
186 0 : if (challenge.plainMessage?.isNotEmpty == true) SelectableText(challenge.plainMessage!),
187 0 : if (image != null) ...[
188 : ThemeHelper.hIndent2x,
189 0 : Center(child: Image.memory(image.bytes, width: 240, fit: BoxFit.contain)),
190 : ],
191 0 : if (flicker != null) ...[
192 : ThemeHelper.hIndent2x,
193 0 : Center(child: FinTsFlickerCode(frames: flicker.frames())),
194 : ],
195 0 : ThemeHelper.hIndent2x,
196 0 : if (challenge.isDecoupled)
197 0 : const Center(child: CircularProgressIndicator())
198 : else
199 0 : TextField(
200 : key: const ValueKey('fints_tan'),
201 0 : controller: controller,
202 : autofocus: true,
203 : enableSuggestions: false,
204 : autocorrect: false,
205 0 : maxLength: method.maxTanLength,
206 0 : keyboardType: method.numericTan ? TextInputType.number : TextInputType.text,
207 0 : inputFormatters: method.numericTan ? [FilteringTextInputFormatter.digitsOnly] : null,
208 0 : decoration: InputDecoration(labelText: AppLocale.labels.finTsTan),
209 0 : onSubmitted: (value) => Navigator.pop(context, TanAnswer.tan(value)),
210 : ),
211 : ],
212 : ),
213 : ),
214 0 : actions: [
215 0 : TextButton(
216 0 : onPressed: () => Navigator.pop(context, TanAnswer.cancelled),
217 0 : child: Text(AppLocale.labels.closeTooltip),
218 : ),
219 0 : if (!challenge.isDecoupled)
220 0 : FilledButton(
221 0 : onPressed: () => Navigator.pop(context, TanAnswer.tan(controller.text)),
222 0 : child: Text(AppLocale.labels.ok),
223 : )
224 0 : else if (challenge.manualConfirmation)
225 0 : FilledButton(
226 0 : onPressed: () => Navigator.pop(context, TanAnswer.approved),
227 0 : child: Text(AppLocale.labels.finTsApproved),
228 : ),
229 : ],
230 : );
231 : }
232 : }
233 :
234 : class FinTsFlickerCode extends StatefulWidget {
235 : final List<List<bool>> frames;
236 : final Duration interval;
237 :
238 0 : const FinTsFlickerCode({
239 : super.key,
240 : required this.frames,
241 : this.interval = const Duration(milliseconds: 50),
242 : });
243 :
244 0 : @override
245 0 : FinTsFlickerCodeState createState() => FinTsFlickerCodeState();
246 : }
247 :
248 : class FinTsFlickerCodeState extends State<FinTsFlickerCode> {
249 : late final Timer _timer;
250 : int _index = 0;
251 :
252 0 : @override
253 : void initState() {
254 0 : super.initState();
255 0 : _timer = Timer.periodic(widget.interval, (_) {
256 0 : if (widget.frames.isNotEmpty) {
257 0 : setState(() => _index = (_index + 1) % widget.frames.length);
258 : }
259 : });
260 : }
261 :
262 0 : @override
263 : void dispose() {
264 0 : _timer.cancel();
265 0 : super.dispose();
266 : }
267 :
268 0 : @override
269 : Widget build(BuildContext context) {
270 0 : final frame = widget.frames.isEmpty ? List.filled(5, false) : widget.frames[_index];
271 0 : return Container(
272 : color: Colors.black,
273 : padding: const EdgeInsets.all(8),
274 0 : child: Row(
275 : mainAxisSize: MainAxisSize.min,
276 0 : children: [
277 0 : for (final bit in frame)
278 0 : Container(
279 : width: 40,
280 : height: 120,
281 : margin: const EdgeInsets.symmetric(horizontal: 4),
282 : color: bit ? Colors.white : Colors.black,
283 : ),
284 : ],
285 : ),
286 : );
287 : }
288 : }
|