update onboarding and providers

fdroid
Kherel 2020-12-06 08:28:31 +01:00
parent 80dee9dbab
commit a112d873eb
10 changed files with 762 additions and 235 deletions

View File

@ -0,0 +1,40 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
import 'package:selfprivacy/logic/models/service.dart';
import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart';
enum ProviderTypes {
server,
domain,
backup,
}
class ProviderModel extends Equatable {
const ProviderModel({this.state, this.type});
final ServiceStateType state;
final ProviderTypes type;
ProviderModel updateState(ServiceStateType newState) => ProviderModel(
state: newState,
type: type,
);
@override
List<Object> get props => [state, type];
IconData get icon {
switch (type) {
case ProviderTypes.server:
return BrandIcons.server;
case ProviderTypes.domain:
return BrandIcons.globe;
break;
case ProviderTypes.backup:
return BrandIcons.save;
}
return null;
}
}

View File

@ -5,7 +5,6 @@ enum ServiceTypes {
messanger,
mail,
passwordManager,
backup,
github,
cloud,
}

View File

@ -20,7 +20,7 @@ void main() {
);
}
var _showOnbording = false;
var _showOnbording = true;
class MyApp extends StatelessWidget {
@override

View File

@ -99,7 +99,9 @@ class _RisedButton extends StatelessWidget {
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: ColoredBox(
color: Theme.of(context).primaryColor,
color: onPressed == null
? BrandColors.gray2
: Theme.of(context).primaryColor,
child: Material(
color: Colors.transparent,
child: InkWell(

View File

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:selfprivacy/config/brand_colors.dart';
class DotsIndicator extends StatelessWidget {
const DotsIndicator({
Key key,
@required this.activeIndex,
@required this.count,
}) : super(key: key);
final int activeIndex;
final int count;
@override
Widget build(BuildContext context) {
var dots = List.generate(
count,
(index) => Container(
margin: EdgeInsets.symmetric(horizontal: 5, vertical: 10),
height: 10,
width: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == activeIndex ? BrandColors.blue : BrandColors.gray2,
),
),
);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: dots,
);
}
}

View File

@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:selfprivacy/config/brand_theme.dart';
import 'package:selfprivacy/config/text_themes.dart';
import 'package:selfprivacy/ui/components/brand_button/brand_button.dart';
import 'package:selfprivacy/ui/components/brand_card/brand_card.dart';
import 'package:selfprivacy/ui/components/brand_modal_sheet/brand_modal_sheet.dart';
import 'package:selfprivacy/ui/components/brand_span_button/brand_span_button.dart';
import 'package:selfprivacy/utils/extensions/text_extension.dart';
class OnboardingPage extends StatelessWidget {
const OnboardingPage({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView(
padding: brandPagePadding1,
children: [
Text('Начало').caption,
Text('SelfPrivacy').h1,
SizedBox(
height: 10,
),
RichText(
text: TextSpan(
children: [
TextSpan(
text:
'Для устойчивости и приватности требует много учёток. Полная инструкция на ',
style: body2Style,
),
BrandSpanButton.link(
text: 'selfprivacy.org/start',
urlString: 'https://selfprivacy.org/start',
),
],
),
),
SizedBox(height: 50),
BrandCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/logos/hetzner.png'),
SizedBox(height: 10),
Text('1. Подключите сервер Hetzner').h2,
SizedBox(height: 10),
Text('Здесь будут жить наши данные и SelfPrivacy-сервисы')
.body2,
_MockForm(
hintText: 'Hetzner API Token',
),
SizedBox(height: 20),
BrandButton.text(
onPressed: () => _showModal(context, _HowHetzner()),
title: 'Как получить API Token',
),
],
),
),
BrandCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/logos/namecheap.png'),
SizedBox(height: 10),
Text('2. Настройте домен ').h2,
SizedBox(height: 10),
RichText(
text: TextSpan(
children: [
TextSpan(
text: 'Зарегистрируйте домен в ',
style: body2Style,
),
BrandSpanButton.link(
text: 'NameCheap',
urlString: 'https://www.namecheap.com',
),
TextSpan(
text:
' или у любого другого регистратора. После этого настройте его на DNS-сервер CloudFlare',
style: body2Style,
),
],
),
),
_MockForm(
hintText: 'Домен, например, selfprivacy.org',
submitButtonText: 'Проверить DNS',
),
SizedBox(height: 20),
BrandButton.text(
onPressed: () {},
title: 'Как настроить DNS CloudFlare',
),
],
),
),
BrandCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/logos/cloudflare.png'),
SizedBox(height: 10),
Text('3. Подключите CloudFlare DNS').h2,
SizedBox(height: 10),
Text('Для управления DNS вашего домена').body2,
_MockForm(
hintText: 'CloudFlare API Token',
),
SizedBox(height: 20),
BrandButton.text(
onPressed: () {},
title: 'Как получить API Token',
),
],
),
),
BrandCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/logos/aws.png'),
SizedBox(height: 10),
Text('4. Подключите Amazon AWS для бекапа').h2,
SizedBox(height: 10),
Text('IaaS-провайдер, для бесплатного хранения резервных копии ваших данных в зашифрованном виде')
.body2,
_MockForm(
hintText: 'Amazon AWS Access Key',
),
SizedBox(height: 20),
BrandButton.text(
onPressed: () {},
title: 'Как получить API Token',
),
],
),
)
],
),
);
}
void _showModal(BuildContext context, Widget widget) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return widget;
},
);
}
}
class _HowHetzner extends StatelessWidget {
const _HowHetzner({
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return BrandModalSheet(
child: Column(
children: [
SizedBox(height: 40),
Text('Как получить Hetzner API Token').h2,
SizedBox(height: 20),
RichText(
text: TextSpan(
children: [
TextSpan(
text: '1 Переходим по ссылке ',
style: body1Style,
),
BrandSpanButton.link(
text: 'hetzner.com/sdfsdfsdfsdf',
urlString: 'https://hetzner.com/sdfsdfsdfsdf',
),
TextSpan(
text: '''
2 Заходим в созданный нами проект. Если такового - нет, значит создаём.
3 Наводим мышкой на боковую панель. Она должна раскрыться, показав нам пункты меню. Нас интересует последний Security (с иконкой ключика).
4 Далее, в верхней части интерфейса видим примерно такой список: SSH Keys, API Tokens, Certificates, Members. Нам нужен API Tokens. Переходим по нему.
5 В правой части интерфейса, нас будет ожидать кнопка Generate API token. Если же вы используете мобильную версию сайта, в нижнем правом углу вы увидите красный плюсик. Нажимаем на эту кнопку.
6 В поле Description, даём нашему токену название (это может быть любое название, которые вам нравиться. Сути оно не меняет.
''',
style: body1Style,
),
],
),
),
],
),
);
}
}
class _MockForm extends StatelessWidget {
const _MockForm({
Key key,
@required this.hintText,
this.submitButtonText = 'Подключить',
}) : super(key: key);
final String hintText;
final String submitButtonText;
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(height: 20),
TextField(decoration: InputDecoration(hintText: hintText)),
SizedBox(height: 20),
BrandButton.rised(onPressed: () {}, title: submitButtonText),
],
);
}
}

