Implemented better tags form field, persistent grid view setting, fixed hidden items in documents list

This commit is contained in:
Anton Stubenbord
2022-11-15 16:12:35 +01:00
parent 7fac53522a
commit 67ddf90a41
15 changed files with 322 additions and 164 deletions

View File

@@ -1,6 +1,8 @@
import 'dart:convert';
import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart';
import 'package:flutter/foundation.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/login/model/authentication_information.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
@@ -20,7 +22,7 @@ class LocalVault {
) async {
await sharedPreferences.setString(
authenticationKey,
json.encode(auth.toJson()),
jsonEncode(auth.toJson()),
);
}
@@ -29,7 +31,7 @@ class LocalVault {
return null;
}
return AuthenticationInformation.fromJson(
json.decode(await sharedPreferences.getString(authenticationKey)),
jsonDecode(await sharedPreferences.getString(authenticationKey)),
);
}
@@ -40,7 +42,9 @@ class LocalVault {
Future<bool> storeApplicationSettings(ApplicationSettingsState settings) {
return sharedPreferences.setString(
applicationSettingsKey, json.encode(settings.toJson()));
applicationSettingsKey,
jsonEncode(settings.toJson()),
);
}
Future<ApplicationSettingsState?> loadApplicationSettings() async {
@@ -48,7 +52,10 @@ class LocalVault {
if (settings.isEmpty) {
return null;
}
return ApplicationSettingsState.fromJson(json.decode(settings));
return compute(
ApplicationSettingsState.fromJson,
jsonDecode(settings) as JSON,
);
}
Future<void> clear() {

View File

@@ -138,8 +138,7 @@ class DocumentRepositoryImpl implements DocumentRepository {
body: json.encode(doc.toJson()),
headers: {"Content-Type": "application/json"}).timeout(requestTimeout);
if (response.statusCode == 200) {
return compute(
DocumentModel.fromJson,
return DocumentModel.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as JSON,
);
} else {

View File

@@ -22,6 +22,9 @@ import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart';
import 'package:paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
import 'package:paperless_mobile/util.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
@@ -40,7 +43,6 @@ class _DocumentsPageState extends State<DocumentsPage> {
);
final PanelController _panelController = PanelController();
ViewType _viewType = ViewType.list;
@override
void initState() {
@@ -149,76 +151,79 @@ class _DocumentsPageState extends State<DocumentsPage> {
}
Widget _buildBody(ConnectivityState connectivityState) {
return BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
// Some ugly tricks to make it work with bloc, update pageController
_pagingController.value = PagingState(
itemList: state.documents,
nextPageKey: state.nextPageNumber,
);
late Widget child;
switch (_viewType) {
case ViewType.list:
child = DocumentListView(
onTap: _openDocumentDetails,
state: state,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection:
connectivityState == ConnectivityState.connected,
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, settings) {
return BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
// Some ugly tricks to make it work with bloc, update pageController
_pagingController.value = PagingState(
itemList: state.documents,
nextPageKey: state.nextPageNumber,
);
break;
case ViewType.grid:
child = DocumentGridView(
onTap: _openDocumentDetails,
state: state,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection:
connectivityState == ConnectivityState.connected);
break;
}
if (state.isLoaded && state.documents.isEmpty) {
child = SliverToBoxAdapter(
child: DocumentsEmptyState(
state: state,
),
);
}
late Widget child;
switch (settings.preferredViewType) {
case ViewType.list:
child = DocumentListView(
onTap: _openDocumentDetails,
state: state,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection:
connectivityState == ConnectivityState.connected,
);
break;
case ViewType.grid:
child = DocumentGridView(
onTap: _openDocumentDetails,
state: state,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection:
connectivityState == ConnectivityState.connected);
break;
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: Container(
padding: const EdgeInsets.only(
bottom: 48 + kBottomNavigationBarHeight + 48,
), // Prevents panel from hiding scrollable content
child: CustomScrollView(
slivers: [
DocumentsPageAppBar(
actions: [
const SortDocumentsButton(),
IconButton(
icon: Icon(
_viewType == ViewType.grid
? Icons.list
: Icons.grid_view,
),
onPressed: () =>
setState(() => _viewType = _viewType.toggle()),
if (state.isLoaded && state.documents.isEmpty) {
child = SliverToBoxAdapter(
child: DocumentsEmptyState(
state: state,
),
);
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: Container(
child: CustomScrollView(
slivers: [
DocumentsPageAppBar(
actions: [
const SortDocumentsButton(),
IconButton(
icon: Icon(
settings.preferredViewType == ViewType.grid
? Icons.list
: Icons.grid_view,
),
onPressed: () =>
BlocProvider.of<ApplicationSettingsCubit>(context)
.setViewType(
settings.preferredViewType.toggle()),
),
],
),
child,
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.of(context).size.height / 4,
),
)
],
),
child,
// SliverToBoxAdapter(
// child: SizedBox(
// height: MediaQuery.of(context).size.height / 3,
// ),
// )
],
),
),
),
);
},
);
},
);
@@ -228,13 +233,16 @@ class _DocumentsPageState extends State<DocumentsPage> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MultiBlocProvider(
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
BlocProvider.value(value: getIt<CorrespondentCubit>()),
BlocProvider.value(value: getIt<DocumentTypeCubit>()),
BlocProvider.value(value: getIt<TagCubit>()),
BlocProvider.value(value: getIt<StoragePathCubit>()),
BlocProvider.value(value: BlocProvider.of<DocumentsCubit>(context)),
BlocProvider.value(
value: BlocProvider.of<CorrespondentCubit>(context)),
BlocProvider.value(
value: BlocProvider.of<DocumentTypeCubit>(context)),
BlocProvider.value(value: BlocProvider.of<TagCubit>(context)),
BlocProvider.value(
value: BlocProvider.of<StoragePathCubit>(context)),
],
child: DocumentDetailsPage(
documentId: model.id,
@@ -244,12 +252,3 @@ class _DocumentsPageState extends State<DocumentsPage> {
);
}
}
enum ViewType {
grid,
list;
ViewType toggle() {
return this == grid ? list : grid;
}
}

View File

@@ -155,6 +155,7 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
TagFormField(
name: DocumentModel.tagsKey,
initialValue: state.filter.tags,
allowCreation: false,
).padded(),
// Required in order for the storage path field to be visible when typing
const SizedBox(

View File

@@ -8,7 +8,8 @@ import 'package:paperless_mobile/generated/l10n.dart';
import 'package:form_builder_extra_fields/form_builder_extra_fields.dart';
class AddTagPage extends StatelessWidget {
const AddTagPage({Key? key}) : super(key: key);
final String? initialValue;
const AddTagPage({Key? key, this.initialValue}) : super(key: key);
@override
Widget build(BuildContext context) {
@@ -16,6 +17,7 @@ class AddTagPage extends StatelessWidget {
addLabelStr: S.of(context).addTagPageTitle,
fromJson: Tag.fromJson,
cubit: BlocProvider.of<TagCubit>(context),
initialName: initialValue,
additionalFields: [
FormBuilderColorPickerField(
name: Tag.colorKey,

View File

@@ -1,20 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:paperless_mobile/features/labels/tags/view/pages/add_tag_page.dart';
import 'package:paperless_mobile/generated/l10n.dart';
class TagFormField extends StatefulWidget {
final TagsQuery? initialValue;
final String name;
final bool allowCreation;
final bool notAssignedSelectable;
const TagFormField({
super.key,
required this.name,
this.initialValue,
this.allowCreation = true,
this.notAssignedSelectable = true,
});
@override
@@ -22,72 +27,110 @@ class TagFormField extends StatefulWidget {
}
class _TagFormFieldState extends State<TagFormField> {
late final TextEditingController _textEditingController;
bool _showCreationSuffixIcon = false;
bool _showClearSuffixIcon = false;
@override
void initState() {
super.initState();
final state = BlocProvider.of<TagCubit>(context).state;
_textEditingController = TextEditingController()
..addListener(() {
setState(() {
_showCreationSuffixIcon = state.values
.where(
(item) => item.name.toLowerCase().startsWith(
_textEditingController.text.toLowerCase(),
),
)
.isEmpty;
});
setState(() =>
_showClearSuffixIcon = _textEditingController.text.isNotEmpty);
});
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TagCubit, Map<int, Tag>>(
builder: (context, tagState) {
return FormBuilderField<TagsQuery>(
builder: (field) {
final sortedTags = tagState.values.toList()
..sort(
(a, b) => a.name.compareTo(b.name),
);
//TODO: this is either not correctly resetting on filter reset or (when adding UniqueKey to FormField or ChipsInput) unmounts widget.
// return ChipsInput<int>(
// chipBuilder: (context, state, data) => Chip(
// onDeleted: () => state.deleteChip(data),
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
// backgroundColor: Color(tagState[data]!.color ?? Colors.white.value),
// label: Text(
// tagState[data]!.name,
// style: TextStyle(color: Color(tagState[data]!.textColor ?? Colors.black.value)),
// ),
// ),
// suggestionBuilder: (context, state, data) => ListTile(
// title: Text(tagState[data]!.name),
// textColor: Color(tagState[data]!.textColor!),
// tileColor: Color(tagState[data]!.color!),
// onTap: () => state.selectSuggestion(data),
// ),
// findSuggestions: (query) => tagState.values
// .where((element) => element.name.toLowerCase().startsWith(query.toLowerCase()))
// .map((e) => e.id!)
// .toList(),
// onChanged: (tags) => field.didChange(tags),
// initialValue: field.value!,
// );
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context).documentTagsPropertyLabel,
),
Wrap(
children: sortedTags
.map((tag) => FilterChip(
label: Text(
tag.name,
style: TextStyle(
color: tag.textColor,
),
),
selectedColor: tag.color,
selected:
field.value?.ids.contains(tag.id) ?? false,
onSelected: (isSelected) {
List<int> ids = [...field.value?.ids ?? []];
if (isSelected) {
ids.add(tag.id!);
} else {
ids.remove(tag.id);
}
field.didChange(TagsQuery.fromIds(ids));
},
backgroundColor: tag.color,
))
.toList()
.padded(const EdgeInsets.only(right: 4.0)),
TypeAheadField<int>(
textFieldConfiguration: TextFieldConfiguration(
decoration: InputDecoration(
prefixIcon: const Icon(
Icons.label_outline,
),
suffixIcon: _buildSuffixIcon(context, field),
labelText: S.of(context).documentTagsPropertyLabel,
hintText: S.of(context).tagFormFieldSearchHintText,
),
controller: _textEditingController,
),
suggestionsCallback: (query) {
final suggestions = tagState.values
.where((element) => element.name
.toLowerCase()
.startsWith(query.toLowerCase()))
.map((e) => e.id!)
.toList()
..removeWhere((element) =>
field.value?.ids.contains(element) ?? false);
if (widget.notAssignedSelectable) {
suggestions.insert(0, -1);
}
return suggestions;
},
getImmediateSuggestions: true,
animationStart: 1,
itemBuilder: (context, data) {
if (data == -1) {
return ListTile(
title: Text(S.of(context).labelNotAssignedText),
);
}
final tag = tagState[data]!;
return ListTile(
leading: Icon(
Icons.circle,
color: tag.color,
),
title: Text(
tag.name,
style: TextStyle(
color: Theme.of(context).colorScheme.onBackground),
),
);
},
onSuggestionSelected: (id) {
if (id == -1) {
field.didChange(const TagsQuery.notAssigned());
return;
} else {
field.didChange(
TagsQuery.fromIds([...field.value?.ids ?? [], id]));
}
_textEditingController.clear();
},
direction: AxisDirection.up,
),
if (field.value?.onlyNotAssigned ?? false) ...[
_buildNotAssignedTag(field)
] else ...[
Wrap(
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
spacing: 8.0,
children: (field.value?.ids ?? [])
.map((id) => _buildTag(field, tagState[id]!))
.toList(),
),
]
],
);
},
@@ -97,4 +140,75 @@ class _TagFormFieldState extends State<TagFormField> {
},
);
}
Widget? _buildSuffixIcon(
BuildContext context,
FormFieldState<TagsQuery> field,
) {
if (_showCreationSuffixIcon && widget.allowCreation) {
return IconButton(
onPressed: () => _onAddTag(context, field),
icon: const Icon(
Icons.new_label,
),
);
}
if (_showClearSuffixIcon) {
return IconButton(
icon: const Icon(Icons.clear),
onPressed: _textEditingController.clear,
);
}
return null;
}
void _onAddTag(BuildContext context, FormFieldState<TagsQuery> field) async {
final Tag? tag = await Navigator.of(context).push<Tag>(
MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: BlocProvider.of<TagCubit>(context),
child: AddTagPage(initialValue: _textEditingController.text),
),
),
);
if (tag != null) {
field.didChange(
TagsQuery.fromIds([...field.value?.ids ?? [], tag.id!]),
);
}
_textEditingController.clear();
// Call has to be delayed as otherwise the framework will not hide the keyboard directly after closing the add page.
Future.delayed(
const Duration(milliseconds: 100),
FocusScope.of(context).unfocus,
);
}
Widget _buildNotAssignedTag(FormFieldState<TagsQuery> field) {
return InputChip(
label: Text(
S.of(context).labelNotAssignedText,
),
backgroundColor:
Theme.of(context).colorScheme.onSurface.withOpacity(0.12),
onDeleted: () => field.didChange(
const TagsQuery.unset(),
),
);
}
Widget _buildTag(FormFieldState<TagsQuery> field, Tag tag) {
return InputChip(
label: Text(
tag.name,
style: TextStyle(color: tag.textColor),
),
backgroundColor: tag.color,
onDeleted: () => field.didChange(
TagsQuery.fromIds(
field.value?.ids.where((element) => element != tag.id).toList() ?? [],
),
),
);
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';

View File

@@ -151,7 +151,16 @@ class _LabelsPageState extends State<LabelsPage>
pageSize: label.documentCount ?? 0,
),
onOpenEditPage: _openEditTagPage,
leadingBuilder: (t) => CircleAvatar(backgroundColor: t.color),
leadingBuilder: (t) => CircleAvatar(
backgroundColor: t.color,
child: t.isInboxTag ?? false
? Icon(
Icons.inbox,
color: t.textColor,
)
: null,
),
contentBuilder: (t) => Text(t.match ?? ''),
emptyStateActionButtonLabel:
S.of(context).labelsPageTagsEmptyStateAddNewLabel,
emptyStateDescription:

View File

@@ -84,9 +84,13 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
Future<void> restoreSessionState() async {
final storedAuth = await localStore.loadAuthenticationInformation();
final appSettings = await localStore.loadApplicationSettings() ??
ApplicationSettingsState.defaultSettings;
late ApplicationSettingsState? appSettings;
try {
appSettings = await localStore.loadApplicationSettings() ??
ApplicationSettingsState.defaultSettings;
} catch (err) {
appSettings = ApplicationSettingsState.defaultSettings;
}
if (storedAuth == null || !storedAuth.isValid) {
emit(AuthenticationState(isAuthenticated: false, wasLoginStored: false));
} else {

View File

@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/store/local_vault.dart';
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
import 'package:injectable/injectable.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
@singleton
class ApplicationSettingsCubit extends Cubit<ApplicationSettingsState> {
@@ -28,13 +29,18 @@ class ApplicationSettingsCubit extends Cubit<ApplicationSettingsState> {
_updateSettings(updatedSettings);
}
Future<void> _updateSettings(ApplicationSettingsState settings) async {
await localVault.storeApplicationSettings(settings);
emit(settings);
}
Future<void> setThemeMode(ThemeMode? selectedMode) async {
final updatedSettings = state.copyWith(preferredThemeMode: selectedMode);
_updateSettings(updatedSettings);
}
Future<void> setViewType(ViewType viewType) async {
final updatedSettings = state.copyWith(preferredViewType: viewType);
_updateSettings(updatedSettings);
}
Future<void> _updateSettings(ApplicationSettingsState settings) async {
await localVault.storeApplicationSettings(settings);
emit(settings);
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
///
/// State holding the current application settings such as selected language, theme mode and more.
@@ -12,44 +13,47 @@ class ApplicationSettingsState {
isLocalAuthenticationEnabled: false,
preferredLocaleSubtag: Platform.localeName.split('_').first,
preferredThemeMode: ThemeMode.system,
preferredViewType: ViewType.list,
);
static const isLocalAuthenticationEnabledKey = "isLocalAuthenticationEnabled";
static const preferredLocaleSubtagKey = "localeSubtag";
static const preferredThemeModeKey = "preferredThemeModeKey";
static const preferredViewTypeKey = 'preferredViewType';
final bool isLocalAuthenticationEnabled;
final String preferredLocaleSubtag;
final ThemeMode preferredThemeMode;
final ViewType preferredViewType;
ApplicationSettingsState({
required this.preferredLocaleSubtag,
required this.preferredThemeMode,
required this.isLocalAuthenticationEnabled,
required this.preferredViewType,
});
JSON toJson() {
return {
isLocalAuthenticationEnabledKey: isLocalAuthenticationEnabled,
preferredLocaleSubtagKey: preferredLocaleSubtag,
preferredThemeModeKey: preferredThemeMode.index,
preferredThemeModeKey: preferredThemeMode.name,
preferredViewTypeKey: preferredViewType.name,
};
}
ApplicationSettingsState.fromJson(JSON json)
: isLocalAuthenticationEnabled = json[isLocalAuthenticationEnabledKey] ??
defaultSettings.isLocalAuthenticationEnabled,
preferredLocaleSubtag = json[preferredLocaleSubtagKey] ??
Platform.localeName.split("_").first,
preferredThemeMode = json[preferredThemeModeKey] != null
? ThemeMode.values[(json[preferredThemeModeKey])]
: defaultSettings.preferredThemeMode;
: isLocalAuthenticationEnabled = json[isLocalAuthenticationEnabledKey],
preferredLocaleSubtag = json[preferredLocaleSubtagKey],
preferredThemeMode =
ThemeMode.values.byName(json[preferredThemeModeKey]),
preferredViewType = ViewType.values.byName(json[preferredViewTypeKey]);
ApplicationSettingsState copyWith({
bool? isLocalAuthenticationEnabled,
String? preferredLocaleSubtag,
ThemeMode? preferredThemeMode,
ViewType? preferredViewType,
}) {
return ApplicationSettingsState(
isLocalAuthenticationEnabled:
@@ -57,6 +61,7 @@ class ApplicationSettingsState {
preferredLocaleSubtag:
preferredLocaleSubtag ?? this.preferredLocaleSubtag,
preferredThemeMode: preferredThemeMode ?? this.preferredThemeMode,
preferredViewType: preferredViewType ?? this.preferredViewType,
);
}
}

View File

@@ -0,0 +1,8 @@
enum ViewType {
grid,
list;
ViewType toggle() {
return this == grid ? list : grid;
}
}

View File

@@ -46,6 +46,7 @@ void main() async {
getIt<ConnectivityCubit>().initialize();
await getIt<ApplicationSettingsCubit>().initialize();
await getIt<AuthenticationCubit>().initialize();
runApp(const MyApp());
}
@@ -64,9 +65,9 @@ class _MyAppState extends State<MyApp> {
BlocProvider.value(value: getIt<ConnectivityCubit>()),
BlocProvider.value(value: getIt<AuthenticationCubit>()),
BlocProvider.value(value: getIt<PaperlessServerInformationCubit>()),
BlocProvider.value(value: getIt<ApplicationSettingsCubit>()),
],
child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
bloc: getIt<ApplicationSettingsCubit>(),
builder: (context, settings) {
return MaterialApp(
debugShowCheckedModeBanner: true,

View File

@@ -577,7 +577,7 @@ packages:
source: hosted
version: "2.0.0"
flutter_typeahead:
dependency: transitive
dependency: "direct main"
description:
name: flutter_typeahead
url: "https://pub.dartlang.org"

View File

@@ -79,6 +79,7 @@ dependencies:
mime: ^1.0.2
receive_sharing_intent: ^1.4.5
uuid: ^3.0.6
flutter_typeahead: ^4.1.1
dev_dependencies:
integration_test: