user_view.dart 12.5 KB
Newer Older
1
2
3
4
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

Leon Tappe's avatar
Leon Tappe committed
5
import 'package:digital_3g_common/backend_switcher.dart';
Leon Tappe's avatar
Leon Tappe committed
6
import 'package:digital_3g_common/theme.dart';
Leon Tappe's avatar
Leon Tappe committed
7
import 'package:digital_3g_common/ui.dart';
8
9
10
import 'package:encrypt/encrypt.dart';
import 'package:encrypt/encrypt_io.dart';
import 'package:flutter/material.dart';
Leon Tappe's avatar
Leon Tappe committed
11
import 'package:flutter_bloc/flutter_bloc.dart';
12
import 'package:http/http.dart' as http;
13
import 'package:intl/date_symbol_data_local.dart';
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pointycastle/asymmetric/api.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';

import '../api/auth_api.dart';
import '../api/user_api.dart';

class FormData {
  DateTime? certificationDate;
  TimeOfDay? certificationTime;
  bool isTested;
  bool isRecovered;
  bool isVaccinated;

  FormData({
    this.certificationDate,
    this.certificationTime,
    this.isTested = false,
    this.isRecovered = false,
    this.isVaccinated = false,
  });

  void resetBools() {
    isTested = false;
    isRecovered = false;
    isVaccinated = false;
  }
}

class UserView extends StatefulWidget {
  const UserView({key}) : super(key: key);

  @override
  _UserViewState createState() => _UserViewState();
}

class _UserViewState extends State<UserView> {
Leon Tappe's avatar
Leon Tappe committed
53
  FormData _formData = FormData(
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
    certificationDate: DateTime.now(),
    certificationTime: TimeOfDay.now(),
  );

  final DateFormat _dateFormat = DateFormat.yMd();
  final DateFormat _timeFormat = DateFormat.Hm();

  String _expiryText = '';

  http.Client? _client;

  UserApi? _userApi;

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        PaddedCard(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text('Datenaufnahme', style: Theme.of(context).textTheme.headline5),
              CheckboxListTile(
                title: const Text('Getestet'),
                value: _formData.isTested,
                onChanged: (bool? input) {
80
                  ScaffoldMessenger.of(context).clearSnackBars();
81
82
83
84
85
86
87
88
89
90
91
                  setState(() {
                    _formData.resetBools();
                    _formData.isTested = input ?? false;
                    _expiryText = _makeExpiryString();
                  });
                },
              ),
              CheckboxListTile(
                title: const Text('Genesen'),
                value: _formData.isRecovered,
                onChanged: (bool? input) {
92
                  ScaffoldMessenger.of(context).clearSnackBars();
93
94
95
96
97
98
99
100
101
102
103
                  setState(() {
                    _formData.resetBools();
                    _formData.isRecovered = input ?? false;
                    _expiryText = _makeExpiryString();
                  });
                },
              ),
              CheckboxListTile(
                title: const Text('Geimpft'),
                value: _formData.isVaccinated,
                onChanged: (bool? input) {
104
                  ScaffoldMessenger.of(context).clearSnackBars();
105
106
107
108
109
110
111
                  setState(() {
                    _formData.resetBools();
                    _formData.isVaccinated = input ?? false;
                    _expiryText = _makeExpiryString();
                  });
                },
              ),
112
113
114
115
116
117
118
              if (!_formData.isVaccinated) const Divider(),
              if (!_formData.isVaccinated)
                ListTile(
                    title: const Text('Nachweisdatum'),
                    trailing:
                        Text(_dateFormat.format(_formData.certificationDate ?? DateTime.now())),
                    onTap: () async {
119
                      ScaffoldMessenger.of(context).clearSnackBars();
120
121
                      final date = await showDatePicker(
                        context: context,
Leon Tappe's avatar
Leon Tappe committed
122
123
124
125
                        cancelText: 'Abbrechen',
                        confirmText: 'Bestätigen',
                        fieldLabelText: 'Datum',
                        fieldHintText: 'z.B. 01/04/2021',
126
127
128
129
130
131
132
133
134
135
136
137
                        helpText: 'Nachweisdatum einstellen',
                        initialDate: _formData.certificationDate ?? DateTime.now(),
                        firstDate: DateTime.now().subtract(const Duration(days: 1000)),
                        lastDate: DateTime.now(),
                      );
                      if (date != null) {
                        setState(() {
                          _formData.certificationDate = date;
                          _expiryText = _makeExpiryString();
                        });
                      }
                    }),
138
139
140
141
142
143
              if (_formData.isTested)
                ListTile(
                    title: const Text('Nachweiszeit'),
                    trailing: Text(_formData.certificationTime?.format(context) ??
                        _timeFormat.format(DateTime.now())),
                    onTap: () async {
144
                      ScaffoldMessenger.of(context).clearSnackBars();
145
                      final time = await showTimePicker(
Leon Tappe's avatar
Leon Tappe committed
146
147
148
149
                        cancelText: 'Abbrechen',
                        confirmText: 'Bestätigen',
                        minuteLabelText: 'Minuten',
                        hourLabelText: 'Stunden',
150
                        helpText: 'Nachweiszeit auswählen',
Leon Tappe's avatar
Leon Tappe committed
151
                        initialEntryMode: TimePickerEntryMode.dial,
152
153
154
155
                        context: context,
                        initialTime: _formData.certificationTime ?? TimeOfDay.now(),
                      );
                      if (time != null) {
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
                        final now = DateTime.now();
                        if (_formData.certificationDate?.year == now.year &&
                            _formData.certificationDate?.month == now.month &&
                            _formData.certificationDate?.day == now.day &&
                            (time.hour > now.hour ||
                                (time.hour == now.hour && time.minute > now.minute))) {
                          ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
                              content: Text(
                                  'Der gewählte Testzeitpunkt kann nicht benutzt werden, da er in der Zukunft liegt.')));
                        } else {
                          setState(() {
                            _formData.certificationTime = time;
                            _expiryText = _makeExpiryString();
                          });
                        }
171
172
173
174
175
176
177
178
179
180
181
182
                      }
                    }),
              const Divider(),
              const Text('Gültig bis:'),
              Text(_expiryText, style: Theme.of(context).textTheme.headline6),
            ],
          ),
        ),
        const Divider(),
        Padding(
          padding: const EdgeInsets.all(16.0),
          child: ElevatedButton(
183
184
185
186
            onPressed: _formData.isRecovered || _formData.isVaccinated || _formData.isTested
                ? _onShowCode
                : null,
            child: const Text('QR-Code zeigen', textScaleFactor: 1.2),
187
188
189
190
191
192
193
          ),
        ),
      ],
    );
  }

  @override