View File

@ -5,16 +5,56 @@ import 'package:selfprivacy/ui/components/brand_button/brand_button.dart';
import 'package:selfprivacy/ui/components/brand_card/brand_card.dart';
import 'package:selfprivacy/ui/components/brand_modal_sheet/brand_modal_sheet.dart';
import 'package:selfprivacy/ui/components/brand_span_button/brand_span_button.dart';
import 'package:selfprivacy/ui/pages/dots_indicator/dots_indicator.dart';
import 'package:selfprivacy/ui/pages/rootRoute.dart';
import 'package:selfprivacy/utils/extensions/text_extension.dart';
import 'package:selfprivacy/utils/route_transitions/basic.dart';
class OnboardingPage extends StatelessWidget {
class OnboardingPage extends StatefulWidget {
const OnboardingPage({Key key}) : super(key: key);
@override
_OnboardingPageState createState() => _OnboardingPageState();
}
class _OnboardingPageState extends State<OnboardingPage> {
PageController controller;
var currentPage = 0;
@override
void initState() {
controller = PageController(
initialPage: 0,
)..addListener(() {
if (currentPage != controller.page.toInt()) {
setState(() {
currentPage = controller.page.toInt();
});
}
});
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {});
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
var steps = getSteps();
return SafeArea(
child: Scaffold(
body: ListView(
shrinkWrap: true,
children: [
Padding(
padding: brandPagePadding1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Начало').caption,
Text('SelfPrivacy').h1,
@ -36,21 +76,53 @@ class OnboardingPage extends StatelessWidget {
],
),
),
],
),
),
Container(
height: 480,
child: PageView.builder(
physics: NeverScrollableScrollPhysics(),
allowImplicitScrolling: false,
controller: controller,
itemBuilder: (_, index) {
return Padding(
padding: brandPagePadding2,
child: steps[index],
);
},
itemCount: 4,
),
),
DotsIndicator(
activeIndex: currentPage,
count: steps.length,
),
SizedBox(height: 50),
],
),
),
);
}
List<Widget> getSteps() => <Widget>[
BrandCard(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/logos/hetzner.png'),
SizedBox(height: 10),
Text('1. Подключите сервер Hetzner').h2,
SizedBox(height: 10),
Text('Здесь будут жить наши данные и SelfPrivacy-сервисы')
.body2,
Text('Здесь будут жить наши данные и SelfPrivacy-сервисы').body2,
_MockForm(
onPressed: _nextPage,
hintText: 'Hetzner API Token',
length: 2,
),
SizedBox(height: 20),
Spacer(),
BrandButton.text(
onPressed: () => _showModal(context, _HowHetzner()),
title: 'Как получить API Token',
@ -60,6 +132,7 @@ class OnboardingPage extends StatelessWidget {
),
BrandCard(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/logos/namecheap.png'),
@ -86,10 +159,12 @@ class OnboardingPage extends StatelessWidget {
),
),
_MockForm(
onPressed: _nextPage,
hintText: 'Домен, например, selfprivacy.org',
submitButtonText: 'Проверить DNS',
length: 2,
),
SizedBox(height: 20),
Spacer(),
BrandButton.text(
onPressed: () {},
title: 'Как настроить DNS CloudFlare',
@ -99,6 +174,7 @@ class OnboardingPage extends StatelessWidget {
),
BrandCard(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/logos/cloudflare.png'),
@ -107,9 +183,11 @@ class OnboardingPage extends StatelessWidget {
SizedBox(height: 10),
Text('Для управления DNS вашего домена').body2,
_MockForm(
onPressed: _nextPage,
hintText: 'CloudFlare API Token',
length: 2,
),
SizedBox(height: 20),
Spacer(),
BrandButton.text(
onPressed: () {},
title: 'Как получить API Token',
@ -119,6 +197,7 @@ class OnboardingPage extends StatelessWidget {
),
BrandCard(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/logos/aws.png'),
@ -128,20 +207,22 @@ class OnboardingPage extends StatelessWidget {
Text('IaaS-провайдер, для бесплатного хранения резервных копии ваших данных в зашифрованном виде')
.body2,
_MockForm(
onPressed: () {
Navigator.of(context)
.pushReplacement(materialRoute(RootPage()));
},
hintText: 'Amazon AWS Access Key',
length: 2,
),
SizedBox(height: 20),
Spacer(),
BrandButton.text(
onPressed: () {},
title: 'Как получить API Token',
),
],
),
)
],
),
);
}
];
void _showModal(BuildContext context, Widget widget) {
showModalBottomSheet<void>(
@ -153,6 +234,11 @@ class OnboardingPage extends StatelessWidget {
},
);
}
void _nextPage() => controller.nextPage(
duration: Duration(milliseconds: 300),
curve: Curves.easeIn,
);
}
class _HowHetzner extends StatelessWidget {
@ -163,6 +249,8 @@ class _HowHetzner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BrandModalSheet(
child: Padding(
padding: brandPagePadding2,
child: Column(
children: [
SizedBox(height: 40),
@ -200,28 +288,52 @@ class _HowHetzner extends StatelessWidget {
),
],
),
),
);
}
}
class _MockForm extends StatelessWidget {
class _MockForm extends StatefulWidget {
const _MockForm({
Key key,
@required this.hintText,
this.submitButtonText = 'Подключить',
@required this.onPressed,
@required this.length,
}) : super(key: key);
final String hintText;
final String submitButtonText;
final int length;
final VoidCallback onPressed;
@override
__MockFormState createState() => __MockFormState();
}
class __MockFormState extends State<_MockForm> {
String text = '';
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(height: 20),
TextField(decoration: InputDecoration(hintText: hintText)),
TextField(
onChanged: (value) => {
setState(() {
text = value;
})
},
decoration: InputDecoration(hintText: widget.hintText),
),
SizedBox(height: 20),
BrandButton.rised(onPressed: () {}, title: submitButtonText),
BrandButton.rised(
onPressed:
text.length == widget.length ? widget.onPressed ?? () {} : null,
title: widget.submitButtonText,
),
],
);
}

