nc-photos/app/lib/widget/account_picker_dialog.dart

525 lines
16 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:clock/clock.dart';
import 'package:copy_with/copy_with.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc_util.dart';
import 'package:nc_photos/controller/account_controller.dart';
import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/db/entity_converter.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/entity/server_status.dart';
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/help_utils.dart' as help_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/stream_util.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/toast.dart';
import 'package:nc_photos/url_launcher_util.dart';
import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/settings.dart';
import 'package:nc_photos/widget/settings/account_settings.dart';
import 'package:nc_photos/widget/sign_in.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_db/np_db.dart';
import 'package:to_string/to_string.dart';
part 'account_picker_dialog.g.dart';
part 'account_picker_dialog/bloc.dart';
part 'account_picker_dialog/state_event.dart';
typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
typedef _BlocListener = BlocListener<_Bloc, _State>;
class AccountPickerDialog extends StatelessWidget {
const AccountPickerDialog({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => _Bloc(
container: KiwiContainer().resolve(),
accountController: context.read(),
prefController: context.read(),
db: context.read(),
),
child: const _WrappedAccountPickerDialog(),
);
}
}
class _WrappedAccountPickerDialog extends StatelessWidget {
const _WrappedAccountPickerDialog();
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
_BlocListener(
listenWhen: (previous, current) =>
previous.newSelectAccount != current.newSelectAccount,
listener: (context, state) {
if (state.newSelectAccount != null) {
Navigator.of(context).pushNamedAndRemoveUntil(
Home.routeName,
(_) => false,
arguments: HomeArguments(state.newSelectAccount!),
);
}
},
),
_BlocListener(
listenWhen: (previous, current) => previous.error != current.error,
listener: (context, state) {
if (state.error != null) {
AppToast.showToast(
context,
msg: exception_util.toUserString(state.error!.error),
duration: k.snackBarDurationNormal,
);
}
},
),
],
child: Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 512),
child: Padding(
padding: const EdgeInsets.all(8),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Stack(
alignment: Alignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
L10n.global().appTitle,
style: Theme.of(context).textTheme.titleLarge,
),
),
ValueStreamBuilder<bool>(
stream: context
.read<PrefController>()
.isFollowSystemTheme,
builder: (_, isFollowSystemTheme) {
if (!isFollowSystemTheme.requireData) {
return Align(
alignment: AlignmentDirectional.centerEnd,
child: _DarkModeSwitch(
onChanged: (value) {
context
.read<_Bloc>()
.add(_SetDarkTheme(value));
},
),
);
} else {
return const SizedBox.shrink();
}
},
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Container(
color: Theme.of(context).colorScheme.background,
child: Material(
type: MaterialType.transparency,
child: _BlocBuilder(
buildWhen: (previous, current) =>
previous.isOpenDropdown !=
current.isOpenDropdown ||
previous.accounts != current.accounts,
builder: (context, state) {
final bloc = context.read<_Bloc>();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const _AccountDropdown(),
if (state.isOpenDropdown) ...[
...state.accounts
.where((a) =>
a.id != bloc.activeAccount.id)
.map((a) => _AccountView(account: a)),
const _NewAccountView(),
] else
const _AccountSettingsView(),
],
);
},
),
),
),
),
_IconTile(
icon: const Icon(Icons.settings_outlined),
title: Text(L10n.global().settingsMenuLabel),
isCircularSplash: true,
onTap: () {
Navigator.of(context)
..pop()
..pushNamed(Settings.routeName);
},
),
_IconTile(
icon: const Icon(Icons.groups_outlined),
title: Text(L10n.global().contributorsTooltip),
isCircularSplash: true,
onTap: () {
Navigator.of(context).pop();
launch(help_util.contributorsUrl);
},
),
_IconTile(
icon: const Icon(Icons.help_outline),
title: Text(L10n.global().helpTooltip),
isCircularSplash: true,
onTap: () {
Navigator.of(context).pop();
launch(help_util.mainUrl);
},
),
const _AboutChin(),
],
),
),
),
),
),
),
);
}
}
class _DarkModeSwitch extends StatelessWidget {
const _DarkModeSwitch({
this.onChanged,
});
@override
Widget build(BuildContext context) {
return DarkModeSwitchTheme(
child: Switch(
value: Theme.of(context).brightness == Brightness.dark,
onChanged: onChanged,
activeThumbImage:
const AssetImage("assets/ic_dark_mode_switch_24dp.png"),
inactiveThumbImage:
const AssetImage("assets/ic_dark_mode_switch_24dp.png"),
),
);
}
final ValueChanged<bool>? onChanged;
}
class _AccountDropdown extends StatelessWidget {
const _AccountDropdown();
@override
Widget build(BuildContext context) {
return _AccountTile(
account: context.read<_Bloc>().activeAccount,
trailing: _BlocBuilder(
builder: (_, state) {
return AnimatedRotation(
turns: state.isOpenDropdown ? .5 : 0,
duration: k.animationDurationShort,
child: IgnorePointer(
ignoring: true,
child: IconButton(
onPressed: () {},
color: state.isOpenDropdown
? Theme.of(context).colorScheme.primary
: null,
icon: const Icon(Icons.keyboard_arrow_down_outlined),
),
),
);
},
),
onTap: () {
context.read<_Bloc>().add(const _ToggleDropdown());
},
);
}
}
class _AccountTile extends StatelessWidget {
const _AccountTile({
required this.account,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
final accountLabel = AccountPref.of(account).getAccountLabel();
return ListTile(
dense: true,
leading: SizedBox.square(
dimension: 40,
child: Center(child: _AccountIcon(account)),
),
title: accountLabel != null
? SizedBox(
height: 64,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
accountLabel,
maxLines: 1,
overflow: TextOverflow.clip,
),
),
)
: Text(
account.address,
maxLines: 1,
overflow: TextOverflow.clip,
),
subtitle: accountLabel == null ? Text(account.username2) : null,
trailing: trailing,
onTap: onTap,
);
}
final Account account;
final Widget? trailing;
final VoidCallback? onTap;
}
class _AccountIcon extends StatelessWidget {
const _AccountIcon(this.account);
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: CachedNetworkImage(
imageUrl: api_util.getAccountAvatarUrl(account, 64),
fadeInDuration: const Duration(),
filterQuality: FilterQuality.high,
),
);
}
final Account account;
}
class _IconTile extends StatelessWidget {
const _IconTile({
required this.icon,
required this.title,
this.onTap,
this.isCircularSplash = false,
});
@override
Widget build(BuildContext context) {
final content = ListTile(
dense: true,
leading: SizedBox.square(
dimension: 40,
child: Center(child: icon),
),
title: title,
onTap: onTap,
);
if (isCircularSplash) {
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Material(
type: MaterialType.transparency,
child: content,
),
);
} else {
return content;
}
}
final Widget icon;
final Widget title;
final VoidCallback? onTap;
final bool isCircularSplash;
}
class _AccountView extends StatelessWidget {
const _AccountView({
required this.account,
});
@override
Widget build(BuildContext context) {
final accountLabel = AccountPref.of(account).getAccountLabel();
return _AccountTile(
account: account,
trailing: IconButton(
icon: const Icon(Icons.logout),
tooltip: L10n.global().deleteTooltip,
onPressed: () async {
final result = await showDialog<bool>(
context: context,
builder: (_) => _DeleteAccountConfirmDialog(
accountLabel: accountLabel ?? account.address,
),
);
if (!context.mounted || result != true) {
return;
}
context.read<_Bloc>().add(_DeleteAccount(account));
},
),
onTap: () {
context.read<_Bloc>().add(_SwitchAccount(account));
},
);
}
final Account account;
}
class _AccountSettingsView extends StatelessWidget {
const _AccountSettingsView();
@override
Widget build(BuildContext context) {
return _IconTile(
icon: const Icon(Icons.manage_accounts_outlined),
title: Text(L10n.global().accountSettingsTooltip),
onTap: () {
Navigator.of(context)
..pop()
..pushNamed(AccountSettings.routeName);
},
);
}
}
class _NewAccountView extends StatelessWidget {
const _NewAccountView();
@override
Widget build(BuildContext context) {
return _IconTile(
icon: const Icon(Icons.add),
title: Text(L10n.global().addServerTooltip),
onTap: () {
Navigator.of(context)
..pop()
..pushNamed(SignIn.routeName);
},
);
}
}
class _AboutChin extends StatelessWidget {
const _AboutChin();
@override
Widget build(BuildContext context) {
return StreamBuilder<ServerStatus?>(
stream: context.read<_Bloc>().accountController.serverController.status,
initialData: context
.read<_Bloc>()
.accountController
.serverController
.status
.valueOrNull,
builder: (context, snapshot) {
var text = "${L10n.global().appTitle} ${k.versionStr}";
if (snapshot.hasData) {
final status = snapshot.requireData!;
text +=
" ${_getSymbol()} ${status.productName} ${status.versionName}";
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall,
),
);
},
);
}
String _getSymbol() {
final today = clock.now();
if (today.month == 1 && today.day == 1) {
// firework
return "\u{1f386}";
} else if (today.month == 4 && today.day == 10) {
// initial commit!
return "\u{1f382}";
} else {
const symbols = [
// cloud
"\u2601",
// heart
"\u2665",
// star
"\u2b50",
// rainbow
"\u{1f308}",
// globe
"\u{1f310}",
// clover
"\u{1f340}",
];
return symbols[Random(_seed).nextInt(symbols.length)];
}
}
static final _seed = Random().nextInt(65536);
}
class _DeleteAccountConfirmDialog extends StatelessWidget {
const _DeleteAccountConfirmDialog({
required this.accountLabel,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Text(L10n.global().deleteAccountConfirmDialogText(accountLabel)),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
);
}
final String accountLabel;
}