Add more components

This commit is contained in:
Neeraj Gupta
2023-04-09 19:10:34 +05:30
parent 84bf988de4
commit fc2db578a8
5 changed files with 674 additions and 9 deletions

View File

@@ -1,5 +1,3 @@
// @dart=2.9
import 'package:ente_auth/ui/common/loading_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
@@ -8,7 +6,7 @@ class WebPage extends StatefulWidget {
final String title;
final String url;
const WebPage(this.title, this.url, {Key key}) : super(key: key);
const WebPage(this.title, this.url, {Key? key}) : super(key: key);
@override
State<WebPage> createState() => _WebPageState();

View File

@@ -0,0 +1,286 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:photos/core/constants.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/models/search/button_result.dart";
import 'package:photos/models/typedefs.dart';
import 'package:photos/theme/colors.dart';
import 'package:photos/theme/effects.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import 'package:photos/ui/components/text_input_widget.dart';
import 'package:photos/utils/separators_util.dart';
///Will return null if dismissed by tapping outside
Future<ButtonResult?> showDialogWidget({
required BuildContext context,
required String title,
String? body,
required List<ButtonWidget> buttons,
IconData? icon,
bool isDismissible = true,
}) {
return showDialog(
barrierDismissible: isDismissible,
barrierColor: backdropFaintDark,
context: context,
builder: (context) {
final widthOfScreen = MediaQuery.of(context).size.width;
final isMobileSmall = widthOfScreen <= mobileSmallThreshold;
return Padding(
padding: EdgeInsets.symmetric(horizontal: isMobileSmall ? 8 : 0),
child: Dialog(
insetPadding: EdgeInsets.zero,
child: DialogWidget(
title: title,
body: body,
buttons: buttons,
icon: icon,
),
),
);
},
);
}
class DialogWidget extends StatelessWidget {
final String title;
final String? body;
final List<ButtonWidget> buttons;
final IconData? icon;
const DialogWidget({
required this.title,
this.body,
required this.buttons,
this.icon,
super.key,
});
@override
Widget build(BuildContext context) {
final widthOfScreen = MediaQuery.of(context).size.width;
final isMobileSmall = widthOfScreen <= mobileSmallThreshold;
final colorScheme = getEnteColorScheme(context);
return Container(
width: min(widthOfScreen, 320),
padding: isMobileSmall
? const EdgeInsets.all(0)
: const EdgeInsets.fromLTRB(6, 8, 6, 6),
decoration: BoxDecoration(
color: colorScheme.backgroundElevated,
boxShadow: shadowFloatLight,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ContentContainer(
title: title,
body: body,
icon: icon,
),
const SizedBox(height: 36),
Actions(buttons),
],
),
),
);
}
}
class ContentContainer extends StatelessWidget {
final String title;
final String? body;
final IconData? icon;
const ContentContainer({
required this.title,
this.body,
this.icon,
super.key,
});
@override
Widget build(BuildContext context) {
final textTheme = getEnteTextTheme(context);
final colorScheme = getEnteColorScheme(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
icon == null
? const SizedBox.shrink()
: Row(
children: [
Icon(
icon,
size: 32,
),
],
),
icon == null ? const SizedBox.shrink() : const SizedBox(height: 19),
Text(title, style: textTheme.largeBold),
body != null ? const SizedBox(height: 19) : const SizedBox.shrink(),
body != null
? Text(
body!,
style: textTheme.body.copyWith(color: colorScheme.textMuted),
)
: const SizedBox.shrink(),
],
);
}
}
class Actions extends StatelessWidget {
final List<ButtonWidget> buttons;
const Actions(this.buttons, {super.key});
@override
Widget build(BuildContext context) {
return Column(
children: addSeparators(
buttons,
const SizedBox(
// In figma this white space is of height 8pts. But the Button
// component has 1pts of invisible border by default in code. So two
// 1pts borders will visually make the whitespace 8pts.
// Height of button component in figma = 48, in code = 50 (2pts for
// top + bottom border)
height: 6,
),
),
);
}
}
class TextInputDialog extends StatefulWidget {
final String title;
final String? body;
final String submitButtonLabel;
final IconData? icon;
final String? label;
final String? message;
final FutureVoidCallbackParamStr onSubmit;
final String? hintText;
final IconData? prefixIcon;
final String? initialValue;
final Alignment? alignMessage;
final int? maxLength;
final bool showOnlyLoadingState;
final TextCapitalization? textCapitalization;
final bool alwaysShowSuccessState;
final bool isPasswordInput;
const TextInputDialog({
required this.title,
this.body,
required this.submitButtonLabel,
required this.onSubmit,
this.icon,
this.label,
this.message,
this.hintText,
this.prefixIcon,
this.initialValue,
this.alignMessage,
this.maxLength,
this.textCapitalization,
this.showOnlyLoadingState = false,
this.alwaysShowSuccessState = false,
this.isPasswordInput = false,
super.key,
});
@override
State<TextInputDialog> createState() => _TextInputDialogState();
}
class _TextInputDialogState extends State<TextInputDialog> {
//the value of this ValueNotifier has no significance
final _submitNotifier = ValueNotifier(false);
@override
void dispose() {
_submitNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final widthOfScreen = MediaQuery.of(context).size.width;
final isMobileSmall = widthOfScreen <= mobileSmallThreshold;
final colorScheme = getEnteColorScheme(context);
return Container(
width: min(widthOfScreen, 320),
padding: isMobileSmall
? const EdgeInsets.all(0)
: const EdgeInsets.fromLTRB(6, 8, 6, 6),
decoration: BoxDecoration(
color: colorScheme.backgroundElevated,
boxShadow: shadowFloatLight,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ContentContainer(
title: widget.title,
body: widget.body,
icon: widget.icon,
),
Padding(
padding: const EdgeInsets.only(top: 19),
child: TextInputWidget(
label: widget.label,
message: widget.message,
hintText: widget.hintText,
prefixIcon: widget.prefixIcon,
initialValue: widget.initialValue,
alignMessage: widget.alignMessage,
autoFocus: true,
maxLength: widget.maxLength,
submitNotifier: _submitNotifier,
onSubmit: widget.onSubmit,
popNavAfterSubmission: true,
showOnlyLoadingState: widget.showOnlyLoadingState,
textCapitalization: widget.textCapitalization,
alwaysShowSuccessState: widget.alwaysShowSuccessState,
isPasswordInput: widget.isPasswordInput,
),
),
const SizedBox(height: 36),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: ButtonWidget(
buttonType: ButtonType.secondary,
buttonSize: ButtonSize.small,
labelText: S.of(context).cancel,
isInAlert: true,
),
),
const SizedBox(width: 8),
Expanded(
child: ButtonWidget(
buttonSize: ButtonSize.small,
buttonType: ButtonType.neutral,
labelText: widget.submitButtonLabel,
onTap: () async {
_submitNotifier.value = !_submitNotifier.value;
},
),
),
],
)
],
),
),
);
}
}

View File

@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:photos/models/execution_states.dart';
import 'package:photos/models/typedefs.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/utils/debouncer.dart';
import 'package:photos/utils/separators_util.dart';
class TextInputWidget extends StatefulWidget {
final String? label;
final String? message;
final String? hintText;
final IconData? prefixIcon;
final String? initialValue;
final Alignment? alignMessage;
final bool? autoFocus;
final int? maxLength;
///TextInputWidget will listen to this notifier and executes onSubmit when
///notified.
final ValueNotifier? submitNotifier;
final bool alwaysShowSuccessState;
final bool showOnlyLoadingState;
final FutureVoidCallbackParamStr? onSubmit;
final VoidCallbackParamStr? onChange;
final bool popNavAfterSubmission;
final bool shouldSurfaceExecutionStates;
final TextCapitalization? textCapitalization;
final bool isPasswordInput;
final bool cancellable;
final bool shouldUnfocusOnCancelOrSubmit;
const TextInputWidget({
this.onSubmit,
this.onChange,
this.label,
this.message,
this.hintText,
this.prefixIcon,
this.initialValue,
this.alignMessage,
this.autoFocus,
this.maxLength,
this.submitNotifier,
this.alwaysShowSuccessState = false,
this.showOnlyLoadingState = false,
this.popNavAfterSubmission = false,
this.shouldSurfaceExecutionStates = true,
this.textCapitalization = TextCapitalization.none,
this.isPasswordInput = false,
this.cancellable = false,
this.shouldUnfocusOnCancelOrSubmit = false,
super.key,
});
@override
State<TextInputWidget> createState() => _TextInputWidgetState();
}
class _TextInputWidgetState extends State<TextInputWidget> {
ExecutionState executionState = ExecutionState.idle;
final _textController = TextEditingController();
final _debouncer = Debouncer(const Duration(milliseconds: 300));
late final ValueNotifier<bool> _obscureTextNotifier;
///This is to pass if the TextInputWidget is in a dialog and an error is
///thrown in executing onSubmit by passing it as arg in Navigator.pop()
Exception? _exception;
@override
void initState() {
widget.submitNotifier?.addListener(_onSubmit);
if (widget.initialValue != null) {
_textController.value = TextEditingValue(
text: widget.initialValue!,
selection: TextSelection.collapsed(offset: widget.initialValue!.length),
);
}
if (widget.onChange != null) {
_textController.addListener(() {
widget.onChange!.call(_textController.text);
});
}
_obscureTextNotifier = ValueNotifier(widget.isPasswordInput);
_obscureTextNotifier.addListener(_safeRefresh);
super.initState();
}
@override
void dispose() {
widget.submitNotifier?.removeListener(_onSubmit);
_obscureTextNotifier.dispose();
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (executionState == ExecutionState.successful) {
Future.delayed(Duration(seconds: widget.popNavAfterSubmission ? 1 : 2),
() {
setState(() {
executionState = ExecutionState.idle;
});
});
}
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
var textInputChildren = <Widget>[];
if (widget.label != null) {
textInputChildren.add(Text(widget.label!));
}
textInputChildren.add(
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Material(
child: TextFormField(
textCapitalization: widget.textCapitalization!,
autofocus: widget.autoFocus ?? false,
controller: _textController,
inputFormatters: widget.maxLength != null
? [LengthLimitingTextInputFormatter(50)]
: null,
obscureText: _obscureTextNotifier.value,
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle: textTheme.body.copyWith(color: colorScheme.textMuted),
filled: true,
fillColor: colorScheme.fillFaint,
contentPadding: const EdgeInsets.fromLTRB(
12,
12,
0,
12,
),
border: const UnderlineInputBorder(
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.strokeFaint),
borderRadius: BorderRadius.circular(8),
),
suffixIcon: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 175),
switchInCurve: Curves.easeInExpo,
switchOutCurve: Curves.easeOutExpo,
child: SuffixIconWidget(
key: ValueKey(executionState),
executionState: executionState,
shouldSurfaceExecutionStates:
widget.shouldSurfaceExecutionStates,
obscureTextNotifier: _obscureTextNotifier,
isPasswordInput: widget.isPasswordInput,
textController: _textController,
isCancellable: widget.cancellable,
shouldUnfocusOnCancelOrSubmit:
widget.shouldUnfocusOnCancelOrSubmit,
),
),
),
prefixIconConstraints: const BoxConstraints(
maxHeight: 44,
maxWidth: 44,
minHeight: 44,
minWidth: 44,
),
suffixIconConstraints: const BoxConstraints(
maxHeight: 24,
maxWidth: 48,
minHeight: 24,
minWidth: 48,
),
prefixIcon: widget.prefixIcon != null
? Icon(
widget.prefixIcon,
color: colorScheme.strokeMuted,
)
: null,
),
onEditingComplete: () {
_onSubmit();
},
),
),
),
);
if (widget.message != null) {
textInputChildren.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Align(
alignment: widget.alignMessage ?? Alignment.centerLeft,
child: Text(
widget.message!,
style: textTheme.small.copyWith(color: colorScheme.textMuted),
),
),
),
);
}
textInputChildren =
addSeparators(textInputChildren, const SizedBox(height: 4));
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: textInputChildren,
);
}
void _safeRefresh() {
if (mounted) {
setState(() {});
}
}
void _onSubmit() async {
_debouncer.run(
() => Future(() {
setState(() {
executionState = ExecutionState.inProgress;
});
}),
);
if (widget.shouldUnfocusOnCancelOrSubmit) {
FocusScope.of(context).unfocus();
}
try {
await widget.onSubmit!.call(_textController.text);
} catch (e) {
executionState = ExecutionState.error;
_debouncer.cancelDebounce();
_exception = e as Exception;
if (!widget.popNavAfterSubmission) {
rethrow;
}
}
widget.alwaysShowSuccessState && _debouncer.isActive()
? executionState = ExecutionState.successful
: null;
_debouncer.cancelDebounce();
if (executionState == ExecutionState.successful) {
setState(() {});
}
// when the time taken by widget.onSubmit is approximately equal to the debounce
// time, the callback is getting executed when/after the if condition
// below is executing/executed which results in execution state stuck at
// idle state. This Future is for delaying the execution of the if
// condition so that the calback in the debouncer finishes execution before.
await Future.delayed(const Duration(milliseconds: 5));
if (executionState == ExecutionState.inProgress ||
executionState == ExecutionState.error) {
if (executionState == ExecutionState.inProgress) {
if (mounted) {
if (widget.showOnlyLoadingState) {
setState(() {
executionState = ExecutionState.idle;
});
_popNavigatorStack(context);
} else {
setState(() {
executionState = ExecutionState.successful;
Future.delayed(
Duration(
seconds: widget.shouldSurfaceExecutionStates
? (widget.popNavAfterSubmission ? 1 : 2)
: 0,
), () {
widget.popNavAfterSubmission
? _popNavigatorStack(context)
: null;
if (mounted) {
setState(() {
executionState = ExecutionState.idle;
});
}
});
});
}
}
}
if (executionState == ExecutionState.error) {
setState(() {
executionState = ExecutionState.idle;
widget.popNavAfterSubmission
? Future.delayed(
const Duration(seconds: 0),
() => _popNavigatorStack(context, e: _exception),
)
: null;
});
}
} else {
if (widget.popNavAfterSubmission) {
Future.delayed(
Duration(seconds: widget.alwaysShowSuccessState ? 1 : 0),
() => _popNavigatorStack(context),
);
}
}
}
void _popNavigatorStack(BuildContext context, {Exception? e}) {
Navigator.of(context).canPop() ? Navigator.of(context).pop(e) : null;
}
}
//todo: Add clear and custom icon for suffic icon
class SuffixIconWidget extends StatelessWidget {
final ExecutionState executionState;
final bool shouldSurfaceExecutionStates;
final TextEditingController textController;
final ValueNotifier? obscureTextNotifier;
final bool isPasswordInput;
final bool isCancellable;
final bool shouldUnfocusOnCancelOrSubmit;
const SuffixIconWidget({
required this.executionState,
required this.shouldSurfaceExecutionStates,
required this.textController,
this.obscureTextNotifier,
this.isPasswordInput = false,
this.isCancellable = false,
this.shouldUnfocusOnCancelOrSubmit = false,
super.key,
});
@override
Widget build(BuildContext context) {
final Widget trailingWidget;
final colorScheme = getEnteColorScheme(context);
if (executionState == ExecutionState.idle ||
!shouldSurfaceExecutionStates) {
if (isCancellable) {
trailingWidget = GestureDetector(
onTap: () {
textController.clear();
if (shouldUnfocusOnCancelOrSubmit) {
FocusScope.of(context).unfocus();
}
},
child: Icon(
Icons.cancel_rounded,
color: colorScheme.strokeMuted,
),
);
} else if (isPasswordInput) {
assert(obscureTextNotifier != null);
trailingWidget = GestureDetector(
onTap: () {
obscureTextNotifier!.value = !obscureTextNotifier!.value;
},
child: Icon(
obscureTextNotifier!.value
? Icons.visibility_off_outlined
: Icons.visibility,
color: obscureTextNotifier!.value ? colorScheme.strokeMuted : null,
),
);
} else {
trailingWidget = const SizedBox.shrink();
}
} else if (executionState == ExecutionState.inProgress) {
trailingWidget = EnteLoadingWidget(
color: colorScheme.strokeMuted,
);
} else if (executionState == ExecutionState.successful) {
trailingWidget = Icon(
Icons.check_outlined,
size: 22,
color: colorScheme.primary500,
);
} else {
trailingWidget = const SizedBox.shrink();
}
return trailingWidget;
}
}

View File

@@ -1,5 +1,3 @@
// @dart=2.9
import 'package:ente_auth/services/update_service.dart';
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/common/web_page.dart';
@@ -95,12 +93,13 @@ class AboutSectionWidget extends StatelessWidget {
class AboutMenuItemWidget extends StatelessWidget {
final String title;
final String url;
final String webPageTitle;
final String? webPageTitle;
const AboutMenuItemWidget({
@required this.title,
@required this.url,
required this.title,
required this.url,
this.webPageTitle,
Key key,
Key? key,
}) : super(key: key);
@override