View File

@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import 'package:selfprivacy/config/brand_theme.dart';
import 'package:selfprivacy/logic/cubit/services/services_cubit.dart';
import 'package:selfprivacy/logic/models/provider.dart';
import 'package:selfprivacy/logic/models/service.dart';
import 'package:selfprivacy/ui/components/brand_button/brand_button.dart';
import 'package:selfprivacy/ui/components/brand_card/brand_card.dart';
import 'package:selfprivacy/ui/components/brand_header/brand_header.dart';
import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart';
import 'package:selfprivacy/ui/components/brand_modal_sheet/brand_modal_sheet.dart';
import 'package:selfprivacy/ui/components/icon_status_mask/icon_status_mask.dart';
import 'package:selfprivacy/ui/pages/settings/setting.dart';
import 'package:selfprivacy/utils/extensions/text_extension.dart';
import 'package:selfprivacy/utils/route_transitions/basic.dart';
class ProvidersPage extends StatefulWidget {
ProvidersPage({Key key}) : super(key: key);
@ -19,9 +20,11 @@ class ProvidersPage extends StatefulWidget {
class _ProvidersPageState extends State<ProvidersPage> {
@override
Widget build(BuildContext context) {
final serviceCubit = context.watch<ServicesCubit>();
final connected = serviceCubit.state.connected;
final uninitialized = serviceCubit.state.uninitialized;
final cards = ProviderTypes.values
.map((type) => _Card(
provider:
ProviderModel(state: ServiceStateType.stable, type: type)))
.toList();
return Scaffold(
appBar: PreferredSize(
child: BrandHeader(title: 'Провайдеры'),
@ -29,86 +32,161 @@ class _ProvidersPageState extends State<ProvidersPage> {
),
body: ListView(
padding: brandPagePadding2,
children: [
SizedBox(height: 24),
...connected.map((service) => _Card(service: service)).toList(),
if (uninitialized.isNotEmpty) ...[
Text('не подключены').body1,
SizedBox(height: 30),
],
...uninitialized.map((service) => _Card(service: service)).toList()
],
children: cards,
),
);
}
}
class _Card extends StatelessWidget {
const _Card({Key key, @required this.service}) : super(key: key);
const _Card({Key key, @required this.provider}) : super(key: key);
final Service service;
final ProviderModel provider;
@override
Widget build(BuildContext context) {
String title;
IconData iconData;
String description;
String message;
String stableText;
switch (service.type) {
case ServiceTypes.messanger:
iconData = BrandIcons.messanger;
title = 'Мессенджер';
description =
'Delta Chat срфеТекст-текст описание. Если бы мне надо было обсудить что-то от чего зависит жизнь. Я бы выбрал Delta.Chat + свой почтовый сервер.';
switch (provider.type) {
case ProviderTypes.server:
title = 'Сервер';
stableText = 'В норме';
break;
case ServiceTypes.mail:
iconData = BrandIcons.envelope;
title = 'Почта';
description = 'Электронная почта для семьи или компании ';
case ProviderTypes.domain:
title = 'Домен';
message = 'example.com';
stableText = 'Домен настроен';
break;
case ServiceTypes.passwordManager:
iconData = BrandIcons.key;
title = 'Менеджер паролей';
description = 'Надёжное хранилище для ваших паролей и ключей доступа';
break;
case ServiceTypes.github:
iconData = BrandIcons.github;
title = 'Git сервер';
description = 'Сервис для приватного хранения своих разработок';
break;
case ServiceTypes.backup:
iconData = BrandIcons.save;
case ProviderTypes.backup:
message = '22 янв 2021 14:30';
title = 'Резервное копирование';
description = 'Обеспеченье целосности и сохранности ваших данных';
break;
case ServiceTypes.cloud:
iconData = BrandIcons.upload;
title = 'Файловое Облако';
description = 'Сервис для доступа к вашим файлам в любой точке мира';
stableText = 'В норме';
break;
}
return BrandCard(
return GestureDetector(
onTap: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return _ProviderDetails(
provider: provider,
statusText: stableText,
);
},
),
child: BrandCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconStatusMaks(
status: service.state,
child: Icon(iconData, size: 30, color: Colors.white),
status: provider.state,
child: Icon(provider.icon, size: 30, color: Colors.white),
),
SizedBox(height: 10),
Text(title).h2,
SizedBox(height: 10),
if (service.state == ServiceStateType.uninitialized) ...[
Text(description).body1,
if (message != null) ...[
Text(message).body2,
SizedBox(height: 10),
BrandButton.text(
title: 'Подключить',
onPressed: () {
context.read<ServicesCubit>().connect(service);
})
],
if (service.state == ServiceStateType.stable) Text('Подключен').body1,
if (provider.state == ServiceStateType.stable)
Text(stableText).body2,
],
),
),
);
}
}
class _ProviderDetails extends StatelessWidget {
const _ProviderDetails({
Key key,
@required this.provider,
@required this.statusText,
}) : super(key: key);
final ProviderModel provider;
final String statusText;
@override
Widget build(BuildContext context) {
String title;
switch (provider.type) {
case ProviderTypes.server:
title = 'Сервер';
break;
case ProviderTypes.domain:
title = 'Домен';
break;
case ProviderTypes.backup:
title = 'Резервное копирование';
break;
}
return BrandModalSheet(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.symmetric(
vertical: 4,
horizontal: 2,
),
child: PopupMenuButton<_PopupMenuItemType>(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
onSelected: (_PopupMenuItemType result) {
switch (result) {
case _PopupMenuItemType.setting:
Navigator.of(context)
.pushReplacement(materialRoute(SettingsPage()));
break;
}
},
icon: Icon(Icons.more_vert),
itemBuilder: (BuildContext context) => [
PopupMenuItem<_PopupMenuItemType>(
value: _PopupMenuItemType.setting,
child: Container(
padding: EdgeInsets.only(left: 5),
child: Text('Настройки'),
),
),
],
),
),
),
Padding(
padding: brandPagePadding1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 13),
IconStatusMaks(
status: provider.state,
child: Icon(provider.icon, size: 40, color: Colors.white),
),
SizedBox(height: 10),
Text(title).h1,
SizedBox(height: 10),
Text(statusText).body1,
SizedBox(
height: 20,
),
Text('Статусы сервера и сервис провайдера и т.д.')
],
),
)
],
),
);
}
}
enum _PopupMenuItemType { setting }

