Merge branch 'main' into codemagic-setup

This commit is contained in:
Hank Grabowski 2023-11-30 13:15:54 -05:00
commit c9a57e02e5
39 changed files with 948 additions and 270 deletions

View file

@ -2,7 +2,7 @@
A Flutter application for interfacing with the Friendica social network.
<img src="screenshots/v0.3.0/linux/relatica_v0.3.0_home.png" alt="Relatica v0.3.0 on Linux Home Screen Screenshot" width="300px"/>
<img src="screenshots/v0.8.0/home_screen.png" alt="Relatica v0.8.0 on Linux Home Screen Screenshot" width="300px"/>
<img src="screenshots/v0.3.0/linux/relatica_v0.3.0_drawer.png" alt="Relatica v0.3.0 on Linux Home Expanded Drawer Screenshot" width="300px"/>

View file

@ -6,12 +6,12 @@ For more information about the current beta testing program
# Latest Binaries:
* [Android v0.7.2](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.7.2/relatica_v0.7.2.apk.zip)
* iPhone/iPad v0.7.2: This is only available through TestFlight. Please contact me for access.
* [Windows (Intel) v0.7.2](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.7.2/relatica_v0.7.2_win_x64.zip)
* macOS v0.7.2:This is only available through TestFlight. Please contact me for access.
* [Linux (Intel Ubuntu 20) v0.7.2](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.7.2/relatica_v0.7.2_linux_x64_ubuntu20.zip)
* [Linux (Intel Ubuntu 22) v0.7.2](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.7.2/relatica_v0.7.2_linux_x64_ubuntu22.zip)
* Android v0.8.0 Is available by invitation through Play Store beta (please see me for access)
or [this self-installable ZIP file](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.8.0/relatica_v0.8.0.apk.zip)
* iPhone/iPad v0.8.0: This is only available through TestFlight. Please contact me for access.
* [Windows (Intel) v0.8.0](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.8.0/relatica_v0.8.0_win_x64.zip)
* macOS v0.8.0:This is only available through TestFlight. Please contact me for access.
* [Linux v0.8.0 (tested on Ubuntu 20 and 22)](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.8.0/relatica_v0.8.0_linux_x64.zip)
## Mobile

View file

