fix: Enable logging in production

This commit is contained in:
Anton Stubenbord
2023-10-19 18:26:02 +02:00
parent 7d1c0dffe4
commit 520bfbd7b1
104 changed files with 632 additions and 257 deletions

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
class ThemeModeAdapter extends TypeAdapter<ThemeMode> {
@override

View File

@@ -1,6 +1,6 @@
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/custom_adapters/theme_mode_adapter.dart';
import 'package:paperless_mobile/core/database/hive/custom_adapters/theme_mode_adapter.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/database/tables/user_credentials.dart';

View File

@@ -4,7 +4,7 @@ import 'dart:typed_data';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
import 'package:paperless_mobile/features/settings/model/file_download_type.dart';

View File

@@ -1,6 +1,6 @@
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/local_user_settings.dart';
part 'local_user_account.g.dart';

View File

@@ -1,6 +1,6 @@
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';

View File

@@ -1,5 +1,5 @@
import 'package:hive/hive.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
part 'local_user_settings.g.dart';

View File

@@ -1,5 +1,5 @@
import 'package:hive/hive.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
part 'user_credentials.g.dart';

View File

@@ -0,0 +1,48 @@
extension NullableMapKey<K, V> on Map<K, V> {
V? tryPutIfAbsent(K key, V? Function() ifAbsent) {
final value = ifAbsent();
if (value == null) {
return null;
}
return putIfAbsent(key, () => value);
}
}
extension Unique<E, Id> on List<E> {
List<E> unique([Id Function(E element)? id, bool inplace = true]) {
final ids = <Id>{};
var list = inplace ? this : List<E>.from(this);
list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id));
return list;
}
}
extension DuplicateCheckable<T> on Iterable<T> {
bool hasDuplicates() {
return toSet().length != length;
}
}
extension DateHelpers on DateTime {
bool get isToday {
final now = DateTime.now();
return now.day == day && now.month == month && now.year == year;
}
bool get isYesterday {
final yesterday = DateTime.now().subtract(const Duration(days: 1));
return yesterday.day == day &&
yesterday.month == month &&
yesterday.year == year;
}
bool isOnSameDayAs(DateTime other) {
return other.day == day && other.month == month && other.year == year;
}
}
extension StringNormalizer on String {
String normalized() {
return trim().toLowerCase();
}
}

View File

@@ -0,0 +1,18 @@
import 'package:collection/collection.dart';
import 'package:paperless_api/paperless_api.dart';
extension DocumentModelIterableExtension on Iterable<DocumentModel> {
Iterable<int> get ids => map((e) => e.id);
Iterable<DocumentModel> withDocumentreplaced(DocumentModel document) {
return map((e) => e.id == document.id ? document : e);
}
bool containsDocument(DocumentModel document) {
return ids.contains(document.id);
}
Iterable<DocumentModel> withDocumentRemoved(DocumentModel document) {
return whereNot((element) => element.id == document.id);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/widgets.dart';
extension WidgetPadding on Widget {
Widget padded([double all = 8.0]) {
return Padding(
padding: EdgeInsets.all(all),
child: this,
);
}
Widget paddedSymmetrically({
double horizontal = 0.0,
double vertical = 0.0,
}) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: horizontal, vertical: vertical),
child: this,
);
}
Widget paddedOnly({
double top = 0.0,
double bottom = 0.0,
double left = 0.0,
double right = 0.0,
}) {
return Padding(
padding: EdgeInsets.only(
top: top,
bottom: bottom,
left: left,
right: right,
),
child: this,
);
}
Widget paddedLTRB(double left, double top, double right, double bottom) {
return Padding(
padding: EdgeInsets.fromLTRB(left, top, right, bottom),
child: this,
);
}
}
extension WidgetsPadding on List<Widget> {
List<Widget> padded([EdgeInsetsGeometry value = const EdgeInsets.all(8)]) {
return map((child) => Padding(
padding: value,
child: child,
)).toList();
}
}

View File