View File

@ -75,11 +75,7 @@ class _Card extends StatelessWidget {
title = 'Git сервер';
description = 'Сервис для приватного хранения своих разработок';
break;
case ServiceTypes.backup:
iconData = BrandIcons.save;
title = 'Резервное копирование';
description = 'Обеспеченье целосности и сохранности ваших данных';
break;
case ServiceTypes.cloud:
iconData = BrandIcons.upload;
title = 'Файловое Облако';

View File

@ -199,26 +199,60 @@ class _UserDetails extends StatelessWidget {
vertical: 4,
horizontal: 2,
),
child: PopupMenuButton<int>(
child: PopupMenuButton<PopupMenuItemType>(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
// onSelected: (WhyFarther result) {
// setState(() {
// _selection = result;
// });
// },
onSelected: (PopupMenuItemType result) {
switch (result) {
case PopupMenuItemType.reset:
break;
case PopupMenuItemType.delete:
showDialog(
context: context,
child: AlertDialog(
title: Text('Подтверждение '),
content: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Text('удалить учетную запись?'),
],
),
),
actions: <Widget>[
TextButton(
child: Text('Отменить'),
onPressed: () {
Navigator.of(context)..pop();
},
),
TextButton(
child: Text(
'Удалить',
style: TextStyle(
color: BrandColors.red,
),
),
onPressed: () {
Navigator.of(context)..pop()..pop();
},
),
],
));
break;
}
},
icon: Icon(Icons.more_vert),
itemBuilder: (BuildContext context) => [
PopupMenuItem<int>(
value: 1,
PopupMenuItem<PopupMenuItemType>(
value: PopupMenuItemType.reset,
child: Container(
padding: EdgeInsets.only(left: 5),
child: Text('Сбросить пароль'),
),
),
PopupMenuItem<int>(
value: 2,
PopupMenuItem<PopupMenuItemType>(
value: PopupMenuItemType.delete,
child: Container(
padding: EdgeInsets.only(left: 5),
child: Text(
@ -277,7 +311,7 @@ class _UserDetails extends StatelessWidget {
BrandDivider(),
SizedBox(height: 20),
Text(
'Вам был создан доступ к сервисам с логином <login> и паролем <password> к сервисам:- E-mail с адресом <username@domain.com>- Менеджер паролей: <pass.domain.com>- Файловое облако: <cloud.mydomain.com>- Видеоконференция <meet.domain.com>- Git сервер <git.mydomain.com>'),
'Вам был создан доступ к сервисам с логином <login> и паролем <password> к сервисам:- E-mail с адресом <username@domain.com>- Менеджер паролей: <pass.domain.com>- Файловое облако: <cloud.mydomain.com>- Видеоконференция <meet.domain.com>- Git сервер <git.mydomain.com>'),
],
),
)
@ -286,3 +320,8 @@ class _UserDetails extends StatelessWidget {
);
}
}
enum PopupMenuItemType {
reset,
delete,
}