Merge branch 'develop'

This commit is contained in:
Hillel Coren 2024-01-03 17:14:12 +02:00
commit 8238404da1
16 changed files with 623 additions and 88 deletions

View File

@ -86,7 +86,7 @@ jobs:
draft: false draft: false
prerelease: false prerelease: false
title: "Latest Release" title: "Latest Release"
automatic_release_tag: "v5.0.146" automatic_release_tag: "v5.0.147"
files: | files: |
${{ github.workspace }}/artifacts/Invoice-Ninja-Archive ${{ github.workspace }}/artifacts/Invoice-Ninja-Archive
${{ github.workspace }}/artifacts/Invoice-Ninja-Hash ${{ github.workspace }}/artifacts/Invoice-Ninja-Hash

View File

@ -50,6 +50,7 @@
</screenshots> </screenshots>
<content_rating type="oars-1.1"/> <content_rating type="oars-1.1"/>
<releases> <releases>
<release version="5.0.147" date="2024-01-02"/>
<release version="5.0.146" date="2023-12-20"/> <release version="5.0.146" date="2023-12-20"/>
<release version="5.0.145" date="2023-12-03"/> <release version="5.0.145" date="2023-12-03"/>
<release version="5.0.144" date="2023-12-01"/> <release version="5.0.144" date="2023-12-01"/>

View File

@ -4,7 +4,7 @@ class Constants {
} }
// TODO remove version once #46609 is fixed // TODO remove version once #46609 is fixed
const String kClientVersion = '5.0.146'; const String kClientVersion = '5.0.147';
const String kMinServerVersion = '5.0.4'; const String kMinServerVersion = '5.0.4';
const String kAppName = 'Invoice Ninja'; const String kAppName = 'Invoice Ninja';

View File

@ -37,8 +37,8 @@ class ProjectRepository {
} }
Future<BuiltList<ProjectEntity>> loadList( Future<BuiltList<ProjectEntity>> loadList(
Credentials credentials, int createdAt, bool filterDeleted) async { Credentials credentials, bool filterDeleted) async {
String url = credentials.url+ '/projects?created_at=$createdAt'; String url = credentials.url + '/projects?';
if (filterDeleted) { if (filterDeleted) {
url += '&filter_deleted_clients=true'; url += '&filter_deleted_clients=true';
@ -60,7 +60,7 @@ class ProjectRepository {
} }
final url = final url =
credentials.url+ '/projects/bulk?per_page=$kMaxEntitiesPerBulkAction'; credentials.url + '/projects/bulk?per_page=$kMaxEntitiesPerBulkAction';
final dynamic response = await webClient.post(url, credentials.token, final dynamic response = await webClient.post(url, credentials.token,
data: json.encode({'ids': ids, 'action': action.toApiParam()})); data: json.encode({'ids': ids, 'action': action.toApiParam()}));
@ -77,10 +77,10 @@ class ProjectRepository {
if (project.isNew) { if (project.isNew) {
response = await webClient.post( response = await webClient.post(
credentials.url+ '/projects', credentials.token, credentials.url + '/projects', credentials.token,
data: json.encode(data)); data: json.encode(data));
} else { } else {
final url = credentials.url+ '/projects/${project.id}'; final url = credentials.url + '/projects/${project.id}';
response = response =
await webClient.put(url, credentials.token, data: json.encode(data)); await webClient.put(url, credentials.token, data: json.encode(data));
} }

View File

@ -227,7 +227,6 @@ Middleware<AppState> _loadProjects(ProjectRepository repository) {
repository repository
.loadList( .loadList(
state.credentials, state.credentials,
state.createdAtLimit,
state.filterDeletedClients, state.filterDeletedClients,
) )
.then((data) { .then((data) {

View File

@ -297,23 +297,17 @@ class _InvoiceEmailViewState extends State<InvoiceEmailView>
return Container( return Container(
color: Colors.white, color: Colors.white,
height: double.infinity, height: double.infinity,
child: Stack( child: (supportsInlineBrowser())
children: [ ? EmailPreview(
if (_isLoading) LinearProgressIndicator(),
if (supportsInlineBrowser())
EmailPreview(
isLoading: _isLoading, isLoading: _isLoading,
subject: _subjectPreview, subject: _subjectPreview,
body: _emailPreview, body: _emailPreview,
) )
else : IgnorePointer(
IgnorePointer(
child: ExampleEditor( child: ExampleEditor(
value: '### $_subjectPreview\n\n\n' + value: '### $_subjectPreview\n\n\n' +
html2md.convert(_bodyPreview), html2md.convert(_bodyPreview),
), ),
)
],
), ),
); );
} }
@ -372,9 +366,11 @@ class _InvoiceEmailViewState extends State<InvoiceEmailView>
), ),
), ),
), ),
if (state.company.markdownEmailEnabled)
Expanded( Expanded(
child: ColoredBox( child: Stack(
children: [
if (state.company.markdownEmailEnabled)
ColoredBox(
color: Colors.white, color: Colors.white,
child: IgnorePointer( child: IgnorePointer(
ignoring: !enableCustomEmail, ignoring: !enableCustomEmail,
@ -388,7 +384,6 @@ class _InvoiceEmailViewState extends State<InvoiceEmailView>
}, },
), ),
), ),
),
) )
else else
Padding( Padding(
@ -401,7 +396,11 @@ class _InvoiceEmailViewState extends State<InvoiceEmailView>
onChanged: (_) => _onChanged(), onChanged: (_) => _onChanged(),
enabled: enableCustomEmail, enabled: enableCustomEmail,
), ),
) ),
if (_isLoading) LinearProgressIndicator(),
],
),
),
], ],
); );
} }

View File

@ -88,7 +88,7 @@ class _MenuDrawerState extends State<MenuDrawer> {
// Fix for CORS error using 'object' subdomain // Fix for CORS error using 'object' subdomain
return CachedImage( return CachedImage(
width: MenuDrawer.LOGO_WIDTH, width: MenuDrawer.LOGO_WIDTH,
url: state.credentials.url+ '/companies/' + company.id + '/logo', url: state.credentials.url + '/companies/' + company.id + '/logo',
apiToken: state.userCompanyStates apiToken: state.userCompanyStates
.firstWhere((userCompanyState) => .firstWhere((userCompanyState) =>
userCompanyState.company.id == company.id) userCompanyState.company.id == company.id)
@ -967,9 +967,14 @@ class _DrawerTileState extends State<DrawerTile> {
? iconWidget ? iconWidget
: isLoading : isLoading
? Padding( ? Padding(
padding: const EdgeInsets.only(left: 10), padding: const EdgeInsets.only(
left: 10,
right: 8,
),
child: SizedBox( child: SizedBox(
child: CircularProgressIndicator(), child: CircularProgressIndicator(
color: state.accentColor,
),
width: 22, width: 22,
height: 22, height: 22,
), ),
@ -1680,7 +1685,7 @@ class _ContactUsDialogState extends State<ContactUsDialog> {
setState(() => _isSaving = true); setState(() => _isSaving = true);
WebClient() WebClient()
.post(state.credentials.url+ '/support/messages/send', .post(state.credentials.url + '/support/messages/send',
state.credentials.token, state.credentials.token,
data: json.encode({ data: json.encode({
'message': _message, 'message': _message,

View File

@ -268,6 +268,11 @@ class InvoiceOverview extends StatelessWidget {
widgets.add(EntityListTile(entity: project, isFilter: isFilter)); widgets.add(EntityListTile(entity: project, isFilter: isFilter));
} }
if ((invoice.invoiceId ?? '').isNotEmpty) {
final linkedInvoice = state.invoiceState.get(invoice.invoiceId!);
widgets.add(EntityListTile(entity: linkedInvoice, isFilter: isFilter));
}
if (invoice.expenseId.isNotEmpty) { if (invoice.expenseId.isNotEmpty) {
final expense = state.vendorState.get(invoice.expenseId); final expense = state.vendorState.get(invoice.expenseId);
widgets.add(EntityListTile(entity: expense, isFilter: isFilter)); widgets.add(EntityListTile(entity: expense, isFilter: isFilter));

View File

@ -31,6 +31,75 @@ MutableDocument deserializeMarkdownToDocument(String markdown) {
return MutableDocument(nodes: nodeVisitor.content); return MutableDocument(nodes: nodeVisitor.content);
} }
String serializeDocumentToMarkdown(Document doc) {
final StringBuffer buffer = StringBuffer();
bool isFirstLine = true;
for (int i = 0; i < doc.nodes.length; ++i) {
final node = doc.nodes[i];
if (!isFirstLine) {
// Create a new line to encode the given node.
buffer.writeln('');
} else {
isFirstLine = false;
}
if (node is ImageNode) {
buffer.write('![${node.altText}](${node.imageUrl})');
} else if (node is HorizontalRuleNode) {
buffer.write('---');
} else if (node is ListItemNode) {
final indent = List.generate(node.indent + 1, (index) => ' ').join('');
final symbol = node.type == ListItemType.unordered ? '*' : '1.';
buffer.write('$indent$symbol ${node.text.toMarkdown()}');
final nodeBelow = i < doc.nodes.length - 1 ? doc.nodes[i + 1] : null;
if (nodeBelow != null && (nodeBelow is! ListItemNode)) {
// This list item is the last item in the list. Add an extra
// blank line after it.
buffer.writeln('');
}
} else if (node is ParagraphNode) {
final Attribution blockType = node.getMetadataValue('blockType');
if (blockType == header1Attribution) {
buffer.write('# ${node.text.toMarkdown()}');
} else if (blockType == header2Attribution) {
buffer.write('## ${node.text.toMarkdown()}');
} else if (blockType == header3Attribution) {
buffer.write('### ${node.text.toMarkdown()}');
} else if (blockType == header4Attribution) {
buffer.write('#### ${node.text.toMarkdown()}');
} else if (blockType == header5Attribution) {
buffer.write('##### ${node.text.toMarkdown()}');
} else if (blockType == header6Attribution) {
buffer.write('###### ${node.text.toMarkdown()}');
} else if (blockType == blockquoteAttribution) {
// TODO: handle multiline
buffer.write('> ${node.text.toMarkdown()}');
} else if (blockType == codeAttribution) {
buffer //
..writeln('```') //
..writeln(node.text.toMarkdown()) //
..write('```');
} else {
buffer.write(node.text.toMarkdown());
}
// Separates paragraphs with blank lines.
// If we are at the last node we don't add a trailing
// blank line.
if (i != doc.nodes.length - 1) {
buffer.writeln();
}
}
}
return buffer.toString();
}
/// Converts structured markdown to a list of [DocumentNode]s. /// Converts structured markdown to a list of [DocumentNode]s.
/// ///
/// To use [_MarkdownToDocument], obtain a series of markdown /// To use [_MarkdownToDocument], obtain a series of markdown

View File

@ -63,6 +63,7 @@ class _ExampleEditorState extends State<ExampleEditor> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Fix for <p> tags cutting off text // Fix for <p> tags cutting off text
var markdown = widget.value; var markdown = widget.value;
markdown = markdown.replaceAll('<p/>', '\n'); markdown = markdown.replaceAll('<p/>', '\n');
@ -71,7 +72,6 @@ class _ExampleEditorState extends State<ExampleEditor> {
markdown = markdown.replaceAll('</p>', ''); markdown = markdown.replaceAll('</p>', '');
markdown = markdown.replaceAll('</div>', ''); markdown = markdown.replaceAll('</div>', '');
// _doc = createInitialDocument()..addListener(_onDocumentChange);
_doc = deserializeMarkdownToDocument(markdown) _doc = deserializeMarkdownToDocument(markdown)
..addListener(_onDocumentChange); ..addListener(_onDocumentChange);
_composer = MutableDocumentComposer(); _composer = MutableDocumentComposer();
@ -91,8 +91,35 @@ class _ExampleEditorState extends State<ExampleEditor> {
_iosControlsController = SuperEditorIosControlsController(); _iosControlsController = SuperEditorIosControlsController();
} }
@override
void didUpdateWidget(ExampleEditor oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
_setValue(widget.value);
}
}
void _setValue(String value) {
// Fix for <p> tags cutting off text
var markdown = widget.value;
markdown = markdown.replaceAll('<p/>', '\n');
markdown = markdown.replaceAll('<p>', '\n');
markdown = markdown.replaceAll('<div>', '\n');
markdown = markdown.replaceAll('</p>', '');
markdown = markdown.replaceAll('</div>', '');
_doc.removeListener(_onDocumentChange);
_doc = deserializeMarkdownToDocument(markdown)
..addListener(_onDocumentChange);
_docEditor =
createDefaultDocumentEditor(document: _doc, composer: _composer);
}
@override @override
void dispose() { void dispose() {
_doc.removeListener(_onDocumentChange);
_iosControlsController.dispose(); _iosControlsController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_editorFocusNode.dispose(); _editorFocusNode.dispose();
@ -103,6 +130,11 @@ class _ExampleEditorState extends State<ExampleEditor> {
void _onDocumentChange(_) { void _onDocumentChange(_) {
_hideOrShowToolbar(); _hideOrShowToolbar();
_docChangeSignal.notifyListeners(); _docChangeSignal.notifyListeners();
if (widget.onChanged != null) {
final value = serializeDocumentToMarkdown(_doc);
widget.onChanged!(value);
}
} }
void _hideOrShowToolbar() { void _hideOrShowToolbar() {

View File

@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'package:super_editor/super_editor.dart';
/// A selection control, which displays a button with the selected item, and upon tap, displays a
/// popover list of available text options, from which the user can select a different
/// option.
///
/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route,
/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared
/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode]
/// still has non-primary focus.
///
/// The popover list is positioned based on the following rules:
///
/// 1. The popover is displayed below the selected item, if there's enough room, or
/// 2. The popover is displayed above the selected item, if there's enough room, or
/// 3. The popover is displayed with its bottom aligned with the bottom of
/// the given boundary, and it covers the selected item.
///
/// The popover list height is based on the following rules:
///
/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or
/// 2. The popover is displayed as tall as the available space and becomes scrollable.
///
/// The popover list includes keyboard selection behaviors:
///
/// * Pressing UP/DOWN moves the "active" item selection up/down.
/// * Pressing UP with the first item active moves the active item selection to the last item.
/// * Pressing DOWN with the last item active moves the active item selection to the first item.
/// * Pressing ENTER selects the currently active item and closes the popover list.
class SuperEditorDemoTextItemSelector extends StatefulWidget {
const SuperEditorDemoTextItemSelector({
super.key,
this.parentFocusNode,
this.boundaryKey,
this.id,
required this.items,
required this.onSelected,
});
/// The [FocusNode], to which the popover list's [FocusNode] will be added as a child.
///
/// In Flutter, [FocusNode]s have parents and children. This relationship allows an
/// entire ancestor path to "have focus", but only the lowest level descendant
/// in that path has "primary focus". This path is important because various
/// widgets alter their presentation or behavior based on whether or not they
/// currently have focus, even if they only have "non-primary focus".
///
/// When the popover list of items is visible, that list will have primary focus.
/// Moreover, because the popover list is built in an `Overlay`, none of your
/// widgets are in the natural focus path for that popover list. Therefore, if you
/// need your widget tree to retain focus while the popover list is visible, then
/// you need to provide the [FocusNode] that the popover list should use as its
/// parent, thereby retaining focus for your widgets.
final FocusNode? parentFocusNode;
/// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed.
///
/// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoTextItemSelector]
/// is close to the bottom of the screen.
///
/// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget
/// bound to the [boundaryKey].
///
/// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`.
final GlobalKey? boundaryKey;
/// The currently selected value or `null` if no item is selected.
///
/// This value is used to build the button.
final SuperEditorDemoTextItem? id;
/// The items that will be displayed in the popover list.
///
/// For each item, its [SuperEditorDemoTextItem.label] is displayed.
final List<SuperEditorDemoTextItem> items;
/// Called when the user selects an item on the popover list.
final void Function(SuperEditorDemoTextItem? value) onSelected;
@override
State<SuperEditorDemoTextItemSelector> createState() =>
_SuperEditorDemoTextItemSelectorState();
}
class _SuperEditorDemoTextItemSelectorState
extends State<SuperEditorDemoTextItemSelector> {
/// Shows and hides the popover.
final PopoverController _popoverController = PopoverController();
/// The [FocusNode] of the popover list.
final FocusNode _popoverFocusNode = FocusNode();
@override
void dispose() {
_popoverController.dispose();
_popoverFocusNode.dispose();
super.dispose();
}
void _onItemSelected(SuperEditorDemoTextItem? value) {
_popoverController.close();
widget.onSelected(value);
}
@override
Widget build(BuildContext context) {
return PopoverScaffold(
controller: _popoverController,
buttonBuilder: _buildButton,
popoverFocusNode: _popoverFocusNode,
parentFocusNode: widget.parentFocusNode,
boundaryKey: widget.boundaryKey,
popoverBuilder: (context) => RoundedRectanglePopoverAppearance(
child: ItemSelectionList<SuperEditorDemoTextItem>(
focusNode: _popoverFocusNode,
value: widget.id,
items: widget.items,
itemBuilder: _buildPopoverListItem,
onItemSelected: _onItemSelected,
onCancel: () => _popoverController.close(),
),
),
);
}
Widget _buildButton(BuildContext context) {
return SuperEditorPopoverButton(
padding: const EdgeInsets.only(left: 16.0, right: 24),
onTap: () => _popoverController.open(),
child: widget.id == null //
? const SizedBox()
: Text(
widget.id!.label,
style: const TextStyle(
color: Colors.black,
fontSize: 12,
),
),
);
}
Widget _buildPopoverListItem(BuildContext context,
SuperEditorDemoTextItem item, bool isActive, VoidCallback onTap) {
return DecoratedBox(
decoration: BoxDecoration(
color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent,
),
child: InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: kMinInteractiveDimension),
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Text(
item.label,
style: const TextStyle(
color: Colors.black,
fontSize: 12,
),
),
),
),
);
}
}
/// An option that is displayed as text by a [SuperEditorDemoTextItemSelector].
///
/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [id].
class SuperEditorDemoTextItem {
const SuperEditorDemoTextItem({
required this.id,
required this.label,
});
/// The value that identifies this item.
final String id;
/// The text that is displayed.
final String label;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SuperEditorDemoTextItem &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}
/// A selection control, which displays a button with the selected item, and upon tap, displays a
/// popover list of available icons, from which the user can select a different option.
///
/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route,
/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared
/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode]
/// still has non-primary focus.
///
/// The popover list is positioned based on the following rules:
///
/// 1. The popover is displayed below the selected item, if there's enough room, or
/// 2. The popover is displayed above the selected item, if there's enough room, or
/// 3. The popover is displayed with its bottom aligned with the bottom of
/// the given boundary, and it covers the selected item.
///
/// The popover list height is based on the following rules:
///
/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or
/// 2. The popover is displayed as tall as the available space and becomes scrollable.
///
/// The popover list includes keyboard selection behaviors:
///
/// * Pressing UP/DOWN moves the "active" item selection up/down.
/// * Pressing UP with the first item active moves the active item selection to the last item.
/// * Pressing DOWN with the last item active moves the active item selection to the first item.
/// * Pressing ENTER selects the currently active item and closes the popover list.
class SuperEditorDemoIconItemSelector extends StatefulWidget {
const SuperEditorDemoIconItemSelector({
super.key,
this.parentFocusNode,
this.boundaryKey,
this.value,
required this.items,
required this.onSelected,
});
/// The [FocusNode], to which the popover list's [FocusNode] will be added as a child.
///
/// In Flutter, [FocusNode]s have parents and children. This relationship allows an
/// entire ancestor path to "have focus", but only the lowest level descendant
/// in that path has "primary focus". This path is important because various
/// widgets alter their presentation or behavior based on whether or not they
/// currently have focus, even if they only have "non-primary focus".
///
/// When the popover list of items is visible, that list will have primary focus.
/// Moreover, because the popover list is built in an `Overlay`, none of your
/// widgets are in the natural focus path for that popover list. Therefore, if you
/// need your widget tree to retain focus while the popover list is visible, then
/// you need to provide the [FocusNode] that the popover list should use as its
/// parent, thereby retaining focus for your widgets.
final FocusNode? parentFocusNode;
/// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed.
///
/// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoIconItemSelector]
/// is close to the bottom of the screen.
///
/// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget
/// bound to the [boundaryKey].
///
/// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`.
final GlobalKey? boundaryKey;
/// The currently selected value or `null` if no item is selected.
///
/// This value is used to build the button.
final SuperEditorDemoIconItem? value;
/// The items that will be displayed in the popover list.
///
/// For each item, its [SuperEditorDemoIconItem.icon] is displayed.
final List<SuperEditorDemoIconItem> items;
/// Called when the user selects an item on the popover list.
final void Function(SuperEditorDemoIconItem? value) onSelected;
@override
State<SuperEditorDemoIconItemSelector> createState() =>
_SuperEditorDemoIconItemSelectorState();
}
class _SuperEditorDemoIconItemSelectorState
extends State<SuperEditorDemoIconItemSelector> {
/// Shows and hides the popover.
final PopoverController _popoverController = PopoverController();
/// The [FocusNode] of the popover list.
final FocusNode _popoverFocusNode = FocusNode();
@override
void dispose() {
_popoverController.dispose();
_popoverFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopoverScaffold(
controller: _popoverController,
buttonBuilder: _buildButton,
popoverFocusNode: _popoverFocusNode,
parentFocusNode: widget.parentFocusNode,
popoverBuilder: (context) => RoundedRectanglePopoverAppearance(
child: ItemSelectionList<SuperEditorDemoIconItem>(
value: widget.value,
items: widget.items,
itemBuilder: _buildItem,
onItemSelected: _onItemSelected,
onCancel: () => _popoverController.close(),
focusNode: _popoverFocusNode,
),
),
);
}
Widget _buildItem(BuildContext context, SuperEditorDemoIconItem item,
bool isActive, VoidCallback onTap) {
return DecoratedBox(
decoration: BoxDecoration(
color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent,
),
child: InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: kMinInteractiveDimension),
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Icon(item.icon),
),
),
);
}
Widget _buildButton(BuildContext context) {
return SuperEditorPopoverButton(
onTap: () => _popoverController.open(),
padding: const EdgeInsets.only(left: 8.0, right: 24),
child: widget.value == null //
? const SizedBox()
: Icon(widget.value!.icon),
);
}
void _onItemSelected(SuperEditorDemoIconItem? value) {
_popoverController.close();
widget.onSelected(value);
}
}
/// An option that is displayed as an icon by a [SuperEditorDemoIconItemSelector].
///
/// Two [SuperEditorDemoIconItem]s are considered to be equal if they have the same [id].
class SuperEditorDemoIconItem {
const SuperEditorDemoIconItem({
required this.id,
required this.icon,
});
/// The value that identifies this item.
final String id;
/// The icon that is displayed.
final IconData icon;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SuperEditorDemoIconItem &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}
/// A button with a center-left aligned [child] and a right aligned arrow icon.
///
/// The arrow is displayed above the [child].
class SuperEditorPopoverButton extends StatelessWidget {
const SuperEditorPopoverButton({
super.key,
this.padding,
required this.onTap,
this.child,
});
/// Padding around the [child].
final EdgeInsets? padding;
/// Called when the user taps the button.
final VoidCallback onTap;
/// The Widget displayed inside this button.
///
/// If `null`, only the arrow is displayed.
final Widget? child;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Center(
child: Stack(
alignment: Alignment.centerLeft,
children: [
if (child != null) //
Padding(
padding: padding ?? EdgeInsets.zero,
child: child,
),
const Positioned(
right: 0,
child: Icon(Icons.arrow_drop_down),
),
],
),
),
);
}
}

View File

@ -4,6 +4,7 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
//import 'package:flutter_gen/gen_l10n/app_localizations.dart'; //import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:follow_the_leader/follow_the_leader.dart';
import 'package:invoiceninja_flutter/utils/super_editor/super_editor_item_selector.dart';
import 'package:overlord/follow_the_leader.dart'; import 'package:overlord/follow_the_leader.dart';
import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor.dart';
@ -103,7 +104,6 @@ class _EditorToolbarState extends State<EditorToolbar> {
super.dispose(); super.dispose();
} }
/*
/// Returns true if the currently selected text node is capable of being /// Returns true if the currently selected text node is capable of being
/// transformed into a different type text node, returns false if /// transformed into a different type text node, returns false if
/// multiple nodes are selected, no node is selected, or the selected /// multiple nodes are selected, no node is selected, or the selected
@ -147,6 +147,7 @@ class _EditorToolbarState extends State<EditorToolbar> {
} }
} }
/*
/// Returns the text alignment of the currently selected text node. /// Returns the text alignment of the currently selected text node.
/// ///
/// Throws an exception if the currently selected node is not a text node. /// Throws an exception if the currently selected node is not a text node.
@ -184,6 +185,7 @@ class _EditorToolbarState extends State<EditorToolbar> {
final selectedNode = widget.document.getNodeById(selection.extent.nodeId); final selectedNode = widget.document.getNodeById(selection.extent.nodeId);
return selectedNode is ParagraphNode; return selectedNode is ParagraphNode;
} }
*/
/// Converts the currently selected text node into a new type of /// Converts the currently selected text node into a new type of
/// text node, represented by [newType]. /// text node, represented by [newType].
@ -260,7 +262,6 @@ class _EditorToolbarState extends State<EditorToolbar> {
return null; return null;
} }
} }
*/
/// Toggles bold styling for the current selected text. /// Toggles bold styling for the current selected text.
void _toggleBold() { void _toggleBold() {
@ -464,6 +465,7 @@ class _EditorToolbarState extends State<EditorToolbar> {
.getNodeById(widget.composer.selection!.extent.nodeId) as ParagraphNode; .getNodeById(widget.composer.selection!.extent.nodeId) as ParagraphNode;
selectedNode.putMetadataValue('textAlign', newAlignmentValue); selectedNode.putMetadataValue('textAlign', newAlignmentValue);
} }
*/
/// Returns the localized name for the given [_TextType], e.g., /// Returns the localized name for the given [_TextType], e.g.,
/// "Paragraph" or "Header 1". /// "Paragraph" or "Header 1".
@ -492,7 +494,17 @@ class _EditorToolbarState extends State<EditorToolbar> {
return 'Unordered List Item'; return 'Unordered List Item';
} }
} }
*/
/// Called when the user selects a block type on the toolbar.
void _onBlockTypeSelected(SuperEditorDemoTextItem? selectedItem) {
if (selectedItem != null) {
setState(() {
_convertTextToNewType(_TextType.values //
.where((e) => e.name == selectedItem.id)
.first);
});
}
}
void _onPerformAction(TextInputAction action) { void _onPerformAction(TextInputAction action) {
if (action == TextInputAction.done) { if (action == TextInputAction.done) {
@ -547,38 +559,16 @@ class _EditorToolbarState extends State<EditorToolbar> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
/*
// Only allow the user to select a new type of text node if // Only allow the user to select a new type of text node if
// the currently selected node can be converted. // the currently selected node can be converted.
if (_isConvertibleNode()) ...[ if (_isConvertibleNode()) ...[
Tooltip( Tooltip(
//message: AppLocalizations.of(context)!.labelTextBlockType, //message: AppLocalizations.of(context)!.labelTextBlockType,
message: 'Block Type', message: 'Block Type',
child: DropdownButton<_TextType>( child: _buildBlockTypeSelector(),
value: _getCurrentTextType(),
items: _TextType.values
.map((textType) => DropdownMenuItem<_TextType>(
value: textType,
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(_getTextTypeName(textType)),
),
))
.toList(),
icon: const Icon(Icons.arrow_drop_down),
style: const TextStyle(
color: Colors.black,
fontSize: 12,
),
underline: const SizedBox(),
elevation: 0,
itemHeight: 48,
onChanged: _convertTextToNewType,
),
), ),
_buildVerticalDivider(), _buildVerticalDivider(),
], ],
*/
Center( Center(
child: IconButton( child: IconButton(
onPressed: _toggleBold, onPressed: _toggleBold,
@ -673,6 +663,27 @@ class _EditorToolbarState extends State<EditorToolbar> {
); );
} }
Widget _buildBlockTypeSelector() {
final currentBlockType = _getCurrentTextType();
return SuperEditorDemoTextItemSelector(
parentFocusNode: widget.editorFocusNode,
boundaryKey: widget.editorViewportKey,
id: SuperEditorDemoTextItem(
id: currentBlockType.name,
label: _getTextTypeName(currentBlockType),
),
items: _TextType.values
.map(
(blockType) => SuperEditorDemoTextItem(
id: blockType.name,
label: _getTextTypeName(blockType),
),
)
.toList(),
onSelected: _onBlockTypeSelected,
);
}
Widget _buildUrlField() { Widget _buildUrlField() {
return Material( return Material(
shape: const StadiumBorder(), shape: const StadiumBorder(),
@ -735,7 +746,6 @@ class _EditorToolbarState extends State<EditorToolbar> {
); );
} }
/*
Widget _buildVerticalDivider() { Widget _buildVerticalDivider() {
return Container( return Container(
width: 1, width: 1,
@ -743,6 +753,7 @@ class _EditorToolbarState extends State<EditorToolbar> {
); );
} }
/*
IconData _buildTextAlignIcon(TextAlign align) { IconData _buildTextAlignIcon(TextAlign align) {
switch (align) { switch (align) {
case TextAlign.left: case TextAlign.left:
@ -760,7 +771,6 @@ class _EditorToolbarState extends State<EditorToolbar> {
*/ */
} }
/*
enum _TextType { enum _TextType {
header1, header1,
header2, header2,
@ -770,7 +780,6 @@ enum _TextType {
orderedListItem, orderedListItem,
unorderedListItem, unorderedListItem,
} }
*/
/// Small toolbar that is intended to display over an image and /// Small toolbar that is intended to display over an image and
/// offer controls to expand or contract the size of the image. /// offer controls to expand or contract the size of the image.

View File

@ -1,6 +1,6 @@
name: invoiceninja_flutter name: invoiceninja_flutter
description: Client for Invoice Ninja description: Client for Invoice Ninja
version: 5.0.146+146 version: 5.0.147+147
homepage: https://invoiceninja.com homepage: https://invoiceninja.com
documentation: https://invoiceninja.github.io documentation: https://invoiceninja.github.io
publish_to: none publish_to: none

View File

@ -46,7 +46,7 @@ packages:
description: description:
path: attributed_text path: attributed_text
ref: stable ref: stable
resolved-ref: "88af88145526519a1830336c83bb154ceba7235c" resolved-ref: cf157b787d118db0e80e92d5b10883ab58e9e21c
url: "https://github.com/superlistapp/super_editor" url: "https://github.com/superlistapp/super_editor"
source: git source: git
version: "0.2.2" version: "0.2.2"
@ -1532,7 +1532,7 @@ packages:
description: description:
path: super_editor path: super_editor
ref: stable ref: stable
resolved-ref: "88af88145526519a1830336c83bb154ceba7235c" resolved-ref: cf157b787d118db0e80e92d5b10883ab58e9e21c
url: "https://github.com/superlistapp/super_editor" url: "https://github.com/superlistapp/super_editor"
source: git source: git
version: "0.2.6" version: "0.2.6"
@ -1541,7 +1541,7 @@ packages:
description: description:
path: super_editor_markdown path: super_editor_markdown
ref: stable ref: stable
resolved-ref: "88af88145526519a1830336c83bb154ceba7235c" resolved-ref: cf157b787d118db0e80e92d5b10883ab58e9e21c
url: "https://github.com/superlistapp/super_editor" url: "https://github.com/superlistapp/super_editor"
source: git source: git
version: "0.1.5" version: "0.1.5"
@ -1550,7 +1550,7 @@ packages:
description: description:
path: super_text_layout path: super_text_layout
ref: stable ref: stable
resolved-ref: "88af88145526519a1830336c83bb154ceba7235c" resolved-ref: cf157b787d118db0e80e92d5b10883ab58e9e21c
url: "https://github.com/superlistapp/super_editor" url: "https://github.com/superlistapp/super_editor"
source: git source: git
version: "0.1.8" version: "0.1.8"

View File

@ -1,6 +1,6 @@
name: invoiceninja_flutter name: invoiceninja_flutter
description: Client for Invoice Ninja description: Client for Invoice Ninja
version: 5.0.146+146 version: 5.0.147+147
homepage: https://invoiceninja.com homepage: https://invoiceninja.com
documentation: https://invoiceninja.github.io documentation: https://invoiceninja.github.io
publish_to: none publish_to: none

View File

@ -1,5 +1,5 @@
name: invoiceninja name: invoiceninja
version: '5.0.146' version: '5.0.147'
summary: Create invoices, accept payments, track expenses & time tasks summary: Create invoices, accept payments, track expenses & time tasks
description: "### Note: if the app fails to run using `snap run invoiceninja` it may help to run `/snap/invoiceninja/current/bin/invoiceninja` instead description: "### Note: if the app fails to run using `snap run invoiceninja` it may help to run `/snap/invoiceninja/current/bin/invoiceninja` instead