@ -57,7 +57,7 @@ class _MediaKitAvControlState extends State<MediaKitAvControl> {
@override
Widget build(BuildContext context) {
_logger.finer('Building MediaKit Control for ${widget.videoUrl}');
_logger.finest('Building MediaKit Control for ${widget.videoUrl}');
if (controller == null) {
return Container(
width: widget.width,

View file

@ -103,6 +103,7 @@ class _MediaUploadsControlState extends State<MediaUploadsControl> {
Expanded(
child: TextField(
controller: controller,
textCapitalization: TextCapitalization.sentences,
onChanged: (value) {
widget.entryMediaItems.albumName = value;
},

View file

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../models/timeline_entry.dart';
import '../services/auth_service.dart';
import '../utils/clipboard_utils.dart';
import '../utils/url_opening_utils.dart';
import 'html_text_viewer_control.dart';
@ -103,14 +105,17 @@ class _SearchResultStatusControlState extends State<SearchResultStatusControl> {
),
),
);
return Padding(
padding: const EdgeInsets.only(
left: otherPadding,
right: otherPadding,
top: otherPadding,
bottom: otherPadding,
return GestureDetector(
onTap: widget.goToPostFunction,
child: Padding(
padding: const EdgeInsets.only(
left: otherPadding,
right: otherPadding,
top: otherPadding,
bottom: otherPadding,
),
child: body,
),
child: body,
);
}
@ -127,6 +132,25 @@ class _SearchResultStatusControlState extends State<SearchResultStatusControl> {
if (items.isEmpty) {
return const SizedBox();
}
// A Link Preview with only one media attachment will have a duplicate image
// even though it points to different resources server side. So we don't
// want to render it twice.
if (widget.status.linkPreviewData != null && items.length == 1) {
return const SizedBox();
}
// A Diaspora reshare will have an HTML-built card with a link preview image
// to the same image as what would be in the single attachment but at a
// different link. So we don't want it to render twice.
final linkPhotoBaseUrl = Uri.https(
context.read<AccountsService>().currentProfile.serverName,
'photo/link',
).toString();
if (widget.status.body.contains(linkPhotoBaseUrl) && items.length == 1) {
return const SizedBox();
}
return SizedBox(
height: 250.0,
child: ListView.separated(

View file

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@ -10,6 +12,7 @@ import '../../globals.dart';
import '../../models/filters/timeline_entry_filter.dart';
import '../../models/flattened_tree_item.dart';
import '../../models/timeline_entry.dart';
import '../../services/auth_service.dart';
import '../../services/timeline_entry_filter_service.dart';
import '../../services/timeline_manager.dart';
import '../../utils/active_profile_selector.dart';
@ -25,6 +28,9 @@ import 'interactions_bar_control.dart';
import 'link_preview_control.dart';
import 'status_header_control.dart';
const maxDepth = 5.0;
const otherPadding = 8.0;
class FlattenedTreeEntryControl extends StatefulWidget {
final FlattenedTreeItem originalItem;
final bool openRemote;
@ -75,8 +81,8 @@ class _StatusControlState extends State<FlattenedTreeEntryControl> {
filteringInfo = filterService.checkTimelineEntry(entry);
const otherPadding = 8.0;
final leftPadding = otherPadding + (widget.originalItem.level * 15.0);
final leftPadding =
otherPadding + (min(maxDepth, widget.originalItem.level) * 15.0);
final color = widget.originalItem.level.isOdd
? Theme.of(context).secondaryHeaderColor
: Theme.of(context).dialogBackgroundColor;
@ -228,6 +234,25 @@ class _StatusControlState extends State<FlattenedTreeEntryControl> {
if (items.isEmpty) {
return const SizedBox();
}
// A Link Preview with only one media attachment will have a duplicate image
// even though it points to different resources server side. So we don't
// want to render it twice.
if (entry.linkPreviewData != null && items.length == 1) {
return const SizedBox();
}
// A Diaspora reshare will have an HTML-built card with a link preview image
// to the same image as what would be in the single attachment but at a
// different link. So we don't want it to render twice.
final linkPhotoBaseUrl = Uri.https(
context.read<AccountsService>().currentProfile.serverName,
'photo/link',
).toString();
if (entry.body.contains(linkPhotoBaseUrl) && items.length == 1) {
return const SizedBox();
}
return SizedBox(
height: ResponsiveSizesCalculator(context).maxThumbnailHeight,
child: ListView.separated(

View file

@ -51,14 +51,14 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
isProcessing = true;
});
final newState = !isFavorited;
_logger.finest('Trying to toggle favorite from $isFavorited to $newState');
_logger.fine('Trying to toggle favorite from $isFavorited to $newState');
final result = await getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenAsync(
(tm) async => await tm.toggleFavorited(widget.entry.id, newState));
result.match(onSuccess: (update) {
setState(() {
_logger.finest(
_logger.fine(
'Success toggling! $isFavorited -> ${update.entry.isFavorited}');
});
}, onError: (error) {
@ -93,14 +93,14 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
}
final id = widget.entry.id;
_logger.finest('Trying to reshare $id');
_logger.fine('Trying to reshare $id');
final result = await getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenAsync((tm) async => await tm.resharePost(id));
result.match(onSuccess: (update) {
setState(() {
_logger.finest('Success resharing post by ${widget.entry.author}');
_logger.fine('Success resharing post by ${widget.entry.author}');
});
}, onError: (error) {
buildSnackbar(context, 'Error resharing post by ${widget.entry.author}');
@ -122,13 +122,13 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
isProcessing = true;
});
final id = widget.entry.id;
_logger.finest('Trying to un-reshare $id');
_logger.fine('Trying to un-reshare $id');
final result = await getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenAsync((tm) async => await tm.unResharePost(id));
result.match(onSuccess: (update) {
setState(() {
_logger.finest('Success un-resharing post by ${widget.entry.author}');
_logger.fine('Success un-resharing post by ${widget.entry.author}');
});
}, onError: (error) {
buildSnackbar(

View file

@ -74,7 +74,7 @@ class _PostControlState extends State<PostControl> {
final int scrollToIndex = _scrollToIndexCalc(items);
// TODO Figure out why doesn't scroll to correct position on loading
_logger.finer('Building view with initial position at $scrollToIndex');
_logger.finest('Building view with initial position at $scrollToIndex');
return ScrollablePositionedList.builder(
physics: const AlwaysScrollableScrollPhysics(),

View file

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:relatica/controls/padding.dart';
import 'package:relatica/globals.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../models/TimelineIdentifiers.dart';
import '../../services/network_status_service.dart';
import '../../services/timeline_manager.dart';
import '../../utils/active_profile_selector.dart';
import 'post_control.dart';
@ -15,16 +18,15 @@ class TimelinePanel extends StatelessWidget {
TimelinePanel({super.key, required this.timeline});
Future<void> update(TimelineManager manager) async {
await manager.updateTimeline(
timeline,
TimelineRefreshType.refresh,
);
await manager.updateTimeline(
timeline,
TimelineRefreshType.loadNewer,
);
Future<void> update(BuildContext context, TimelineManager manager) async {
final confirm =
await showYesNoDialog(context, 'Reload timeline from scratch?');
if (confirm == true) {
await manager.updateTimeline(
timeline,
TimelineRefreshType.refresh,
);
}
}
void scrollToTop() {
@ -34,59 +36,81 @@ class TimelinePanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
_logger.finer('Build');
final nss = getIt<NetworkStatusService>();
final manager = context
.watch<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.value;
final items = manager.getTimeline(timeline);
return RefreshIndicator(
onRefresh: () async {
update(manager);
return;
},
child: items.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => manager.updateTimeline(
timeline, TimelineRefreshType.refresh),
child: const Text('Load Posts'))
],
),
)
: ScrollablePositionedList.builder(
itemScrollController: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (context, index) {
if (index == 0) {
return TextButton(
onPressed: () async => await manager.updateTimeline(
timeline, TimelineRefreshType.loadNewer),
child: const Text('Load newer posts'));
}
if (index == items.length + 1) {
return TextButton(
onPressed: () async => await manager.updateTimeline(
timeline, TimelineRefreshType.loadOlder),
child: const Text('Load older posts'));
}
final itemIndex = index - 1;
final item = items[itemIndex];
TimelinePanel._logger.finest(
'Building item: $itemIndex: ${item.entry.toShortString()}');
return PostControl(
originalItem: item,
scrollToId: item.id,
openRemote: false,
showStatusOpenButton: true,
isRoot: false,
);
},
itemCount: items.length + 2,
return ValueListenableBuilder(
valueListenable: nss.timelineLoadingStatus,
builder: (BuildContext context, bool loading, Widget? _) {
if (items.isEmpty && loading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Loading Posts'),
],
),
);
}
if (items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('No posts loaded'),
const VerticalPadding(),
ElevatedButton(
onPressed: () => manager.updateTimeline(
timeline, TimelineRefreshType.refresh),
child: const Text('Load Posts'))
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
update(context, manager);
return;
},
child: ScrollablePositionedList.builder(
itemScrollController: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (context, index) {
if (index == 0) {
return TextButton(
onPressed: () async => await manager.updateTimeline(
timeline, TimelineRefreshType.loadNewer),
child: const Text('Load newer posts'));
}
if (index == items.length + 1) {
return TextButton(
onPressed: () async => await manager.updateTimeline(
timeline, TimelineRefreshType.loadOlder),
child: const Text('Load older posts'));
}
final itemIndex = index - 1;
final item = items[itemIndex];
TimelinePanel._logger.finest(
'Building item: $itemIndex: ${item.entry.toShortString()}');
return PostControl(
originalItem: item,
scrollToId: item.id,
openRemote: false,
showStatusOpenButton: true,
isRoot: false,
);
},
itemCount: items.length + 2,
),
);
},
);
}
}

View file

@ -817,6 +817,8 @@ class StatusesClient extends FriendicaClient {
...descendants
.map((d) => TimelineEntryMastodonExtensions.fromJson(d))
];
_logger.finer(() =>
'Got status $id with full context which returned ${ancestors.length} ancestors and ${descendants.length} descendants');
return items;
} else {
return [TimelineEntryMastodonExtensions.fromJson(json)];

View file

@ -11,9 +11,13 @@ final getIt = GetIt.instance;
String randomId() => const Uuid().v4().toString();
final platformHasCamera = Platform.isIOS || Platform.isAndroid;
final platformIsMobile = Platform.isIOS || Platform.isAndroid;
final useImagePicker = kIsWeb || Platform.isAndroid || Platform.isIOS;
final platformHasCamera = platformIsMobile;
final platformIsDesktop = !platformIsMobile;
final useImagePicker = kIsWeb || platformIsMobile;
const usePhpDebugging = false;

View file

@ -19,6 +19,7 @@ import 'services/follow_requests_manager.dart';
import 'services/gallery_service.dart';
import 'services/hashtag_service.dart';
import 'services/interactions_manager.dart';
import 'services/log_service.dart';
import 'services/notifications_manager.dart';
import 'services/setting_service.dart';
import 'services/status_service.dart';
@ -32,14 +33,14 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
final logService = LogService();
getIt.registerSingleton<LogService>(logService);
// await dotenv.load(fileName: '.env');
const enablePreview = false;
Logger.root.level = Level.FINER;
Logger.root.level = Level.OFF;
Logger.root.onRecord.listen((event) {
final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName;
final msg =
'${event.level.name} - $logName @ ${event.time}: ${event.message}';
print(msg);
logService.add(event);
});
await fixLetsEncryptCertOnOldAndroid();
@ -60,9 +61,15 @@ class App extends StatelessWidget {
final settingsService = getIt<SettingsService>();
return AnimatedBuilder(
builder: (context, child) {
Logger.root.level = settingsService.logLevel;
print('Log level: ${settingsService.logLevel}');
return Portal(
child: MultiProvider(
providers: [
ChangeNotifierProvider<LogService>(
create: (_) => getIt<LogService>(),
lazy: true,
),
ChangeNotifierProvider<StatusService>(
create: (_) => getIt<StatusService>(),
lazy: true,

View file

@ -1,12 +1,15 @@
import 'TimelineIdentifiers.dart';
import 'entry_tree_item.dart';
const defaultLowestId = 9223372036854775807;
const defaultHighestId = 0;
class Timeline {
final TimelineIdentifiers id;
final List<EntryTreeItem> _posts = [];
final Map<String, EntryTreeItem> _postsById = {};
int _lowestStatusId = 9223372036854775807;
int _highestStatusId = 0;
int _lowestStatusId = defaultLowestId;
int _highestStatusId = defaultHighestId;
int get highestStatusId => _highestStatusId;
@ -72,8 +75,8 @@ class Timeline {
void clear() {
_posts.clear();
_postsById.clear();
_lowestStatusId = 0;
_highestStatusId = 0;
_lowestStatusId = defaultLowestId;
_highestStatusId = defaultHighestId;
}
@override

View file

@ -17,6 +17,7 @@ import 'screens/gallery_screen.dart';
import 'screens/home.dart';
import 'screens/image_editor_screen.dart';
import 'screens/interactions_viewer_screen.dart';
import 'screens/logviewer_screen.dart';
import 'screens/message_thread_screen.dart';
import 'screens/message_threads_browser_screen.dart';
import 'screens/messages_new_thread.dart';
@ -51,6 +52,7 @@ class ScreenPaths {
static String likes = '/likes';
static String reshares = '/reshares';
static String search = '/search';
static String logViewer = '/logViewer';
}
bool needAuthChangeInitialized = true;
@ -314,4 +316,11 @@ final appRouter = GoRouter(
child: const SearchScreen(),
),
),
GoRoute(
path: ScreenPaths.logViewer,
name: ScreenPaths.logViewer,
pageBuilder: (context, state) => const NoTransitionPage(
child: LogViewerScreen(),
),
),
]);

View file

@ -84,7 +84,7 @@ class _EditorScreenState extends State<EditorScreen> {
parentEntry = entry;
visibility = entry.visibility;
}, onError: (error) {
_logger.finest('Error trying to get parent entry: $error');
_logger.severe('Error trying to get parent entry: $error');
});
}
@ -96,13 +96,13 @@ class _EditorScreenState extends State<EditorScreen> {
}
void restoreStatusData() async {
_logger.fine('Attempting to load status for editing');
_logger.finer('Attempting to load status for editing');
loaded = false;
final result = await getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenAsync((manager) async => manager.getEntryById(widget.id));
result.match(onSuccess: (entry) {
_logger.fine('Loading status ${widget.id} information into fields');
_logger.finer('Loading status ${widget.id} information into fields');
contentController.text = htmlToSimpleText(entry.body);
spoilerController.text = entry.spoilerText;
existingMediaItems
@ -227,7 +227,7 @@ class _EditorScreenState extends State<EditorScreen> {
@override
Widget build(BuildContext context) {
_logger.finest('Build editor $isComment $parentEntry');
_logger.finer('Build editor $isComment $parentEntry');
final manager = context
.read<ActiveProfileSelector<TimelineManager>>()
.activeEntry
@ -310,8 +310,11 @@ class _EditorScreenState extends State<EditorScreen> {
return Scaffold(
appBar: StandardAppBar.build(
context, widget.id.isEmpty ? 'New $statusType' : 'Edit $statusType',
withDrawer: true),
context,
widget.id.isEmpty ? 'New $statusType' : 'Edit $statusType',
withDrawer: true,
withHome: !isSubmitting,
),
body: body,
);
}

View file

@ -67,7 +67,7 @@ class _FilterEditorScreenState extends State<FilterEditorScreen> {
@override
Widget build(BuildContext context) {
_logger.finer('Build for filter ${widget.id}');
_logger.finest('Build for filter ${widget.id}');
final fieldWidth = MediaQuery.of(context).size.width * 0.8;
final service = context
.watch<ActiveProfileSelector<TimelineEntryFilterService>>()

View file

@ -84,7 +84,7 @@ class GalleryBrowsersScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
_logger.finest('Building');
_logger.finer('Building');
final service = context
.watch<ActiveProfileSelector<GalleryService>>()
.activeEntry

View file

@ -24,7 +24,7 @@ class GalleryScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
_logger.finest('Building $galleryName');
_logger.finer('Building $galleryName');
final nss = getIt<NetworkStatusService>();
return Scaffold(
appBar: StandardAppBar.build(context, galleryName, actions: [
@ -59,7 +59,7 @@ class _GalleryScreenBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
_logger.finest('Building');
_logger.finer('Building');
final service = context
.watch<ActiveProfileSelector<GalleryService>>()
.activeEntry

View file

@ -29,7 +29,7 @@ class _HomeScreenState extends State<HomeScreen> {
TimelineIdentifiers currentTimeline = TimelineIdentifiers.home();
void updateTimeline(TimelineManager manager) {
_logger.finest('Updating timeline: $currentTimeline');
_logger.finer('Updating timeline: $currentTimeline');
Future.delayed(const Duration(milliseconds: 100), () async {
await manager.updateTimeline(
currentTimeline, TimelineRefreshType.refresh);

View file

@ -138,6 +138,7 @@ class _ImageEditorScreenState extends State<ImageEditorScreen> {
TextField(
controller: altTextController,
maxLines: 10,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: 'ALT Text',
alignLabelWithHint: true,

View file

@ -0,0 +1,224 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_file_dialog/flutter_file_dialog.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import '../controls/padding.dart';
import '../controls/responsive_max_width.dart';
import '../controls/standard_appbar.dart';
import '../services/log_service.dart';
import '../services/setting_service.dart';
import '../utils/clipboard_utils.dart';
import '../utils/dateutils.dart';
import '../utils/json_printer.dart';
import '../utils/logrecord_extensions.dart';
import '../utils/snackbar_builder.dart';
final _logger = Logger('LogViewerScreen');
class LogViewerScreen extends StatefulWidget {
const LogViewerScreen({super.key});
@override
State<LogViewerScreen> createState() => _LogViewerScreenState();
}
class _LogViewerScreenState extends State<LogViewerScreen> {
var filterText = '';
var filterByText = false;
var filterByModule = false;
var filterModuleName = '';
var attemptingWrite = false;
Future<void> _writeEventsLog(List<LogRecord> events) async {
if (attemptingWrite) {
return;
}
attemptingWrite = true;
try {
final json =
PrettyJsonEncoder().convert(events.map((e) => e.toJson()).toList());
final filename = 'EventsLog_${DateTime.now().toFileNameString()}.json';
if (Platform.isAndroid || Platform.isIOS) {
final params = SaveFileDialogParams(
data: Uint8List.fromList(json.codeUnits),
fileName: filename,
);
await FlutterFileDialog.saveFile(params: params);
if (mounted) {
buildSnackbar(context, 'Wrote Events Log to: $filename');
}
} else {
final appsDir = await getApplicationDocumentsDirectory();
final location = await FilePicker.platform.saveFile(
dialogTitle: 'Save Events Log',
fileName: filename,
initialDirectory: appsDir.path,
);
if (location != null) {
await File(location).writeAsString(json);
if (mounted) {
buildSnackbar(context, 'Wrote Events Log to: $location');
}
}
}
} catch (e) {
_logger.severe('Error attempting to write out log: $e');
if (mounted) {
buildSnackbar(context, 'Error attempting to write out log: $e');
}
}
attemptingWrite = false;
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final logService = context.watch<LogService>();
final events = logService.events;
return Scaffold(
appBar: StandardAppBar.build(context, 'Log Viewer'),
body: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ResponsiveMaxWidth(
child: Column(
children: [
buildLogPanel(settings),
buildModuleFilter(settings, events),
buildTextSearchPanel(),
const VerticalPadding(),
buildLogList(settings.logLevel, events),
],
),
),
),
),
floatingActionButton: FloatingActionButton.small(
onPressed: () => _writeEventsLog(events),
child: const Icon(Icons.save),
),
);
}
Widget buildLogPanel(SettingsService settings) {
return ListTile(
title: const Text('Log Level'),
trailing: DropdownButton<Level>(
value: settings.logLevel,
items: Level.LEVELS
.map((c) => DropdownMenuItem(value: c, child: Text(c.name)))
.toList(),
onChanged: (value) {
settings.logLevel = value ?? Level.OFF;
}),
);
}
Widget buildModuleFilter(SettingsService settings, List<LogRecord> events) {
final modules = events.map((e) => e.loggerName).toSet().toList();
modules.sort();
modules.add('');
return ListTile(
leading: Checkbox(
value: filterByModule,
onChanged: (bool? value) {
setState(() {
filterByModule = value ?? false;
});
},
),
title: filterByModule ? null : const Text('Filter by module'),
trailing: !filterByModule
? null
: DropdownButton<String>(
value: filterModuleName,
items: modules
.map((c) => DropdownMenuItem(value: c, child: Text(c)))
.toList(),
onChanged: (value) {
setState(() {
filterModuleName = value ?? '';
});
}),
);
}
Widget buildTextSearchPanel() {
return ListTile(
leading: Checkbox(
value: filterByText,
onChanged: (value) {
setState(() {
filterByText = value ?? false;
});
},
),
title: TextField(
enabled: filterByText,
onChanged: (value) {
setState(() {
filterText = value.toLowerCase();
});
},
decoration: InputDecoration(
labelText: 'Filter by text',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).highlightColor,
),
borderRadius: BorderRadius.circular(5.0),
),
),
),
);
}
Widget buildLogList(Level logLevel, List<LogRecord> allEvents) {
final events = allEvents.where((e) {
final levelFilterPasses = e.level >= logLevel;
final passesTextFilter = filterByText
? e.message.toLowerCase().contains(filterText.toLowerCase())
: true;
final passesModuleFilter = filterByModule
? filterModuleName.isEmpty || e.loggerName == filterModuleName
: true;
return levelFilterPasses && passesTextFilter && passesModuleFilter;
}).toList();
return Expanded(
child: ListView.separated(
itemBuilder: (context, index) {
final event = events[index];
return ListTile(
onLongPress: () {
copyToClipboard(
context: context,
text: jsonEncode(event.toJson()),
);
},
titleAlignment: ListTileTitleAlignment.titleHeight,
leading: Text(event.level.toString()),
title:
Text('${event.loggerName} at ${event.time.toIso8601String()}'),
subtitle: Text(
event.message,
softWrap: true,
),
);
},
separatorBuilder: (_, __) => const Divider(),
itemCount: events.length,
),
);
}
}

View file

@ -3,7 +3,9 @@ import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../controls/app_bottom_nav_bar.dart';
import '../controls/linear_status_indicator.dart';
import '../controls/notifications_control.dart';
import '../controls/padding.dart';
import '../controls/responsive_max_width.dart';
import '../controls/standard_app_drawer.dart';
import '../controls/standard_appbar.dart';
@ -25,7 +27,7 @@ class NotificationsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
_logger.finest('Building');
_logger.finer('Building');
final nss = getIt<NetworkStatusService>();
final managerResult = context
.watch<ActiveProfileSelector<NotificationsManager>>()
@ -36,10 +38,11 @@ class NotificationsScreen extends StatelessWidget {
managerResult.match(onSuccess: (manager) {
final notifications = manager.notifications;
actions = [
StatusAndRefreshButton(
valueListenable: nss.notificationsUpdateStatus,
refreshFunction: () async => update(manager),
),
if (platformIsDesktop)
StatusAndRefreshButton(
valueListenable: nss.notificationsUpdateStatus,
refreshFunction: () async => update(manager),
),
IconButton(
onPressed: () async => _clearAllNotifications(context, manager),
icon: const Icon(Icons.cleaning_services),
@ -52,11 +55,21 @@ class NotificationsScreen extends StatelessWidget {
update(manager);
return;
},
child: const Center(
child: Center(
child: Column(
children: [
Center(child: Text('No notifications')),
],
mainAxisAlignment: MainAxisAlignment.center,
children: nss.notificationsUpdateStatus.value
? [
const Center(child: Text('Loading Notifications')),
]
: [
const Center(child: Text('No notifications')),
const VerticalPadding(),
ElevatedButton(
onPressed: () => update(manager),
child: const Text('Load Notifications'),
)
],
)),
);
} else {
@ -131,7 +144,16 @@ class NotificationsScreen extends StatelessWidget {
actions: actions,
),
drawer: const StandardAppDrawer(),
body: body,
body: Center(
child: Column(
children: [
StandardLinearProgressIndicator(nss.notificationsUpdateStatus),
Expanded(
child: ResponsiveMaxWidth(child: body),
),
],
),
),
bottomNavigationBar: const AppBottomNavBar(
currentButton: NavBarButtons.notifications,
),

View file

@ -37,7 +37,6 @@ class _SearchScreenState extends State<SearchScreen> {
static final _logger = Logger('$SearchScreen');
var searchTextController = TextEditingController();
var searchType = SearchTypes.statusesText;
var searching = false;
PagingData nextPage = PagingData(limit: limit);
var searchResult = SearchResults.empty();
Profile? profileOfSearchRequest;
@ -68,9 +67,6 @@ class _SearchScreenState extends State<SearchScreen> {
nextPage = PagingData(limit: limit);
searchResult = SearchResults.empty();
}
setState(() {
searching = true;
});
print('Search $searchType on ${searchTextController.text}');
final result = await SearchClient(profile)
@ -84,9 +80,7 @@ class _SearchScreenState extends State<SearchScreen> {
onError: (error) =>
buildSnackbar(context, 'Error getting search result: $error'),
);
setState(() {
searching = false;
});
print('Ending update');
}
@ -95,13 +89,12 @@ class _SearchScreenState extends State<SearchScreen> {
setState(() {
searchResult = SearchResults.empty();
searchTextController.text = '';
searching = false;
});
}
@override
Widget build(BuildContext context) {
_logger.info('Build');
_logger.finer('Build');
final nss = getIt<NetworkStatusService>();
final profileService = context.watch<AccountsService>();
final profile = profileService.currentProfile;
@ -111,21 +104,25 @@ class _SearchScreenState extends State<SearchScreen> {
clearSearchResults();
}
if (searchResult.isEmpty && searching) {
body = Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Searching for ${searchType.toLabel()} on: ${searchTextController.text}',
body = ValueListenableBuilder(
valueListenable: nss.searchLoadingStatus,
builder: (BuildContext context, bool searching, Widget? _) {
if (searchResult.isEmpty && searching) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Searching for ${searchType.toLabel()} on: ${searchTextController.text}',
),
],
),
],
),
);
} else {
body = ResponsiveMaxWidth(child: buildResultBody(profile));
}
);
}
return ResponsiveMaxWidth(child: buildResultBody(profile));
},
);
return Scaffold(
drawer: const StandardAppDrawer(skipPopDismiss: false),
body: SafeArea(
@ -192,7 +189,14 @@ class _SearchScreenState extends State<SearchScreen> {
.toList()),
],
),
if (searching) const LinearProgressIndicator(),
ValueListenableBuilder(
valueListenable: nss.searchLoadingStatus,
builder: (context, searching, _) {
if (searching) {
return const LinearProgressIndicator();
}
return const SizedBox();
}),
Expanded(child: body),
],
),
@ -205,7 +209,7 @@ class _SearchScreenState extends State<SearchScreen> {
}
Widget buildResultBody(Profile profile) {
_logger.fine('Building search result body with: $searchResult');
_logger.finer('Building search result body with: $searchResult');
switch (searchType) {
case SearchTypes.hashTag:
return buildHashtagResultWidget(profile);

View file

@ -1,12 +1,16 @@
import 'package:color_blindness/color_blindness.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../controls/padding.dart';
import '../controls/responsive_max_width.dart';
import '../controls/standard_appbar.dart';
import '../di_initialization.dart';
import '../globals.dart';
import '../routes.dart';
import '../services/setting_service.dart';
import '../utils/theme_mode_extensions.dart';
@ -28,6 +32,7 @@ class SettingsScreen extends StatelessWidget {
buildThemeWidget(settings),
if (!kReleaseMode) buildColorBlindnessTestSettings(settings),
buildClearCaches(context),
buildLogPanel(context, settings),
],
),
),
@ -96,4 +101,27 @@ class SettingsScreen extends StatelessWidget {
),
);
}
Widget buildLogPanel(BuildContext context, SettingsService settings) {
return ListTile(
title: Wrap(
children: [
const Text('Log Level'),
const HorizontalPadding(),
ElevatedButton(
onPressed: () => context.pushNamed(ScreenPaths.logViewer),
child: const Text('Open Log Viewer'),
)
],
),
trailing: DropdownButton<Level>(
value: settings.logLevel,
items: Level.LEVELS
.map((c) => DropdownMenuItem(value: c, child: Text(c.name)))
.toList(),
onChanged: (value) {
settings.logLevel = value ?? Level.OFF;
}),
);
}
}

View file

@ -47,6 +47,28 @@ class _SignInScreenState extends State<SignInScreen> {
} else {
newProfile();
}
usernameController.addListener(() {
_updateSignInButtonStatus();
});
passwordController.addListener(() {
_updateSignInButtonStatus();
});
serverNameController.addListener(() {
_updateSignInButtonStatus();
});
}
void _updateSignInButtonStatus() {
setState(() {
signInButtonEnabled = switch (oauthType) {
usernamePasswordType =>
serverNameController.text.isNotEmpty &&
usernameController.text.isNotEmpty &&
passwordController.text.isNotEmpty,
oauthType => serverNameController.text.isNotEmpty,
_ => false,
};
});
}
void newProfile() {
@ -54,7 +76,7 @@ class _SignInScreenState extends State<SignInScreen> {
passwordController.clear();
serverNameController.clear();
authType = oauthType;
signInButtonEnabled = true;
signInButtonEnabled = false;
existingProfile = null;
}
@ -131,14 +153,14 @@ class _SignInScreenState extends State<SignInScreen> {
onChanged: existingAccount
? null
: (value) {
if (existingAccount) {
buildSnackbar(context,
"Can't change the type on an existing account");
return;
}
authType = value!;
setState(() {});
}),
if (existingAccount) {
buildSnackbar(context,
"Can't change the type on an existing account");
return;
}
authType = value!;
setState(() {});
}),
),
const VerticalPadding(),
TextFormField(
@ -147,12 +169,15 @@ class _SignInScreenState extends State<SignInScreen> {
autovalidateMode: AutovalidateMode.onUserInteraction,
controller: serverNameController,
validator: (value) =>
isFQDN(value ?? '') ? null : 'Not a valid server name',
isFQDN(value ?? '') ? null : 'Not a valid server name',
decoration: InputDecoration(
hintText: 'Server Name (friendica.example.com)',
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.background,
color: Theme
.of(context)
.colorScheme
.background,
),
borderRadius: BorderRadius.circular(5.0),
),
@ -163,7 +188,8 @@ class _SignInScreenState extends State<SignInScreen> {
if (!showUsernameAndPasswordFields) ...[
Text(
existingAccount
? 'Configured to sign in as user ${existingProfile?.handle}'
? 'Configured to sign in as user ${existingProfile
?.handle}'
: 'Relatica will open the requested Friendica site in a web browser where you will be asked to authorize this client.',
softWrap: true,
),
@ -195,7 +221,10 @@ class _SignInScreenState extends State<SignInScreen> {
hintText: 'Username (user@example.com)',
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.background,
color: Theme
.of(context)
.colorScheme
.background,
),
borderRadius: BorderRadius.circular(5.0),
),
@ -229,7 +258,10 @@ class _SignInScreenState extends State<SignInScreen> {
hintText: 'Password',
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.background,
color: Theme
.of(context)
.colorScheme
.background,
),
borderRadius: BorderRadius.circular(5.0),
),
@ -240,111 +272,117 @@ class _SignInScreenState extends State<SignInScreen> {
],
signInButtonEnabled
? ElevatedButton(
onPressed: () => _signIn(context),
child: const Text('Signin'),
)
onPressed: () async => await _signIn(context),
child: const Text('Signin'),
)
: SizedBox(),
const VerticalPadding(),
Text(
'Logged out:',
style: Theme.of(context).textTheme.headlineSmall,
style: Theme
.of(context)
.textTheme
.headlineSmall,
),
loggedOutProfiles.isEmpty
? const Text(
'No logged out profiles',
textAlign: TextAlign.center,
)
'No logged out profiles',
textAlign: TextAlign.center,
)
: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: 0.5,
)),
child: Column(
children: loggedOutProfiles.map((p) {
return ListTile(
onTap: () {
setCredentials(context, p);
setState(() {});
},
title: Text(p.handle),
subtitle: Text(p.credentials is BasicCredentials
? usernamePasswordType
: oauthType),
trailing: ElevatedButton(
onPressed: () async {
final confirm = await showYesNoDialog(context,
'Remove login information from app?');
if (confirm ?? false) {
await service.removeProfile(p);
}
setState(() {});
},
child: const Text('Remove'),
),
);
}).toList(),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: 0.5,
)),
child: Column(
children: loggedOutProfiles.map((p) {
return ListTile(
onTap: () {
setCredentials(context, p);
setState(() {});
},
title: Text(p.handle),
subtitle: Text(p.credentials is BasicCredentials
? usernamePasswordType
: oauthType),
trailing: ElevatedButton(
onPressed: () async {
final confirm = await showYesNoDialog(context,
'Remove login information from app?');
if (confirm ?? false) {
await service.removeProfile(p);
}
setState(() {});
},
child: const Text('Remove'),
),
),
);
}).toList(),
),
),
const VerticalPadding(),
Text(
'Logged in:',
style: Theme.of(context).textTheme.headlineSmall,
style: Theme
.of(context)
.textTheme
.headlineSmall,
),
loggedInProfiles.isEmpty
? const Text(
'No logged in profiles',
textAlign: TextAlign.center,
)
'No logged in profiles',
textAlign: TextAlign.center,
)
: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: 0.5,
)),
child: Column(
children: loggedInProfiles.map((p) {
final active = service.loggedIn
? p.id == service.currentProfile.id
: false;
return ListTile(
onTap: () async {
setCredentials(context, p);
setState(() {});
},
title: Text(
p.handle,
style: active
? const TextStyle(
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic)
: null,
),
subtitle: Text(
p.credentials is BasicCredentials
? usernamePasswordType
: oauthType,
style: active
? const TextStyle(
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic)
: null,
),
trailing: ElevatedButton(
onPressed: () async {
final confirm = await showYesNoDialog(
context, 'Log out account?');
if (confirm == true) {
await getIt<AccountsService>().signOut(p);
setState(() {});
}
},
child: const Text('Sign out'),
),
);
}).toList(),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: 0.5,
)),
child: Column(
children: loggedInProfiles.map((p) {
final active = service.loggedIn
? p.id == service.currentProfile.id
: false;
return ListTile(
onTap: () async {
setCredentials(context, p);
setState(() {});
},
title: Text(
p.handle,
style: active
? const TextStyle(
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic)
: null,
),
),
subtitle: Text(
p.credentials is BasicCredentials
? usernamePasswordType
: oauthType,
style: active
? const TextStyle(
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic)
: null,
),
trailing: ElevatedButton(
onPressed: () async {
final confirm = await showYesNoDialog(
context, 'Log out account?');
if (confirm == true) {
await getIt<AccountsService>().signOut(p);
setState(() {});
}
},
child: const Text('Sign out'),
),
);
}).toList(),
),
),
],
),
),
@ -353,7 +391,7 @@ class _SignInScreenState extends State<SignInScreen> {
);
}
void _signIn(BuildContext context) async {
Future<void> _signIn(BuildContext context) async {
final valid = formKey.currentState?.validate() ?? false;
if (!valid) {
buildSnackbar(
@ -385,13 +423,19 @@ class _SignInScreenState extends State<SignInScreen> {
return;
}
print('Sign in credentials: ${creds.toJson()}');
final result = await getIt<AccountsService>().signIn(creds);
buildSnackbar(context, 'Attempting to sign in account...');
final result = await getIt<AccountsService>().signIn(
creds,
withNotification: false,
);
if (mounted && result.isFailure) {
buildSnackbar(context, 'Error signing in: ${result.error}');
return;
}
if (mounted) {
buildSnackbar(context, 'Account signed in...');
}
await getIt<AccountsService>().setActiveProfile(result.value);
if (mounted) {
context.goNamed(ScreenPaths.timelines);

View file

@ -1,6 +1,4 @@
import 'package:logging/logging.dart';
import 'package:relatica/services/auth_service.dart';
import 'package:relatica/services/reshared_via_service.dart';
import '../../globals.dart';
import '../../models/engagement_summary.dart';
@ -8,10 +6,13 @@ import '../../models/link_data.dart';
import '../../models/location_data.dart';
import '../../models/timeline_entry.dart';
import '../../models/visibility.dart';
import '../../services/auth_service.dart';
import '../../services/connections_manager.dart';
import '../../services/hashtag_service.dart';
import '../../services/reshared_via_service.dart';
import '../../utils/active_profile_selector.dart';
import '../../utils/dateutils.dart';
import '../../utils/html_to_edit_text_helper.dart';
import 'connection_mastodon_extensions.dart';
import 'hashtag_mastodon_extensions.dart';
import 'link_preview_mastodon_extensions.dart';
@ -100,7 +101,6 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
}
const title = '';
final body = json['content'] ?? '';
final spoilerText = json['spoiler_text'] ?? '';
final externalLink = json['uri'] ?? '';
const actualLocationData = LocationData();
@ -137,6 +137,9 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
}
}
final rawBody = json['content'] ?? '';
final body = htmlWithTagLinkSwap(rawBody, tags);
final connection = ConnectionMastodonExtensions.fromJson(json['account']);
connectionManager?.upsertConnection(connection);

View file

@ -59,6 +59,8 @@ class AccountsService extends ChangeNotifier {
() async => await executeUpdatesForProfile(profile),
);
}
} else {
await signOut(p, withNotification: false);
}
}