@@ -1,111 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:paperless_mobile/core/logging/models/parsed_log_message.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:path/path.dart' as p;
import 'package:rxdart/rxdart.dart';
part 'app_logs_state.dart';
final _fileNameFormat = DateFormat("yyyy-MM-dd");
class AppLogsCubit extends Cubit<AppLogsState> {
StreamSubscription? _fileChangesSubscription;
AppLogsCubit(DateTime date) : super(AppLogsStateInitial(date: date));
Future<void> loadLogs(DateTime date) async {
if (date == state.date) {
return;
}
_fileChangesSubscription?.cancel();
emit(AppLogsStateLoading(date: date));
final logDir = FileService.instance.logDirectory;
final availableLogs = (await logDir
.list()
.whereType<File>()
.where((event) => event.path.endsWith('.log'))
.map((e) =>
_fileNameFormat.parse(p.basenameWithoutExtension(e.path)))
.toList())
.sorted();
final logFile = _getLogfile(date);
if (!await logFile.exists()) {
emit(AppLogsStateLoaded(
date: date,
logs: [],
availableLogs: availableLogs,
));
}
try {
final logs = await logFile.readAsLines();
final parsedLogs =
ParsedLogMessage.parse(logs.skip(2000).toList()).reversed.toList();
_fileChangesSubscription = logFile.watch().listen((event) async {
if (!isClosed) {
final logs = await logFile.readAsLines();
emit(AppLogsStateLoaded(
date: date,
logs: parsedLogs,
availableLogs: availableLogs,
));
}
});
emit(AppLogsStateLoaded(
date: date,
logs: parsedLogs,
availableLogs: availableLogs,
));
} catch (e) {
emit(AppLogsStateError(
error: e,
date: date,
));
}
}
Future<void> clearLogs(DateTime date) async {
final logFile = _getLogfile(date);
await logFile.writeAsString('');
await loadLogs(date);
}
Future<void> copyToClipboard(DateTime date) async {
final file = _getLogfile(date);
if (!await file.exists()) {
return;
}
final content = await file.readAsString();
Clipboard.setData(ClipboardData(text: content));
}
Future<void> saveLogs(DateTime date, String locale) async {
var formattedDate = _fileNameFormat.format(date);
final filename = 'paperless_mobile_logs_$formattedDate.log';
final parentDir = await FilePicker.platform.getDirectoryPath(
dialogTitle: "Save log from ${DateFormat.yMd(locale).format(date)}",
initialDirectory: Platform.isAndroid
? FileService.instance.downloadsDirectory.path
: null,
);
final logFile = _getLogfile(date);
if (parentDir != null) {
await logFile.copy(p.join(parentDir, filename));
}
}
File _getLogfile(DateTime date) {
return File(p.join(FileService.instance.logDirectory.path,
'${_fileNameFormat.format(date)}.log'));
}
@override
Future<void> close() {
_fileChangesSubscription?.cancel();
return super.close();
}
}

View File

@@ -1,33 +0,0 @@
part of 'app_logs_cubit.dart';
sealed class AppLogsState {
final DateTime date;
const AppLogsState({required this.date});
}
class AppLogsStateInitial extends AppLogsState {
const AppLogsStateInitial({required super.date});
}
class AppLogsStateLoading extends AppLogsState {
const AppLogsStateLoading({required super.date});
}
class AppLogsStateLoaded extends AppLogsState {
const AppLogsStateLoaded({
required super.date,
required this.logs,
required this.availableLogs,
});
final List<DateTime> availableLogs;
final List<ParsedLogMessage> logs;
}
class AppLogsStateError extends AppLogsState {
const AppLogsStateError({
required this.error,
required super.date,
});
final Object error;
}

View File

@@ -1,44 +0,0 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import 'package:logger/logger.dart';
import 'package:paperless_mobile/core/logging/models/formatted_log_message.dart';
class FormattedPrinter extends LogPrinter {
static final _timestampFormat = DateFormat("yyyy-MM-dd HH:mm:ss.SSS");
static const _mulitlineObjectEncoder = JsonEncoder.withIndent(null);
@override
List<String> log(LogEvent event) {
final unformattedMessage = event.message;
final formattedMessage = switch (unformattedMessage) {
FormattedLogMessage m => m.format(),
Iterable i => _mulitlineObjectEncoder
.convert(i)
.padLeft(FormattedLogMessage.maxLength),
Map m => _mulitlineObjectEncoder
.convert(m)
.padLeft(FormattedLogMessage.maxLength),
_ => unformattedMessage.toString().padLeft(FormattedLogMessage.maxLength),
};
final formattedLevel = event.level.name
.toUpperCase()
.padRight(Level.values.map((e) => e.name.length).max);
final formattedTimestamp = _timestampFormat.format(event.time);
return [
'$formattedTimestamp\t$formattedLevel --- $formattedMessage',
if (event.error != null) ...[
"---BEGIN ERROR---",
event.error.toString(),
"---END ERROR---",
],
if (event.stackTrace != null) ...[
"---BEGIN STACKTRACE---",
event.stackTrace.toString(),
"---END STACKTRACE---"
],
];
}
}

View File

@@ -1,116 +0,0 @@
import 'package:logger/logger.dart';
import 'package:paperless_mobile/core/logging/models/formatted_log_message.dart';
late Logger logger;
extension FormattedLoggerExtension on Logger {
void ft(
dynamic message, {
String className = '',
String methodName = '',
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
final formattedMessage = FormattedLogMessage(
message,
className: className,
methodName: methodName,
);
log(
Level.trace,
formattedMessage,
time: time,
error: error,
stackTrace: stackTrace,
);
}
void fw(
dynamic message, {
String className = '',
String methodName = '',
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
final formattedMessage = FormattedLogMessage(
message,
className: className,
methodName: methodName,
);
log(
Level.warning,
formattedMessage,
time: time,
error: error,
stackTrace: stackTrace,
);
}
void fd(
dynamic message, {
String className = '',
String methodName = '',
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
final formattedMessage = FormattedLogMessage(
message,
className: className,
methodName: methodName,
);
log(
Level.debug,
formattedMessage,
time: time,
error: error,
stackTrace: stackTrace,
);
}
void fi(
dynamic message, {
String className = '',
String methodName = '',
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
final formattedMessage = FormattedLogMessage(
message,
className: className,
methodName: methodName,
);
log(
Level.info,
formattedMessage,
time: time,
error: error,
stackTrace: stackTrace,
);
}
void fe(
dynamic message, {
String className = '',
String methodName = '',
DateTime? time,
Object? error,
StackTrace? stackTrace,
}) {
final formattedMessage = FormattedLogMessage(
message,
className: className,
methodName: methodName,
);
log(
Level.error,
formattedMessage,
time: time,
error: error,
stackTrace: stackTrace,
);
}
}

View File

@@ -1,54 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:logger/logger.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:path/path.dart' as p;
import 'package:synchronized/synchronized.dart';
class MirroredFileOutput extends LogOutput {
final Completer _initCompleter = Completer();
var lock = Lock();
MirroredFileOutput();
late final File file;
@override
Future<void> init() async {
final today = DateFormat("yyyy-MM-dd").format(DateTime.now());
final logDir = FileService.instance.logDirectory;
file = File(p.join(logDir.path, '$today.log'));
debugPrint("Logging files to ${file.path}.");
_initCompleter.complete();
try {
final oldLogs = await FileService.instance.getAllFiles(logDir);
if (oldLogs.length > 10) {
oldLogs
.sortedBy((file) => file.lastModifiedSync())
.reversed
.skip(10)
.forEach((log) => log.delete());
}
} catch (e) {
debugPrint("Failed to delete old logs...");
}
}
@override
void output(OutputEvent event) async {
await lock.synchronized(() async {
for (var line in event.lines) {
debugPrint(line);
if (_initCompleter.isCompleted) {
await file.writeAsString(
"$line${Platform.lineTerminator}",
mode: FileMode.append,
);
}
}
});
}
}

View File

@@ -1,19 +0,0 @@
/// Class passed to the printer to be formatted and printed.
class FormattedLogMessage {
static const maxLength = 55;
final String message;
final String methodName;
final String className;
FormattedLogMessage(
this.message, {
required this.methodName,
required this.className,
});
String format() {
final formattedClassName = className.padLeft(25);
final formattedMethodName = methodName.padRight(25);
return '[$formattedClassName] - $formattedMethodName: $message';
}
}

View File

@@ -1,148 +0,0 @@
import 'dart:io';
import 'package:logger/logger.dart';
final _newLine = Platform.lineTerminator;
sealed class ParsedLogMessage {
static List<ParsedLogMessage> parse(List<String> logs) {
List<ParsedLogMessage> messages = [];
int offset = 0;
while (offset < logs.length) {
final currentLine = logs[offset];
if (ParsedFormattedLogMessage.canConsumeFirstLine(currentLine)) {
final (consumedLines, result) =
ParsedFormattedLogMessage.consume(logs.sublist(offset));
messages.add(result);
offset += consumedLines;
} else {
messages.add(UnformattedLogMessage(currentLine));
offset++;
}
}
return messages;
}
}
class ParsedErrorLogMessage {
static final RegExp _errorBeginPattern = RegExp(r"---BEGIN ERROR---\s*");
static final RegExp _errorEndPattern = RegExp(r"---END ERROR---\s*");
static final RegExp _stackTraceBeginPattern =
RegExp(r"---BEGIN STACKTRACE---\s*");
static final RegExp _stackTraceEndPattern =
RegExp(r"---END STACKTRACE---\s*");
final String error;
final String? stackTrace;
ParsedErrorLogMessage({
required this.error,
this.stackTrace,
});
static bool canConsumeFirstLine(String line) =>
_errorBeginPattern.hasMatch(line);
static (int consumedLines, ParsedErrorLogMessage result) consume(
List<String> log) {
assert(log.isNotEmpty && canConsumeFirstLine(log.first));
String errorText = "";
int currentLine =
1; // Skip first because we know that the first line is ---BEGIN ERROR---
while (!_errorEndPattern.hasMatch(log[currentLine])) {
errorText += log[currentLine] + _newLine;
currentLine++;
}
currentLine++;
final hasStackTrace = _stackTraceBeginPattern.hasMatch(log[currentLine]);
String? stackTrace;
if (hasStackTrace) {
currentLine++;
String stackTraceText = '';
while (!_stackTraceEndPattern.hasMatch(log[currentLine])) {
stackTraceText += log[currentLine] + _newLine;
currentLine++;
}
stackTrace = stackTraceText;
}
return (
currentLine + 1,
ParsedErrorLogMessage(error: errorText, stackTrace: stackTrace)
);
}
}
class UnformattedLogMessage extends ParsedLogMessage {
final String message;
UnformattedLogMessage(this.message);
}
class ParsedFormattedLogMessage extends ParsedLogMessage {
static final RegExp pattern = RegExp(
r'(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s*(?<level>[A-Z]*)'
r'\s*---\s*(?:\[\s*(?<className>.*)\]\s*-\s*(?<methodName>.*)\s*)?:\s*(?<message>.+)',
);
final Level level;
final String message;
final String? className;
final String? methodName;
final DateTime timestamp;
final ParsedErrorLogMessage? error;
ParsedFormattedLogMessage({
required this.level,
required this.message,
this.className,
this.methodName,
required this.timestamp,
this.error,
});
static bool canConsumeFirstLine(String line) => pattern.hasMatch(line);
static (int consumedLines, ParsedFormattedLogMessage result) consume(
List<String> log) {
assert(log.isNotEmpty && canConsumeFirstLine(log.first));
final match = pattern.firstMatch(log.first)!;
final result = ParsedFormattedLogMessage(
level: Level.values.byName(match.namedGroup('level')!.toLowerCase()),
message: match.namedGroup('message')!,
className: match.namedGroup('className'),
methodName: match.namedGroup('methodName'),
timestamp: DateTime.parse(match.namedGroup('timestamp')!),
);
final updatedLog = log.sublist(1);
if (updatedLog.isEmpty) {
return (1, result);
}
if (ParsedErrorLogMessage.canConsumeFirstLine(updatedLog.first)) {
final (consumedLines, parsedError) =
ParsedErrorLogMessage.consume(updatedLog);
return (
consumedLines + 1,
result.copyWith(error: parsedError),
);
}
return (1, result);
}
ParsedFormattedLogMessage copyWith({
Level? level,
String? message,
String? className,
String? methodName,
DateTime? timestamp,
ParsedErrorLogMessage? error,
}) {
return ParsedFormattedLogMessage(
level: level ?? this.level,
message: message ?? this.message,
className: className ?? this.className,
methodName: methodName ?? this.methodName,
timestamp: timestamp ?? this.timestamp,
error: error ?? this.error,
);
}
}

View File

@@ -1,22 +0,0 @@
(String username, String obscuredUrl) splitRedactUserId(String userId) {
final parts = userId.split('@');
if (parts.length != 2) {
return ('unknown', 'unknown');
}
final username = parts.first;
final serverUrl = parts.last;
final uri = Uri.parse(serverUrl);
final hostLen = uri.host.length;
final obscuredUrl = uri.scheme +
"://" +
uri.host.substring(0, 2) +
List.filled(hostLen - 4, '*').join() +
uri.host.substring(uri.host.length - 2, uri.host.length);
return (username, obscuredUrl);
}
String redactUserId(String userId) {
final (username, obscuredUrl) = splitRedactUserId(userId);
return '$username@$obscuredUrl';
}

View File

@@ -1,283 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:logger/logger.dart';
import 'package:paperless_mobile/core/logging/cubit/app_logs_cubit.dart';
import 'package:paperless_mobile/core/logging/models/parsed_log_message.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class AppLogsPage extends StatefulWidget {
const AppLogsPage({super.key});
@override
State<AppLogsPage> createState() => _AppLogsPageState();
}
class _AppLogsPageState extends State<AppLogsPage> {
final ScrollController _scrollController = ScrollController();
bool autoScroll = true;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
return BlocBuilder<AppLogsCubit, AppLogsState>(
builder: (context, state) {
final formattedDate = DateFormat.yMMMd(locale).format(state.date);
return Scaffold(
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: switch (state) {
AppLogsStateInitial() => [],
AppLogsStateLoading() => [],
AppLogsStateLoaded() => [
IconButton(
tooltip: S.of(context)!.copyToClipboard,
onPressed: () {
context
.read<AppLogsCubit>()
.copyToClipboard(state.date);
},
icon: const Icon(Icons.copy),
).padded(),
IconButton(
tooltip: S.of(context)!.saveLogsToFile,
onPressed: () {
context
.read<AppLogsCubit>()
.saveLogs(state.date, locale);
},
icon: const Icon(Icons.download),
).padded(),
IconButton(
tooltip: S.of(context)!.clearLogs(formattedDate),
onPressed: () {
context.read<AppLogsCubit>().clearLogs(state.date);
},
icon: Icon(
Icons.delete_sweep,
color: Theme.of(context).colorScheme.error,
),
).padded(),
],
_ => [],
},
),
),
appBar: AppBar(
title: Text(S.of(context)!.appLogs(formattedDate)),
actions: [
if (state is AppLogsStateLoaded)
IconButton(
tooltip: MaterialLocalizations.of(context).datePickerHelpText,
onPressed: () async {
final selectedDate = await showDatePicker(
context: context,
initialDate: state.date,
firstDate: state.availableLogs.first,
lastDate: state.availableLogs.last,
selectableDayPredicate: (day) => state.availableLogs
.any((date) => day.isOnSameDayAs(date)),
initialEntryMode: DatePickerEntryMode.calendarOnly,
);
if (selectedDate != null) {
context.read<AppLogsCubit>().loadLogs(selectedDate);
}
},
icon: const Icon(Icons.calendar_today),
).padded(),
],
),
body: switch (state) {
AppLogsStateLoaded(
logs: var logs,
) =>
Builder(
builder: (context) {
if (state.logs.isEmpty) {
return Center(
child: Text(S.of(context)!.noLogsFoundOn(formattedDate)),
);
}
return ListView.builder(
reverse: true,
controller: _scrollController,
itemBuilder: (context, index) {
if (index == 0) {
return Center(
child: Text(S.of(context)!.logfileBottomReached,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.disabledColor,
)),
).padded(24);
}
final messages = state.logs;
final logMessage = messages[index - 1];
final altColor = CupertinoDynamicColor.withBrightness(
color: Colors.grey.shade200,
darkColor: Colors.grey.shade800,
).resolveFrom(context);
return ParsedLogMessageTile(
message: logMessage,
backgroundColor: (index % 2 == 0)
? theme.colorScheme.background
: altColor,
);
},
itemCount: logs.length + 1,
);
},
),
AppLogsStateError() => Center(
child: Text(
S.of(context)!.couldNotLoadLogfileFrom(formattedDate),
),
),
_ => _buildLoadingLogs(state.date)
},
);
},
);
}
Widget _buildLoadingLogs(DateTime date) {
final formattedDate =
DateFormat.yMd(Localizations.localeOf(context).toString()).format(date);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
Text(S.of(context)!.loadingLogsFrom(formattedDate)),
],
),
);
}
}
class ParsedLogMessageTile extends StatelessWidget {
final ParsedLogMessage message;
final Color backgroundColor;
const ParsedLogMessageTile({
super.key,
required this.message,
required this.backgroundColor,
});
@override
Widget build(BuildContext context) {
return switch (message) {
ParsedFormattedLogMessage m => FormattedLogMessageWidget(
message: m,
backgroundColor: backgroundColor,
),
UnformattedLogMessage(message: var m) => Text(m),
};
}
}
class FormattedLogMessageWidget extends StatelessWidget {
final ParsedFormattedLogMessage message;
final Color backgroundColor;
const FormattedLogMessageWidget(
{super.key, required this.message, required this.backgroundColor});
static final _timeFormat = DateFormat("HH:mm:ss.SSS");
@override
Widget build(BuildContext context) {
final c = Theme.of(context).colorScheme;
final icon = switch (message.level) {
Level.trace => Icons.troubleshoot,
Level.debug => Icons.bug_report,
Level.info => Icons.info_outline,
Level.warning => Icons.warning,
Level.error => Icons.error,
Level.fatal => Icons.error_outline,
_ => null,
};
final color = switch (message.level) {
Level.trace => c.onBackground.withOpacity(0.75),
Level.warning => Colors.yellow.shade600,
Level.error => Colors.red,
Level.fatal => Colors.red.shade900,
Level.info => Colors.blue,
_ => c.onBackground,
};
final logStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontSize: 12,
);
final formattedMethodName =
message.methodName != null ? '${message.methodName!.trim()}()' : '';
final source = switch (message.className) {
'' || null => formattedMethodName,
String className => '$className.$formattedMethodName',
};
return Material(
color: backgroundColor,
child: ExpansionTile(
leading: Text(
_timeFormat.format(message.timestamp),
style: logStyle?.copyWith(color: color),
),
title: Text(
message.message,
style: logStyle?.copyWith(color: color),
),
trailing: Icon(
icon,
color: color,
),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
childrenPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
expandedAlignment: Alignment.topLeft,
children: source.isNotEmpty
? [
Row(
children: [
const Icon(Icons.arrow_right),
Flexible(
child: Text(
'In $source',
style: logStyle?.copyWith(fontSize: 14),
),
),
],
),
..._buildErrorWidgets(context),
]
: _buildErrorWidgets(context),
),
);
}
List<Widget> _buildErrorWidgets(BuildContext context) {
if (message.error != null) {
return [
Divider(),
Text(
message.error!.error,
style: TextStyle(color: Colors.red),
).padded(),
if (message.error?.stackTrace != null) ...[
Text(
message.error!.stackTrace!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 10,
),
).paddedOnly(left: 8),
],
];
} else {
return [];
}
}
}

View File

@@ -1,8 +1,8 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:paperless_mobile/core/logging/data/logger.dart';
import 'package:paperless_mobile/core/logging/utils/redaction_utils.dart';
import 'package:paperless_mobile/features/logging/data/logger.dart';
import 'package:paperless_mobile/features/logging/utils/redaction_utils.dart';
import 'package:paperless_mobile/helpers/format_helpers.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -231,9 +231,9 @@ class FileService {
type: StorageDirectory.downloads,
);
directory = await downloadsDir!.first.create(recursive: true);
return;
}
_downloadsDirectory = directory;
return;
} else if (Platform.isIOS) {
final appDir = await getApplicationDocumentsDirectory();
final dir = Directory('${appDir.path}/downloads');

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/model/github_error_report.model.dart';
import 'package:paperless_mobile/core/widgets/error_report_page.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/core/extensions/dart_extensions.dart';
class GithubIssueService {
static void openCreateGithubIssue({

View File

@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/model/github_error_report.model.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/core/extensions/flutter_extensions.dart';
class ErrorReportPage extends StatefulWidget {
final StackTrace? stackTrace;

View File

@@ -3,7 +3,7 @@ import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_relative_date_range_field.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/core/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ExtendedDateRangeDialog extends StatefulWidget {

View File

@@ -0,0 +1,285 @@
import 'dart:collection';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:extended_masked_text/extended_masked_text.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:intl/intl.dart';
import 'package:paperless_mobile/core/extensions/flutter_extensions.dart';
import 'package:synchronized/extension.dart';
final class NeighbourAwareDateInputSegmentControls
with LinkedListEntry<NeighbourAwareDateInputSegmentControls> {
final FocusNode node;
final TextEditingController controller;
final int position;
final String format;
final DateTime? initialDate;
NeighbourAwareDateInputSegmentControls({
required this.node,
required this.controller,
required this.format,
this.initialDate,
required this.position,
});
}
class FormBuilderLocalizedDatePicker extends StatefulWidget {
final String name;
final String labelText;
final Widget? prefixIcon;
final DateTime? initialValue;
final DateTime firstDate;
final DateTime lastDate;
final Locale locale;
const FormBuilderLocalizedDatePicker({
super.key,
required this.name,
this.initialValue,
required this.firstDate,
required this.lastDate,
required this.locale,
required this.labelText,
this.prefixIcon,
});
@override
State<FormBuilderLocalizedDatePicker> createState() =>
_FormBuilderLocalizedDatePickerState();
}
class _FormBuilderLocalizedDatePickerState
extends State<FormBuilderLocalizedDatePicker> {
late final String _separator;
late final String _format;
final _textFieldControls =
LinkedList<NeighbourAwareDateInputSegmentControls>();
@override
void initState() {
super.initState();
final format =
DateFormat.yMd(widget.locale.toString()).format(DateTime(1000, 11, 22));
_separator = format.replaceAll(RegExp(r'\d'), '').characters.first;
_format = format
.replaceAll("1000", "yyyy")
.replaceAll("11", "MM")
.replaceAll("22", "dd");
final components = _format.split(_separator);
for (int i = 0; i < components.length; i++) {
final formatString = components[i];
final initialText = widget.initialValue != null
? DateFormat(formatString).format(widget.initialValue!)
: null;
final item = NeighbourAwareDateInputSegmentControls(
node: FocusNode(debugLabel: formatString),
controller: TextEditingController(text: initialText),
format: formatString,
position: i,
);
item.controller.addListener(() {
if (item.controller.text.length == item.format.length) {
// _textFieldControls.elementAt(i).next?.node.requestFocus();
// _textFieldControls.elementAt(i).next?.controller.selection =
// const TextSelection.collapsed(offset: 0);
// return;
}
});
item.node.addListener(() {
if (item.node.hasFocus) {
item.controller.selection = const TextSelection.collapsed(offset: 0);
}
});
_textFieldControls.add(item);
}
}
@override
Widget build(BuildContext context) {
return RawKeyboardListener(
focusNode: FocusNode(),
onKey: (value) {
if (value.logicalKey == LogicalKeyboardKey.backspace &&
value is RawKeyDownEvent) {
final currentFocus = _textFieldControls
.where((element) => element.node.hasFocus)
.firstOrNull;
if (currentFocus == null) {
return;
}
if (currentFocus.controller.text.isEmpty) {
currentFocus.previous?.node.requestFocus();
final endOffset = currentFocus.previous?.controller.text.length;
currentFocus.previous?.controller.selection =
TextSelection.collapsed(offset: endOffset ?? 0);
}
}
},
child: FormBuilderField<DateTime>(
name: widget.name,
initialValue: widget.initialValue,
validator: (value) {
if (value?.isBefore(widget.firstDate) ?? false) {
return "Date must be before " +
DateFormat.yMd(widget.locale.toString())
.format(widget.firstDate);
}
if (value?.isAfter(widget.lastDate) ?? false) {
return "Date must be after " +
DateFormat.yMd(widget.locale.toString())
.format(widget.lastDate);
}
return null;
},
builder: (field) {
return SizedBox(
height: 56,
child: InputDecorator(
textAlignVertical: TextAlignVertical.bottom,
decoration: InputDecoration(
labelText: widget.labelText,
prefixIcon: widget.prefixIcon,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.calendar_month_outlined),
onPressed: () async {
final selectedDate = await showDatePicker(
context: context,
initialDate: widget.initialValue ?? DateTime.now(),
firstDate: widget.firstDate,
lastDate: widget.lastDate,
initialEntryMode: DatePickerEntryMode.calendarOnly,
);
if (selectedDate != null) {
_updateInputsWithDate(selectedDate);
field.didChange(selectedDate);
FocusScope.of(context).unfocus();
}
},
),
IconButton(
onPressed: () {
field.didChange(null);
for (var c in _textFieldControls) {
c.controller.clear();
}
_textFieldControls.first.node.requestFocus();
},
icon: const Icon(Icons.clear),
),
],
).paddedOnly(right: 4),
),
child: Row(
children: [
for (var s in _textFieldControls) ...[
SizedBox(
width: switch (s.format) {
== "dd" => 32,
== "MM" => 32,
== "yyyy" => 48,
_ => 0,
},
child: _buildDateSegmentInput(s, context, field),
),
if (s.position < 2) Text(_separator).paddedOnly(right: 4),
],
],
),
),
);
},
),
);
}
void _updateInputsWithDate(DateTime date) {
final components = _format.split(_separator);
for (int i = 0; i < components.length; i++) {
final formatString = components[i];
final value = DateFormat(formatString).format(date);
_textFieldControls.elementAt(i).controller.text = value;
}
}
Widget _buildDateSegmentInput(
NeighbourAwareDateInputSegmentControls controls,
BuildContext context,
FormFieldState<DateTime> field,
) {
return TextFormField(
onFieldSubmitted: (value) {
_textFieldControls
.elementAt(controls.position)
.next
?.node
.requestFocus();
},
// onTap: () {
// controls.controller.clear();
// },
canRequestFocus: true,
keyboardType: TextInputType.datetime,
textInputAction: TextInputAction.done,
controller: controls.controller,
focusNode: _textFieldControls.elementAt(controls.position).node,
maxLength: controls.format.length,
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
enableInteractiveSelection: false,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
ReplacingTextFormatter(),
],
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
counterText: '',
hintText: controls.format,
border: Theme.of(context).inputDecorationTheme.border?.copyWith(
borderSide: const BorderSide(
width: 0,
style: BorderStyle.none,
),
),
),
);
}
}
class ReplacingTextFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final oldOffset = oldValue.selection.baseOffset;
final newOffset = newValue.selection.baseOffset;
final replacement = newValue.text.substring(oldOffset, newOffset);
print(
"DBG: Received ${oldValue.text} -> ${newValue.text}. New char = $replacement");
if (oldOffset < newOffset) {
final oldText = oldValue.text;
final newText = oldText.replaceRange(
oldOffset,
newOffset,
newValue.text.substring(oldOffset, newOffset),
);
print("DBG: Replacing $oldText -> $newText");
return newValue.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: newOffset),
);
}
return newValue;
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/core/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FullscreenSelectionForm extends StatefulWidget {

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/core/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class HintCard extends StatelessWidget {

View File

@@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:paperless_mobile/generated/assets.gen.dart';
class PaperlessLogo extends StatelessWidget {
static const _paperlessGreen = Color(0xFF18541F);
final double? height;
final double? width;
final Color _color;
const PaperlessLogo.white({
super.key,
this.height,
this.width,
}) : _color = Colors.white;
const PaperlessLogo.green({super.key, this.height, this.width})
: _color = _paperlessGreen;
const PaperlessLogo.black({super.key, this.height, this.width})
: _color = Colors.black;
const PaperlessLogo.colored(Color color, {super.key, this.height, this.width})
: _color = color;
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: height ?? Theme.of(context).iconTheme.size ?? 32,
maxWidth: width ?? Theme.of(context).iconTheme.size ?? 32,
),
padding: const EdgeInsets.only(right: 8),
child: Assets.logos.paperlessLogoWhiteSvg.svg(
colorFilter: ColorFilter.mode(
_color,
BlendMode.srcIn,
),
));
}
}