194
195
  void didChangeDependencies() async {
    await initializeDateFormatting('de', null);
196
    _client = Provider.of<AuthApi>(context, listen: false).client;
Leon Tappe's avatar
Leon Tappe committed
197
    _userApi = UserApi(_client!, BlocProvider.of<BackendSwitcher>(context).state);
198
199
200
201
202
203
204
205
206
207
208
209
210
211
    super.didChangeDependencies();
  }

  @override
  void initState() {
    super.initState();
    _initKey();
    _expiryText = _makeExpiryString();
  }

  DateTime? _calculateExpiryDate(FormData form) {
    var date = form.certificationDate;
    var time = form.certificationTime;
    if (form.isTested) {
Leon Tappe's avatar
Leon Tappe committed
212
213
      date = DateTime(date!.year, date.month, date.day).add(const Duration(hours: 48));
      date = date.add(Duration(hours: time!.hour, minutes: time.minute));
214
    } else if (form.isRecovered) {
215
      if (DateTime.now().difference(date!) < const Duration(days: 29)) {
Leon Tappe's avatar
Leon Tappe committed
216
        throw Exception('test');
217
218
      }
      date = DateTime(date.year, date.month + 6, date.day - 1, 23, 59);
219
    } else if (form.isVaccinated) {
220
221
222
223
224
225
226
227
228
229
      final now = DateTime.now();
      final thisSs = DateTime(now.year, 4);
      final thisWs = DateTime(now.year, 10);
      if (now.isAfter(thisSs) && now.isBefore(thisWs)) {
        date = thisWs;
      } else if (now.isAfter(thisWs) && now.isAfter(thisSs)) {
        date = DateTime(now.year + 1, 4);
      } else if (now.isBefore(thisWs) && now.isBefore(thisSs)) {
        date = thisSs;
      }
230
231
232
233
234
235
236
    } else {
      return null;
    }
    return date;
  }

  Future<Uint8List?> _getPublicKey() async {
Leon Tappe's avatar
Leon Tappe committed
237
238
    final response =
        await _client!.get(Uri.parse('${BlocProvider.of<BackendSwitcher>(context).state}/pubkey'));
239
240
241
242
243
244
245
246
247
248
249
250
251
252

    if (response.statusCode == 200) {
      return response.bodyBytes;
    } else {
      return null;
    }
  }

  Future<void> _initKey() async {
    if (Provider.of<AuthApi>(context, listen: false).user?.credentials == null) return;

    final basePath = (await getApplicationDocumentsDirectory()).path;
    final keyFile = File('$basePath/public_key.pem');

253
254
255
256
257
258
259
260
    if (await keyFile.exists()) {
      await keyFile.delete();
    }
    await keyFile.create(recursive: true);
    try {
      keyFile.writeAsBytes((await _getPublicKey())!);
    } catch (e) {
      await keyFile.delete();
261
262
263
264
    }
  }

  String _makeExpiryString() {
Leon Tappe's avatar
Leon Tappe committed
265
266
267
268
269
270
271
272
273
274
275
    DateTime? expiry;
    try {
      expiry = _calculateExpiryDate(_formData);
    } catch (e) {
      if (e.toString().contains('test')) {
        return 'Bitte ein gültiges Datum wählen';
      }
    }

    return expiry != null
        ? '${_dateFormat.format(expiry)} ${_timeFormat.format(expiry)}'
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
        : 'Bitte zuerst die Nachweisart wählen';
  }

  Future<String> _onEncrypt() async {
    final basePath = (await getApplicationDocumentsDirectory()).path;

    RSAPublicKey publicKey;
    try {
      publicKey = await parseKeyFromFile<RSAPublicKey>('$basePath/public_key.pem');
    } catch (e) {
      final keyFile = File('$basePath/public_key.pem');
      if (keyFile.existsSync()) {
        await File('$basePath/public_key.pem').delete();
      }
      await keyFile.writeAsBytes((await _getPublicKey())!);
      publicKey = await parseKeyFromFile<RSAPublicKey>('$basePath/public_key.pem');
    }

    final encrypter = Encrypter(RSA(publicKey: publicKey, encoding: RSAEncoding.OAEP));

Leon Tappe's avatar
Leon Tappe committed
296
297
298
299
300
301
302
303
304
    DateTime? expiry;
    try {
      expiry = _calculateExpiryDate(_formData);
    } on Exception catch (e) {
      if (e.toString().contains('test')) {
        ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
            content: Text('Das Nachweisdatum vom PCR Test muss mindestens 28 Tage zurück liegen')));
      }
    }
305
306
307
308
309
310
311
312
313
314
315
316
317

    if (expiry != null) {
      final encrypted =
          encrypter.encrypt(_calculateExpiryDate(_formData)!.toUtc().toIso8601String());
      return base64.encode(encrypted.bytes);
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Endzeitpunkt konnte nicht errechnet werden')));
      return '';
    }
  }

  Future<void> _onShowCode() async {
318
319
    ScaffoldMessenger.of(context).clearSnackBars();

320
321
    final encrypted = await _onEncrypt();

Leon Tappe's avatar
Leon Tappe committed
322
323
    if (encrypted.isEmpty) return;

324
325
    final signed = await _userApi!.signTicket(encrypted);

Leon Tappe's avatar
Leon Tappe committed
326
327
    if (signed == null) {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
Leon Tappe's avatar
Leon Tappe committed
328
329
        content: Text(
            'Code konnte nicht signiert werden. Bitte prüfe die Eingaben auf Korrekt- und Sinnhaftigkeit.'),
Leon Tappe's avatar
Leon Tappe committed
330
331
332
333
        duration: Duration(seconds: 5),
      ));
      return;
    }
334

Leon Tappe's avatar
Leon Tappe committed
335
336
    _resetForm();

337
338
339
340
    await showDialog(
      context: context,
      builder: (BuildContext context) => Dialog(
        insetPadding: const EdgeInsets.all(8.0),
Leon Tappe's avatar
Leon Tappe committed
341
        clipBehavior: Clip.antiAlias,
Leon Tappe's avatar
Leon Tappe committed
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: [
            QrImage(
              data: signed.combinedString,
              version: QrVersions.auto,
              size: MediaQuery.of(context).size.width - 32.0,
            ),
            MaterialButton(
              onPressed: () => Navigator.of(context).pop(),
              child: const Icon(
                Icons.close_rounded,
                size: 48.0,
                color: Colors.white,
              ),
              minWidth: MediaQuery.of(context).size.width - 16.0,
              padding: const EdgeInsets.all(16.0),
              color: primary,
            ),
          ],
363
364
365
366
        ),
      ),
    );
  }
Leon Tappe's avatar
Leon Tappe committed
367
368
369
370
371
372
373
374
375

  void _resetForm() {
    setState(() {
      _formData = FormData(
        certificationDate: DateTime.now(),
        certificationTime: TimeOfDay.now(),
      );
    });
  }
376
}