View file

@ -51,7 +51,7 @@ class BlocksManager extends ChangeNotifier {
);
}).match(
onSuccess: (blockedUser) {
_logger.finest(
_logger.fine(
'Successfully blocked ${blockedUser.name}: ${blockedUser.status}');
final existingIndex = _blocks.indexOf(connection);
if (existingIndex < 0) {
@ -71,8 +71,8 @@ class BlocksManager extends ChangeNotifier {
}
Future<void> unblockConnection(Connection connection) async {
_logger.finest(
'Attempting to unblock ${connection.name}: ${connection.status}');
_logger
.fine('Attempting to unblock ${connection.name}: ${connection.status}');
await RelationshipsClient(profile)
.unblockConnection(connection)
.withResult((blockedUser) {
@ -83,7 +83,7 @@ class BlocksManager extends ChangeNotifier {
);
}).match(
onSuccess: (unblockedUser) {
_logger.finest(
_logger.fine(
'Successfully unblocked ${unblockedUser.name}: ${unblockedUser.status}');
final existingIndex = _blocks.indexOf(connection);
if (existingIndex >= 0) {

View file

@ -0,0 +1,26 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
const _defaultMaxItems = 1000;
class LogService extends ChangeNotifier {
var maxItems = _defaultMaxItems;
final _events = Queue<LogRecord>();
List<LogRecord> get events => UnmodifiableListView(_events);
void add(LogRecord event) {
// final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName;
// final msg =
// '${event.level.name} - $logName @ ${event.time}: ${event.message}';
// print(msg);
_events.add(event);
if (_events.length > maxItems) {
_events.removeFirst();
}
notifyListeners();
}
}

View file

@ -1,5 +1,6 @@
import 'package:color_blindness/color_blindness.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/theme_mode_extensions.dart';
@ -40,6 +41,16 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
Level _logLevel = Level.SEVERE;
Level get logLevel => _logLevel;
set logLevel(Level level) {
_logLevel = level;
_prefs.setString(_logLevelKey, level.name);
notifyListeners();
}
Future<void> initialize() async {
if (_initialized) {
return;
@ -48,6 +59,7 @@ class SettingsService extends ChangeNotifier {
_lowBandwidthMode = _prefs.getBool(_lowBandwidthModeKey) ?? false;
_themeMode = ThemeModeExtensions.parse(_prefs.getString(_themeModeKey));
_colorBlindnessType = _colorBlindnessTypeFromPrefs(_prefs);
_logLevel = _levelFromPrefs(_prefs);
_initialized = true;
}
}
@ -55,6 +67,7 @@ class SettingsService extends ChangeNotifier {
const _lowBandwidthModeKey = 'LowBandwidthMode';
const _themeModeKey = 'ThemeMode';
const _colorBlindnessTestingModeKey = 'ColorBlindnessTestingMode';
const _logLevelKey = 'LogLevel';
ColorBlindnessType _colorBlindnessTypeFromPrefs(SharedPreferences prefs) {
final cbString = prefs.getString(_colorBlindnessTestingModeKey);
@ -66,3 +79,20 @@ ColorBlindnessType _colorBlindnessTypeFromPrefs(SharedPreferences prefs) {
orElse: () => ColorBlindnessType.none,
);
}
Level _levelFromPrefs(SharedPreferences prefs) {
final levelString = prefs.getString(_logLevelKey);
return switch (levelString) {
'ALL' => Level.ALL,
'FINEST' => Level.FINEST,
'FINER' => Level.FINER,
'FINE' => Level.FINE,
'CONFIG' => Level.CONFIG,
'INFO' => Level.INFO,
'WARNING' => Level.WARNING,
'SEVERE' => Level.SEVERE,
'SHOUT' => Level.SHOUT,
'OFF' => Level.OFF,
_ => Level.OFF,
};
}

View file

@ -53,7 +53,7 @@ class TimelineManager extends ChangeNotifier {
}
Future<void> _refreshCircleData() async {
_logger.finest('Refreshing member circle data ');
_logger.finer('Refreshing member circle data ');
await CirclesClient(profile).getCircles().match(
onSuccess: (circles) {
circlesRepo.addAllCircles(circles);
@ -192,9 +192,8 @@ class TimelineManager extends ChangeNotifier {
late final int highestId;
switch (refreshType) {
case TimelineRefreshType.refresh:
lowestId = timeline.highestStatusId == 0
? timeline.highestStatusId
: timeline.highestStatusId + 1;
timeline.clear();
lowestId = 0;
highestId = 0;
break;
case TimelineRefreshType.loadOlder:
@ -218,10 +217,10 @@ class TimelineManager extends ChangeNotifier {
FutureResult<EntryTreeItem, ExecError> toggleFavorited(
String id, bool newStatus) async {
_logger.finest('Attempting toggling favorite $id to $newStatus');
_logger.finer('Attempting toggling favorite $id to $newStatus');
final result = await entryManagerService.toggleFavorited(id, newStatus);
if (result.isFailure) {
_logger.finest('Error toggling favorite $id: ${result.error}');
_logger.info('Error toggling favorite $id: ${result.error}');
return result;
}

View file

@ -58,3 +58,9 @@ class ElapsedDateUtils {
return 'seconds ago';
}
}
const _separator = '_';
extension DateTimeExtensions on DateTime {
String toFileNameString() => '$year$month$day$_separator$hour$minute$second';
}

View file

@ -1,5 +1,10 @@
import 'package:html/dom.dart';
import 'package:html/parser.dart';
import 'package:path/path.dart' as p;
import '../globals.dart';
import '../services/auth_service.dart';
import 'network_utils.dart';
String htmlToSimpleText(String htmlContentFragment) {
try {
@ -13,6 +18,47 @@ String htmlToSimpleText(String htmlContentFragment) {
}
}
void _updateSwapTagLinks(Node node, List<String> tags) {
if (node is Element) {
if (node.attributes.containsKey('href') &&
(node.attributes['class']?.contains('hashtag') ?? false) &&
node.attributes['rel'] == 'tag') {
final url = Uri.parse(node.attributes['href'] ?? '');
late final String tag;
final pathEnd = p.split(url.path).last;
if (pathEnd == 'search' && url.queryParameters.containsKey('tag')) {
tag = url.queryParameters['search']!;
} else {
tag = pathEnd;
}
final tagLowercase = tag.toLowerCase();
final hasExpectedTag = tags
.firstWhere((t) => t.toLowerCase() == tagLowercase, orElse: () => '')
.isNotEmpty;
if (hasExpectedTag) {
final profile = getIt<AccountsService>().currentProfile;
final newTagUrl = generateTagUrlFromProfile(profile, tag);
print(node.attributes['href']);
node.attributes['href'] = newTagUrl.toString();
print(node.attributes['href']);
}
}
node.nodes.forEach((n) => _updateSwapTagLinks(n, tags));
}
}
String htmlWithTagLinkSwap(String htmlContentFragment, List<String> tags) {
try {
final dom = parseFragment(htmlContentFragment);
dom.nodes.forEach((n) => _updateSwapTagLinks(n, tags));
final result = dom.outerHtml;
return result;
} catch (e) {
return htmlContentFragment;
}
}
extension NodeTextConverter on Node {
String nodeToEditText() {
if (nodes.isEmpty) {

View file

@ -0,0 +1,11 @@
import 'package:logging/logging.dart';
extension LogRecordExtensions on LogRecord {
Map<String, dynamic> toJson() => {
'logger': loggerName,
'level': level.toString(),
'time': time.toIso8601String(),
'message': message,
'stackTrace': stackTrace?.toString() ?? 'NONE',
};
}

View file

@ -6,31 +6,155 @@ import 'package:result_monad/result_monad.dart';
import '../friendica_client/paged_response.dart';
import '../globals.dart';
import '../models/auth/profile.dart';
import '../models/exec_error.dart';
final _logger = Logger('NetworkUtils');
http.Response requestTimeout() => http.Response('Client side timeout', 408);
enum _RequestType {
get,
}
const _expireDuration = Duration(seconds: 2);
class _CachedResponse {
final _RequestType requestType;
final Uri requestUri;
final Map<String, dynamic> requestBody;
final Map<String, String> headers;
final http.Response response;
final DateTime requestTime;
_CachedResponse(
{required this.requestType,
required this.requestUri,
required this.requestBody,
required this.headers,
required this.response,
required this.requestTime});
factory _CachedResponse.requestStub(_RequestType type, Uri uri,
Map<String, String>? headers, Map<String, dynamic>? body) =>
_CachedResponse(
requestType: type,
requestUri: uri,
requestBody: body ?? {},
headers: headers ?? {},
response: http.Response('', 555),
requestTime: DateTime(0),
);
factory _CachedResponse.response(
_RequestType type,
Uri uri,
Map<String, String>? headers,
Map<String, dynamic>? body,
http.Response response,
) =>
_CachedResponse(
requestType: type,
requestUri: uri,
requestBody: body ?? {},
headers: headers ?? {},
response: response,
requestTime: DateTime.now(),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _CachedResponse &&
runtimeType == other.runtimeType &&
requestType == other.requestType &&
requestUri == other.requestUri;
@override
int get hashCode => requestType.hashCode ^ requestUri.hashCode;
}
class _ExpiringRequestCache {
final _responses = <_CachedResponse, _CachedResponse>{};
void _cleanupCache() {
final expireTime = DateTime.now().subtract(_expireDuration);
_logger
.finest('Cleaning up request cache with ${_responses.length} entries');
_responses.removeWhere((key, value) {
final expired = key.requestTime.isBefore(expireTime);
if (expired) {
_logger.finest(
'Expiring request: ${value.requestType} => ${value.requestUri}');
}
return expired;
});
_logger.finest('Cleaned up request cache has ${_responses.length} entries');
}
Future<http.Response> getRequestOrExecute({
required Uri url,
Map<String, String>? headers,
Map<String, dynamic>? body,
Duration? timeout,
}) async {
_cleanupCache();
const type = _RequestType.get;
final requestStub = _CachedResponse.requestStub(
type,
url,
headers,
null,
);
late final http.Response response;
if (_responses.containsKey(requestStub)) {
print('Returning cached response for $type => $url');
response = _responses[requestStub]?.response ?? http.Response('', 555);
} else {
final request = http.get(
url,
headers: headers,
);
response = await request.timeout(
timeout ?? apiCallTimeout,
onTimeout: requestTimeout,
);
final cacheEntry = _CachedResponse.response(
type,
url,
headers ?? {},
body ?? {},
response,
);
print('Adding cached response for $type => $url');
_responses[cacheEntry] = cacheEntry;
}
return response;
}
}
final _cache = _ExpiringRequestCache();
FutureResult<PagedResponse<String>, ExecError> getUrl(
Uri url, {
Map<String, String>? headers,
Duration? timeout,
}) async {
_logger.finer('GET: $url');
_logger.fine('GET: $url');
final requestHeaders = headers ?? {};
if (usePhpDebugging) {
requestHeaders['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/';
}
try {
final request = http.get(
url,
headers: requestHeaders,
);
final response = await request.timeout(
timeout ?? apiCallTimeout,
onTimeout: requestTimeout,
final response = await _cache.getRequestOrExecute(
url: url,
headers: headers,
timeout: timeout ?? apiCallTimeout,
);
if (response.statusCode != 200) {
@ -54,7 +178,7 @@ FutureResult<String, ExecError> postUrl(
Map<String, String>? headers,
Duration? timeout,
}) async {
_logger.finer('POST: $url \n Body: $body');
_logger.fine('POST: $url \n Body: $body');
final requestHeaders = headers ?? {};
if (usePhpDebugging) {
requestHeaders['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/';
@ -87,8 +211,9 @@ FutureResult<String, ExecError> putUrl(
Uri url,
Map<String, dynamic> body, {
Map<String, String>? headers,
Duration? timeout,
}) async {
_logger.finer('PUT: $url \n Body: $body');
_logger.fine('PUT: $url \n Body: $body');
try {
final request = http.put(
url,
@ -97,7 +222,7 @@ FutureResult<String, ExecError> putUrl(
);
final response = await request.timeout(
apiCallTimeout,
timeout ?? apiCallTimeout,
onTimeout: requestTimeout,
);
@ -117,8 +242,9 @@ FutureResult<String, ExecError> deleteUrl(
Uri url,
Map<String, dynamic> body, {
Map<String, String>? headers,
Duration? timeout,
}) async {
_logger.finer('DELETE: $url');
_logger.fine('DELETE: $url');
try {
final request = http.delete(
url,
@ -127,7 +253,7 @@ FutureResult<String, ExecError> deleteUrl(
);
final response = await request.timeout(
apiCallTimeout,
timeout ?? apiCallTimeout,
onTimeout: requestTimeout,
);
@ -142,3 +268,7 @@ FutureResult<String, ExecError> deleteUrl(
ExecError(type: ErrorType.localError, message: e.toString()));
}
}
Uri generateTagUrlFromProfile(Profile profile, String tag) {
return Uri.https(profile.serverName, '/search', {'tag': tag});
}

View file

@ -2,7 +2,7 @@ name: relatica
description: A mobile and desktop client for interacting with the Friendica social network
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 0.8.0
version: 0.9.0
environment:
sdk: '>=3.0.0 <4.0.0'

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB