mirror of
https://gitlab.com/mysocialportal/relatica
synced 2024-10-18 12:23:31 +00:00
Merge branch 'main' into codemagic-setup
This commit is contained in:
commit
c9a57e02e5
39 changed files with 948 additions and 270 deletions
|
@ -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"/>
|
||||
|
||||
|
|
12
install.md
12
install.md
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -103,6 +103,7 @@ class _MediaUploadsControlState extends State<MediaUploadsControl> {
|
|||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (value) {
|
||||
widget.entryMediaItems.albumName = value;
|
||||
},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)];
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>>()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -138,6 +138,7 @@ class _ImageEditorScreenState extends State<ImageEditorScreen> {
|
|||
TextField(
|
||||
controller: altTextController,
|
||||
maxLines: 10,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'ALT Text',
|
||||
alignLabelWithHint: true,
|
||||
|
|
224
lib/screens/logviewer_screen.dart
Normal file
224
lib/screens/logviewer_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -59,6 +59,8 @@ class AccountsService extends ChangeNotifier {
|
|||
() async => await executeUpdatesForProfile(profile),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await signOut(p, withNotification: false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
26
lib/services/log_service.dart
Normal file
26
lib/services/log_service.dart
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -58,3 +58,9 @@ class ElapsedDateUtils {
|
|||
return 'seconds ago';
|
||||
}
|
||||
}
|
||||
|
||||
const _separator = '_';
|
||||
|
||||
extension DateTimeExtensions on DateTime {
|
||||
String toFileNameString() => '$year$month$day$_separator$hour$minute$second';
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
11
lib/utils/logrecord_extensions.dart
Normal file
11
lib/utils/logrecord_extensions.dart
Normal 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',
|
||||
};
|
||||
}
|
|
@ -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});
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
BIN
screenshots/v0.8.0/home_screen.png
Executable file
BIN
screenshots/v0.8.0/home_screen.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 333 KiB |
BIN
screenshots/v0.8.0/signin_screen.png
Executable file
BIN
screenshots/v0.8.0/signin_screen.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 48 KiB |
BIN
screenshots/v0.8.0/timeline_picker.png
Executable file
BIN
screenshots/v0.8.0/timeline_picker.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
Loading…
Reference in a new issue