2025-01-28 00:52:44 +08:00
|
|
|
import 'package:dropdown_button2/dropdown_button2.dart';
|
|
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
2025-01-28 19:55:35 +08:00
|
|
|
import 'package:gap/gap.dart';
|
2025-01-28 00:52:44 +08:00
|
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import 'package:provider/provider.dart';
|
2025-01-28 19:55:35 +08:00
|
|
|
import 'package:qr_flutter/qr_flutter.dart';
|
2025-01-28 00:52:44 +08:00
|
|
|
import 'package:styled_widget/styled_widget.dart';
|
|
|
|
import 'package:surface/providers/sn_network.dart';
|
|
|
|
import 'package:surface/types/auth.dart';
|
|
|
|
import 'package:surface/widgets/dialog.dart';
|
|
|
|
import 'package:surface/widgets/loading_indicator.dart';
|
|
|
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
|
|
|
|
2025-01-28 19:55:35 +08:00
|
|
|
final Map<int, (String, String, IconData)> kFactorTypes = {
|
2025-01-28 00:52:44 +08:00
|
|
|
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
|
|
|
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
|
|
|
2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
|
|
|
3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active),
|
|
|
|
};
|
|
|
|
|
|
|
|
class FactorSettingsScreen extends StatefulWidget {
|
|
|
|
const FactorSettingsScreen({super.key});
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<FactorSettingsScreen> createState() => _FactorSettingsScreenState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
|
2025-01-28 19:55:35 +08:00
|
|
|
bool _isBusy = false;
|
2025-01-28 00:52:44 +08:00
|
|
|
List<SnAuthFactor>? _factors;
|
|
|
|
|
|
|
|
Future<void> _fetchFactors() async {
|
|
|
|
try {
|
2025-01-28 19:55:35 +08:00
|
|
|
setState(() => _isBusy = true);
|
2025-01-28 00:52:44 +08:00
|
|
|
final sn = context.read<SnNetworkProvider>();
|
|
|
|
final resp = await sn.client.get('/cgi/id/users/me/factors');
|
|
|
|
_factors = List<SnAuthFactor>.from(
|
|
|
|
resp.data?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)).toList() ?? [],
|
|
|
|
);
|
|
|
|
} catch (err) {
|
|
|
|
if (!mounted) return;
|
|
|
|
context.showErrorDialog(err);
|
|
|
|
} finally {
|
2025-01-28 19:55:35 +08:00
|
|
|
setState(() => _isBusy = false);
|
2025-01-28 00:52:44 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
_fetchFactors();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return AppScaffold(
|
|
|
|
appBar: AppBar(
|
|
|
|
leading: PageBackButton(),
|
|
|
|
title: Text('screenFactorSettings').tr(),
|
|
|
|
),
|
|
|
|
body: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
LoadingIndicator(
|
2025-01-28 19:55:35 +08:00
|
|
|
isActive: _isBusy,
|
2025-01-28 00:52:44 +08:00
|
|
|
),
|
|
|
|
ListTile(
|
|
|
|
title: Text('authFactorAdd').tr(),
|
|
|
|
subtitle: Text('authFactorAddSubtitle').tr(),
|
|
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
|
|
leading: const Icon(Symbols.add),
|
|
|
|
trailing: const Icon(Symbols.chevron_right),
|
|
|
|
onTap: () {
|
|
|
|
showDialog(
|
|
|
|
context: context,
|
|
|
|
builder: (context) => _FactorNewDialog(
|
|
|
|
currentlyHave: _factors!,
|
|
|
|
),
|
|
|
|
).then((val) {
|
|
|
|
if (val == true) _fetchFactors();
|
|
|
|
});
|
|
|
|
},
|
|
|
|
),
|
|
|
|
const Divider(height: 1),
|
|
|
|
Expanded(
|
|
|
|
child: MediaQuery.removePadding(
|
|
|
|
context: context,
|
|
|
|
removeTop: true,
|
|
|
|
child: RefreshIndicator(
|
|
|
|
onRefresh: _fetchFactors,
|
|
|
|
child: ListView.builder(
|
|
|
|
itemCount: _factors?.length ?? 0,
|
|
|
|
itemBuilder: (context, idx) {
|
|
|
|
final ele = _factors![idx];
|
|
|
|
return ListTile(
|
2025-01-28 19:55:35 +08:00
|
|
|
title: Text(kFactorTypes[ele.type]!.$1).tr(),
|
|
|
|
subtitle: Text(kFactorTypes[ele.type]!.$2).tr(),
|
|
|
|
contentPadding: const EdgeInsets.only(left: 24, right: 12),
|
|
|
|
leading: Icon(kFactorTypes[ele.type]!.$3),
|
|
|
|
trailing: IconButton(
|
|
|
|
icon: const Icon(Symbols.close),
|
|
|
|
onPressed: ele.type > 0
|
|
|
|
? () {
|
|
|
|
context
|
|
|
|
.showConfirmDialog(
|
|
|
|
'authFactorDelete'.tr(),
|
|
|
|
'authFactorDeleteDescription'.tr(args: [kFactorTypes[ele.type]!.$1.tr()]),
|
|
|
|
)
|
|
|
|
.then((val) async {
|
|
|
|
if (!val) return;
|
|
|
|
try {
|
|
|
|
if (!context.mounted) return;
|
|
|
|
final sn = context.read<SnNetworkProvider>();
|
|
|
|
await sn.client.delete('/cgi/id/users/me/factors/${ele.id}');
|
|
|
|
_fetchFactors();
|
|
|
|
} catch (err) {
|
|
|
|
if (!context.mounted) return;
|
|
|
|
context.showErrorDialog(err);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
: null,
|
|
|
|
),
|
2025-01-28 00:52:44 +08:00
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _FactorNewDialog extends StatefulWidget {
|
|
|
|
final List<SnAuthFactor> currentlyHave;
|
|
|
|
|
|
|
|
const _FactorNewDialog({required this.currentlyHave});
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<_FactorNewDialog> createState() => _FactorNewDialogState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _FactorNewDialogState extends State<_FactorNewDialog> {
|
|
|
|
int? _factorType;
|
|
|
|
bool _isBusy = false;
|
|
|
|
|
|
|
|
Future<void> _submit() async {
|
|
|
|
try {
|
|
|
|
setState(() => _isBusy = true);
|
|
|
|
final sn = context.read<SnNetworkProvider>();
|
|
|
|
final resp = await sn.client.post('/cgi/id/users/me/factors', data: {
|
|
|
|
'type': _factorType,
|
|
|
|
});
|
2025-01-28 19:55:35 +08:00
|
|
|
final factor = SnAuthFactor.fromJson(resp.data);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (factor.type == 2) {
|
|
|
|
await showModalBottomSheet(
|
|
|
|
context: context,
|
|
|
|
builder: (context) => _FactorTotpFactorDialog(factor: factor),
|
|
|
|
);
|
|
|
|
}
|
2025-01-28 00:52:44 +08:00
|
|
|
if (!mounted) return;
|
|
|
|
Navigator.of(context).pop(true);
|
|
|
|
} catch (err) {
|
|
|
|
if (!mounted) return;
|
|
|
|
context.showErrorDialog(err);
|
|
|
|
} finally {
|
|
|
|
setState(() => _isBusy = false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return AlertDialog(
|
|
|
|
title: Text('authFactorAdd').tr(),
|
|
|
|
content: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
DropdownButtonHideUnderline(
|
|
|
|
child: DropdownButton2<int>(
|
|
|
|
hint: Text(
|
|
|
|
'Select Item',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 14,
|
|
|
|
),
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
),
|
|
|
|
value: _factorType,
|
2025-01-28 19:55:35 +08:00
|
|
|
items: kFactorTypes.entries.map(
|
2025-01-28 00:52:44 +08:00
|
|
|
(ele) {
|
|
|
|
final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key);
|
|
|
|
return DropdownMenuItem<int>(
|
|
|
|
enabled: !contains,
|
|
|
|
value: ele.key,
|
|
|
|
child: Text(
|
|
|
|
ele.value.$1.tr(),
|
|
|
|
style: const TextStyle(
|
|
|
|
fontSize: 14,
|
|
|
|
),
|
|
|
|
).opacity(contains ? 0.75 : 1),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
).toList(),
|
|
|
|
onChanged: (val) => setState(() {
|
|
|
|
_factorType = val;
|
|
|
|
}),
|
|
|
|
buttonStyleData: ButtonStyleData(
|
|
|
|
height: 50,
|
|
|
|
padding: const EdgeInsets.only(left: 14, right: 14),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
borderRadius: BorderRadius.circular(14),
|
|
|
|
border: Border.all(
|
|
|
|
color: Theme.of(context).dividerColor,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
actions: [
|
|
|
|
TextButton(
|
|
|
|
onPressed: _isBusy ? null : () => Navigator.of(context).pop(),
|
|
|
|
child: Text('dialogCancel').tr(),
|
|
|
|
),
|
|
|
|
TextButton(
|
|
|
|
onPressed: _isBusy ? null : () => _submit(),
|
|
|
|
child: Text('dialogConfirm').tr(),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
2025-01-28 19:55:35 +08:00
|
|
|
|
|
|
|
class _FactorTotpFactorDialog extends StatelessWidget {
|
|
|
|
final SnAuthFactor factor;
|
|
|
|
|
2025-01-29 15:18:35 +08:00
|
|
|
const _FactorTotpFactorDialog({required this.factor});
|
2025-01-28 19:55:35 +08:00
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Center(
|
|
|
|
child: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
children: [
|
|
|
|
Center(
|
|
|
|
child: Text(
|
|
|
|
'totpPostSetup',
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
|
|
).tr().width(280),
|
|
|
|
),
|
|
|
|
const Gap(4),
|
|
|
|
Center(
|
|
|
|
child: Text(
|
|
|
|
'totpPostSetupDescription',
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
|
|
).tr().width(280),
|
|
|
|
),
|
|
|
|
const Gap(16),
|
|
|
|
QrImageView(
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
data: factor.config!['url'],
|
|
|
|
errorCorrectionLevel: QrErrorCorrectLevel.H,
|
|
|
|
version: QrVersions.auto,
|
|
|
|
size: 160,
|
|
|
|
gapless: true,
|
|
|
|
eyeStyle: QrEyeStyle(
|
|
|
|
eyeShape: QrEyeShape.circle,
|
|
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
|
|
),
|
|
|
|
dataModuleStyle: QrDataModuleStyle(
|
|
|
|
dataModuleShape: QrDataModuleShape.square,
|
|
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const Gap(16),
|
|
|
|
Center(
|
|
|
|
child: Text(
|
|
|
|
'totpNeverShare',
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
|
|
).tr().bold().width(280),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|