Initial port of Kyanite into Friendica Archive Browser naming

This commit is contained in:
Hank Grabowski 2022-01-17 11:22:53 -05:00
parent 8066a3439d
commit 9bf45e42ba
139 changed files with 10023 additions and 334 deletions

373
LICENSE Normal file
View file

@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

View file

@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b
revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b
channel: stable
project_type: app

View file

@ -0,0 +1,33 @@
# Kyanite Changelog
## Version 0.1.2 (2021-12-07)
### New Features
* Make Photo Details an image carousel on posts/albums with multiple images
* Let users navigate photo details carousel with arrow keys and go back to former screen with escape-key
* Added a "copy" button on posts, comments, conversations that copies all the textual data to the clipboard
* Adds a map view for posts/photos that have latitude/longitude data
### Bug Fixes
* Fixes memory leak with images and posts
* Fixes error where default video player was set to empty string on initial startup
* Fix capitalization inconsistencies on buttons
### Changes
* Change log file textbox on settings panel to be single line and overflow with ellipses
## Version 0.1.1 (2021-11-17)
### Bug Fixes
* Add support for update Facebook archive format (versus original one from a year ago)
## Version 0.1.0 (2021-11-14) ** [Initial Release] **
### New Features
* Posts Browsing/filtering (including media and links)
* Comments Browsing/filtering (including media and links)
* Photo Albums Browsing/filtering (and photos attached to posts and comments)
* Video Album Browsing/filtering (and videos attached to posts and comments)
* Facebook Messenger Conversation Browsing/filtering (with media and links)
* Events Browsing/filtering
* Friends list and history browsing
* Ability to export photos from posts/comments/albums/etc.

View file

@ -1,30 +1,37 @@
# friendica_archive_browser
# A Friendica Archive Viewer
A new Flutter project.
A Flutter-based cross platform desktop
application for viewing the Friendica account archive that a user can
generate with the command line tool in this same project
## Getting Started
## Installation
This project is a starting point for a Flutter application that follows the
[simple app state management
tutorial](https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple).
To install Kyanite you simply have to download the latest release from
[the project release directory](https://gitlab.com/HankG/mysocialportal/-/releases)
for your given platform. Then unzip the folder and you are ready to run. On Mac
and Windows you will get a warning about an "unknown publisher" since this is beta
software that is not installed or signed through the respective app stores. App store
versions will come in the near future.
For help getting started with Flutter, view our
[online documentation](https://flutter.dev/docs), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
## Building
In order to build this application you will need to have installed [Flutter](https://flutter.dev).
Installation instructions for various platforms are [here](https://flutter.dev/docs/get-started/install).
Once you have that installed it is as easy as navigating to the respective directory on the command
line and executing:
## Assets
On Linux:
```bash
flutter run -d linux
```
The `assets` directory houses images, fonts, and any other files you want to
include with your application.
On Mac:
```bash
flutter run -d macos
```
The `assets/images` directory contains [resolution-aware
images](https://flutter.dev/docs/development/ui/assets-and-images#resolution-aware).
On Windows:
```bash
flutter run -d windows
```
## Localization
This project generates localized messages based on arb files found in
the `lib/src/localization` directory.
To support additional languages, please visit the tutorial on
[Internationalizing Flutter
apps](https://flutter.dev/docs/development/accessibility-and-localization/internationalization)
Please report any bugs or feature requests [with our issue tracker](https://gitlab.com/HankG/mysocialportal/-/issues).

View file

@ -1,20 +1,33 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/utils/temp_file_builder.dart';
import 'package:logging/logging.dart';
import 'src/app.dart';
import 'src/settings/settings_controller.dart';
import 'src/settings/settings_service.dart';
void main() async {
// Set up the SettingsController, which will glue user settings to multiple
// Flutter Widgets.
final settingsController = SettingsController(SettingsService());
// Load the user's preferred theme while the splash screen is displayed.
// This prevents a sudden theme change when the app is first displayed.
WidgetsFlutterBinding.ensureInitialized();
final logPath = await setupLogging();
Logger.root.info('Starting Facebook Archive Viewer');
final settingsController = SettingsController(logPath: logPath);
await settingsController.loadSettings();
// Run the app and pass in the SettingsController. The app listens to the
// SettingsController for changes, then passes it further down to the
// SettingsView.
runApp(MyApp(settingsController: settingsController));
runApp(FriendicaArchiveBrowser(settingsController: settingsController));
}
Future<String> setupLogging() async {
final logFilePath = await getTempFile('friendica_archive_browser_', '.log');
final logFile = File(logFilePath);
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((event) {
final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName;
final msg =
'${event.level.name} - $logName @ ${event.time}: ${event.message}\n';
final handle = logFile.openSync(mode: FileMode.append);
handle.writeStringSync(msg);
handle.closeSync();
});
return logFilePath;
}

View file

@ -1,15 +1,21 @@
import 'package:desktop_window/desktop_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:friendica_archive_browser/src/themes.dart';
import 'package:friendica_archive_browser/src/utils/scrolling_behavior.dart';
import 'package:provider/provider.dart';
import 'sample_feature/sample_item_details_view.dart';
import 'sample_feature/sample_item_list_view.dart';
import 'friendica/services/facebook_archive_service.dart';
import 'friendica/services/path_mapping_service.dart';
import 'home.dart';
import 'settings/settings_controller.dart';
import 'settings/settings_view.dart';
/// The Widget that configures your application.
class MyApp extends StatelessWidget {
const MyApp({
class FriendicaArchiveBrowser extends StatelessWidget {
static const minAppSize = Size(915, 700);
const FriendicaArchiveBrowser({
Key? key,
required this.settingsController,
}) : super(key: key);
@ -18,23 +24,20 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Glue the SettingsController to the MaterialApp.
//
// The AnimatedBuilder Widget listens to the SettingsController for changes.
// Whenever the user updates their settings, the MaterialApp is rebuilt.
DesktopWindow.setMinWindowSize(minAppSize);
final pathMappingService = PathMappingService(settingsController);
final archiveService = FacebookArchiveDataService(
pathMappingService: pathMappingService,
appDataDirectory: settingsController.appDataDirectory.path);
settingsController.addListener(() {
archiveService.clearCaches();
pathMappingService.refresh();
});
return AnimatedBuilder(
animation: settingsController,
builder: (BuildContext context, Widget? child) {
return MaterialApp(
// Providing a restorationScopeId allows the Navigator built by the
// MaterialApp to restore the navigation stack when a user leaves and
// returns to the app after it has been killed while running in the
// background.
restorationScopeId: 'app',
// Provide the generated AppLocalizations to the MaterialApp. This
// allows descendant Widgets to display the correct translations
// depending on the user's locale.
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
@ -44,40 +47,20 @@ class MyApp extends StatelessWidget {
supportedLocales: const [
Locale('en', ''), // English, no country code
],
// Use AppLocalizations to configure the correct application title
// depending on the user's locale.
//
// The appTitle is defined in .arb files found in the localization
// directory.
onGenerateTitle: (BuildContext context) =>
AppLocalizations.of(context)!.appTitle,
// Define a light and dark color theme. Then, read the user's
// preferred ThemeMode (light, dark, or system default) from the
// SettingsController to display the correct theme.
theme: ThemeData(),
darkTheme: ThemeData.dark(),
theme: FriendicaArchiveBrowserTheme.light,
darkTheme: FriendicaArchiveBrowserTheme.dark,
themeMode: settingsController.themeMode,
// Define a function to handle named routes in order to support
// Flutter web url navigation and deep linking.
onGenerateRoute: (RouteSettings routeSettings) {
return MaterialPageRoute<void>(
settings: routeSettings,
builder: (BuildContext context) {
switch (routeSettings.name) {
case SettingsView.routeName:
return SettingsView(controller: settingsController);
case SampleItemDetailsView.routeName:
return const SampleItemDetailsView();
case SampleItemListView.routeName:
default:
return const SampleItemListView();
}
},
);
},
scrollBehavior: FacebookAppScrollingBehavior(),
home: MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => settingsController),
ChangeNotifierProvider(create: (context) => archiveService),
Provider(create: (context) => pathMappingService),
],
child: Home(settingsController: settingsController),
),
);
},
);

View file

@ -0,0 +1,42 @@
import 'package:charts_flutter/flutter.dart' as charts;
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/models/stat_bin.dart';
import 'package:logging/logging.dart';
class BarChartComponent extends StatelessWidget {
static final _logger = Logger('$BarChartComponent');
final List<StatBin> stats;
final String Function(int index) xLabelMaker;
const BarChartComponent(
{Key? key, required this.stats, required this.xLabelMaker})
: super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine("Build BarChartComponent");
final graphItems = charts.Series<StatBin, String>(
id: 'Stats',
domainFn: (bin, _) => xLabelMaker(bin.index),
measureFn: (bin, _) => bin.count,
data: stats,
labelAccessorFn: (bin, _) => bin.count.toString(),
);
return AspectRatio(
aspectRatio: 2,
child: Card(
elevation: 4,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
color: Colors.white,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: charts.BarChart(
[graphItems],
animate: false,
barRendererDecorator: charts.BarLabelDecorator<String>(),
domainAxis: const charts.OrdinalAxisSpec(),
))));
}
}

View file

@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/components/heatmap/heatmap_tile.dart';
import 'package:friendica_archive_browser/src/components/heatmap/tile_color_map.dart';
import 'package:friendica_archive_browser/src/models/stat_bin.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/time_stat_generator.dart';
import 'package:provider/provider.dart';
class HeatMapComponent extends StatelessWidget {
static const gridStart = 40;
static final colorMapData = {
1: Colors.green[100]!,
5: Colors.green[300]!,
10: Colors.green[500]!,
20: Colors.green[700]!
};
final int year;
final List<StatBin> stats;
const HeatMapComponent({Key? key, required this.year, required this.stats})
: super(key: key);
@override
Widget build(BuildContext context) {
final formatter = Provider.of<SettingsController>(context).dateFormatter;
final zeroColor = Theme.of(context).cardColor;
final colorMap = TileColorMap(colorMapData, zeroValue: zeroColor);
final statsByDay = <DateTime, int>{};
for (final stat in stats) {
statsByDay[stat.binEpoch] = stat.count;
}
final firstDayOfCalendar = _firstHeatMapDay();
final weeks = List.generate(
53,
(index) =>
firstDayOfCalendar.add(Duration(days: 7 * index)).toDayOnly())
.where((date) => date.year <= year)
.toList();
final weekColumns = weeks
.map((week) => Column(
children: List.generate(7, (day) {
final currentDate = week.add(Duration(days: day));
final value = statsByDay[currentDate] ?? 0;
if (currentDate.year != year) {
return HeatMapTile.blankTile(formatter.format(currentDate));
}
return HeatMapTile(
formatter.format(currentDate), value, colorMap);
})))
.toList();
final dayofWeekColumn = _buildDayOfWeekLabels(context);
final monthsOfYearRow = SizedBox(
height: 20,
width: 800,
child: Stack(
children: _buildMonthLabels(weeks),
));
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
monthsOfYearRow,
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [dayofWeekColumn, ...weekColumns],
),
_buildLegendWidget(context, colorMap),
],
);
}
Widget _buildLegendWidget(BuildContext context, TileColorMap colorMap) {
final legend = [
Row(
children: const [
Text(
'Legend',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(
height: 10,
),
Row(
children: [
HeatMapTile('hovered tile', 1, colorMap),
const SizedBox(width: 5),
const Text('1 to 5'),
],
),
Row(
children: [
HeatMapTile('hovered tile', 5, colorMap),
const SizedBox(width: 5),
const Text('6 to 10'),
],
),
Row(
children: [
HeatMapTile('hovered tile', 10, colorMap),
const SizedBox(width: 5),
const Text('11 to 19'),
],
),
Row(
children: [
HeatMapTile('hovered tile', 20, colorMap),
const SizedBox(width: 5),
const Text('20 and above'),
],
),
];
return Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
child: SizedBox(
width: 200,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: legend,
),
),
),
),
);
}
Widget _buildDayOfWeekLabels(BuildContext context) {
return SizedBox(
height: 7 * HeatMapTile.totalHeight,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Text(
'Mon',
style: TextStyle(fontSize: HeatMapTile.height),
),
Text(
'Wed',
style: TextStyle(fontSize: HeatMapTile.height),
),
Text(
'Sun',
style: TextStyle(fontSize: HeatMapTile.height),
),
],
));
}
List<Positioned> _buildMonthLabels(List<DateTime> weeks) {
final monthStartColumn = List.generate(12, (index) => -1);
for (var i = 0; i < weeks.length; i++) {
final week = weeks[i];
final startMonth = week.month - 1;
final endMonth = week.add(const Duration(days: 7)).month - 1;
if (startMonth == 11 && endMonth == 0) {
monthStartColumn[0] = 0;
continue;
}
if (monthStartColumn[startMonth] < 0) {
monthStartColumn[startMonth] = i;
}
if (monthStartColumn[endMonth] < 0) {
monthStartColumn[endMonth] = i;
}
}
final monthLabels = <Positioned>[];
for (var i = 0; i < monthStartColumn.length; i++) {
late String text;
if (i == 0) {
text = 'Jan';
} else if (i == 1) {
text = 'Feb';
} else if (i == 2) {
text = 'Mar';
} else if (i == 3) {
text = 'Apr';
} else if (i == 4) {
text = 'May';
} else if (i == 5) {
text = 'Jun';
} else if (i == 6) {
text = 'Jul';
} else if (i == 7) {
text = 'Aug';
} else if (i == 8) {
text = 'Sep';
} else if (i == 9) {
text = 'Oct';
} else if (i == 10) {
text = 'Nov';
} else {
text = 'Dec';
}
final label = Positioned(
left: gridStart + monthStartColumn[i] * HeatMapTile.totalWidth,
child: Text(text));
monthLabels.add(label);
}
return monthLabels;
}
DateTime _firstHeatMapDay() {
final firstDayOfYear = DateTime(year).weekday;
final daysIntoPreviousCalendar = firstDayOfYear - 1;
return DateTime(year).subtract(Duration(days: daysIntoPreviousCalendar));
}
}

View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/components/heatmap/tile_color_map.dart';
class HeatMapTile extends StatelessWidget {
static const width = 12.0;
static const height = 12.0;
static const margin = 1.0;
final String dateString;
final int value;
final TileColorMap colorMap;
static double get totalHeight => height + (margin * 2);
static double get totalWidth => width + (margin * 2);
const HeatMapTile(this.dateString, this.value, this.colorMap, {Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
final colorResult = colorMap.getColor(value);
return colorResult.fold(
onSuccess: (color) => Tooltip(
message: '$value on $dateString',
child: Card(
margin: const EdgeInsets.all(margin),
color: color,
child: const SizedBox(width: width, height: width))),
onError: (error) => Tooltip(
message: dateString,
child: const Padding(
padding: EdgeInsets.all(margin),
child: SizedBox(width: width, height: height),
)));
}
HeatMapTile.blankTile(this.dateString, {Key? key})
: value = 0,
colorMap = TileColorMap({}),
super(key: key);
}

View file

@ -0,0 +1,30 @@
import 'dart:ui';
import 'package:result_monad/result_monad.dart';
class TileColorMap {
final Map<int, Color> thresholds;
final Color? zeroValue;
final thresholdValues = <int>[];
TileColorMap(this.thresholds, {this.zeroValue}) {
thresholdValues.addAll(thresholds.keys);
thresholdValues.sort();
}
Result<Color, int> getColor(int value) {
if (thresholdValues.isEmpty) {
return Result.error(0);
}
if (zeroValue != null && value == 0) {
return Result.ok(zeroValue!);
}
int thresholdIndex = thresholdValues
.where((element) => element <= value)
.lastWhere((element) => element <= value,
orElse: () => thresholdValues.first);
return Result.ok(thresholds[thresholdIndex]!);
}
}

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/models/time_element.dart';
import 'package:friendica_archive_browser/src/screens/standin_status_screen.dart';
import 'package:friendica_archive_browser/src/utils/time_stat_generator.dart';
import 'heatmap/heatmap_component.dart';
class HeatMapWidget extends StatefulWidget {
final List<TimeElement> timeElements;
const HeatMapWidget({Key? key, required this.timeElements}) : super(key: key);
@override
State<HeatMapWidget> createState() => _HeatMapWidgetState();
}
class _HeatMapWidgetState extends State<HeatMapWidget> {
int year = 2024;
final years = <int>[];
@override
void initState() {
years.clear();
final newYears = widget.timeElements.map((e) => e.timestamp.year).toSet();
if (newYears.isEmpty) {
years.add(DateTime.now().year);
}
years.addAll(newYears);
years.sort();
year = years.last;
super.initState();
}
@override
Widget build(BuildContext context) {
if (widget.timeElements.isEmpty) {
return const StandInStatusScreen(title: 'No items for heat map');
}
final statBins = TimeStatGenerator(widget.timeElements
.where((element) => element.timestamp.year == year))
.calculateDailyStats();
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Heat Map for $year',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline6,
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text('Year:'),
const SizedBox(width: 5),
DropdownButton<int>(
value: year,
items: years
.map((y) => DropdownMenuItem(value: y, child: Text('$y')))
.toList(),
onChanged: (newYear) => setState(() {
year = newYear!;
})),
],
),
HeatMapComponent(year: year, stats: statBins),
],
),
);
}
}

View file

@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/components/barchart_panel.dart';
import 'package:friendica_archive_browser/src/models/stat_bin.dart';
import 'package:friendica_archive_browser/src/models/time_element.dart';
import 'package:friendica_archive_browser/src/screens/standin_status_screen.dart';
import 'package:friendica_archive_browser/src/utils/time_stat_generator.dart';
import 'package:logging/logging.dart';
class TimeChartWidget extends StatefulWidget {
final List<TimeElement> timeElements;
const TimeChartWidget({Key? key, required this.timeElements})
: super(key: key);
@override
State<TimeChartWidget> createState() => _TimeChartWidgetState();
}
class _TimeChartWidgetState extends State<TimeChartWidget> {
static final _logger = Logger('$_TimeChartWidgetState');
_TimeType _timeType = _TimeType.year;
@override
Widget build(BuildContext context) {
_logger.fine('Build TimeChartWidget');
if (widget.timeElements.isEmpty) {
return const StandInStatusScreen(title: 'No items for statistics');
}
final statBins = <StatBin>[];
final generator = TimeStatGenerator(widget.timeElements);
late final String Function(int index) xAxisStringFunction;
switch (_timeType) {
case _TimeType.day:
xAxisStringFunction = (index) => _dayStringFromIndex(index);
statBins.addAll(generator.calculateByDayOfWeekStats());
break;
case _TimeType.month:
xAxisStringFunction = (index) => _monthStringFromIndex(index);
statBins.addAll(generator.calculateByMonthStats());
break;
case _TimeType.year:
statBins.addAll(generator.calculateStatsByYear());
xAxisStringFunction = (int index) => index.toString();
break;
}
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'${_timeType.toAdjectiveName()} Statistics',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline6,
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text('Date Grouping Type:'),
const SizedBox(width: 5),
DropdownButton<_TimeType>(
value: _timeType,
items: _TimeType.values
.map((e) =>
DropdownMenuItem(value: e, child: Text(e.toName())))
.toList(),
onChanged: (timeType) => setState(() {
_timeType = timeType!;
})),
],
),
BarChartComponent(stats: statBins, xLabelMaker: xAxisStringFunction)
],
),
);
}
String _dayStringFromIndex(int index) {
switch (index) {
case 1:
return 'Monday';
case 2:
return 'Tuesday';
case 3:
return 'Wednesday';
case 4:
return 'Thursday';
case 5:
return 'Friday';
case 6:
return 'Saturday';
case 7:
return 'Sunday';
default:
_logger.severe(['Invalid date index: $index', 'index']);
return '$index';
}
}
String _monthStringFromIndex(int index) {
switch (index) {
case 1:
return 'January';
case 2:
return 'February';
case 3:
return 'March';
case 4:
return 'April';
case 5:
return 'May';
case 6:
return 'June';
case 7:
return 'July';
case 8:
return 'August';
case 9:
return 'September';
case 10:
return 'October';
case 11:
return 'November';
case 12:
return 'December';
default:
_logger.severe(['Invalid date index: $index', 'index']);
return '$index';
}
}
}
enum _TimeType { day, month, year }
extension _TimeTypeStringUtils on _TimeType {
String toAdjectiveName() {
switch (this) {
case _TimeType.day:
return 'Daily';
case _TimeType.month:
return 'Monthly';
case _TimeType.year:
return 'Yearly';
}
}
String toName() {
switch (this) {
case _TimeType.day:
return 'Day';
case _TimeType.month:
return 'Month';
case _TimeType.year:
return 'Year';
}
}
}

View file

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/models/time_element.dart';
import 'package:friendica_archive_browser/src/utils/word_map_generator.dart';
import 'package:logging/logging.dart';
class WordFrequencyWidget extends StatefulWidget {
final List<TimeElement> elements;
const WordFrequencyWidget(this.elements, {Key? key}) : super(key: key);
@override
State<WordFrequencyWidget> createState() => _WordFrequencyWidgetState();
}
class _WordFrequencyWidgetState extends State<WordFrequencyWidget> {
static final _logger = Logger('$WordFrequencyWidget');
int _currentThreshold = 10;
final _thresholds = [10, 20, 50, 100];
final topElements = <WordMapItem>[];
final generator = WordMapGenerator.withCommonWordsFilter(minimumWordSize: 3);
@override
void initState() {
super.initState();
}
// TODO: Put in Isolate if jank goes for too long in practice
void _generateWordMap() {
_logger.finer('Filling list');
generator.clear();
for (final item in widget.elements) {
generator.processEntry(item.text);
}
_logger.finer('List filled');
_calcTopList(false);
}
Future<void> _calcTopList(bool updateState) async {
final newTopElements = generator.getTopList(_currentThreshold);
topElements.clear();
topElements.addAll(newTopElements);
if (updateState) {
setState(() {});
}
_logger.finer('List filled with ${topElements.length} elements');
}
@override
Widget build(BuildContext context) {
_logger.fine('Rebuilding WordFrequencyWidget');
_generateWordMap();
_logger.finer('Top elements count: ${topElements.length}');
final rowElements = <Widget>[];
for (var i = 0; i < topElements.length; i++) {
final element = topElements[i];
final background = i % 2 == 0 ? null : Theme.of(context).dividerColor;
final row = Container(
color: background,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(element.word), Text('${element.count}')],
));
rowElements.add(row);
}
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
'Top',
textAlign: TextAlign.left,
style: Theme.of(context).textTheme.headline6,
),
Padding(
padding: const EdgeInsets.only(left: 5.0, right: 5.0),
child: DropdownButton<int>(
value: _currentThreshold,
items: _thresholds
.map((t) =>
DropdownMenuItem(value: t, child: Text('$t')))
.toList(),
onChanged: (newValue) async {
_currentThreshold = newValue ?? _thresholds.first;
_calcTopList(true);
}),
),
Text(
'Words',
textAlign: TextAlign.right,
style: Theme.of(context).textTheme.headline6,
),
],
),
const SizedBox(height: 10.0),
SizedBox(
width: 200,
child: Column(
children: rowElements,
),
),
],
),
);
}
}

View file

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_comment.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/clipboard_helper.dart';
import 'package:provider/provider.dart';
import 'facebook_link_elements_component.dart';
import 'facebook_media_timeline_component.dart';
class CommentCard extends StatelessWidget {
final FacebookComment comment;
const CommentCard({Key? key, required this.comment}) : super(key: key);
@override
Widget build(BuildContext context) {
if (Scrollable.recommendDeferredLoadingForContext(context)) {
return const SizedBox();
}
const double spacingHeight = 5.0;
final formatter = context.read<SettingsController>().dateTimeFormatter;
final title = comment.title.isEmpty ? 'Comment' : comment.title;
final mapper = Provider.of<PathMappingService>(context);
final dateStamp = ' At ' +
formatter.format(DateTime.fromMillisecondsSinceEpoch(
comment.creationTimestamp * 1000)
.toLocal());
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
direction: Axis.horizontal,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
Text(dateStamp,
style: const TextStyle(
fontStyle: FontStyle.italic,
)),
Tooltip(
message: 'Copy text version of comment to clipboard',
child: IconButton(
onPressed: () async => await copyToClipboard(
context: context,
text: comment.toHumanString(mapper, formatter),
snackbarMessage: 'Copied Comment to clipboard'),
icon: const Icon(Icons.copy)),
),
]),
if (comment.comment.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
Text(comment.comment)
],
if (comment.links.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
FacebookLinkElementsComponent(links: comment.links)
],
if (comment.mediaAttachments.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
FacebookMediaTimelineComponent(
mediaAttachments: comment.mediaAttachments)
],
],
),
);
}
}

View file

@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_messenger_message.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/clipboard_helper.dart';
import 'package:provider/provider.dart';
import 'facebook_link_elements_component.dart';
import 'facebook_media_timeline_component.dart';
import 'facebook_media_wrapper_component.dart';
class ConversationMessageCard extends StatelessWidget {
final FacebookMessengerMessage message;
const ConversationMessageCard({Key? key, required this.message})
: super(key: key);
@override
Widget build(BuildContext context) {
if (Scrollable.recommendDeferredLoadingForContext(context)) {
return const SizedBox();
}
const double spacingHeight = 5.0;
const double stickerSize = 64.0;
final settings = Provider.of<SettingsController>(context);
final formatter = settings.dateTimeFormatter;
final mapper = Provider.of<PathMappingService>(context);
return Padding(
padding: const EdgeInsets.all(8.0),
child: Tooltip(
message: formatter
.format(DateTime.fromMillisecondsSinceEpoch(message.timestampMS)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Tooltip(
message: 'Copy text version of line to clipboard',
child: IconButton(
onPressed: () async => await copyToClipboard(
context: context,
text: message.toHumanString(mapper, formatter),
snackbarMessage:
'Copied Messenger line to clipboard'),
icon: const Icon(Icons.copy)),
),
Text('${message.from}: ',
style: const TextStyle(fontWeight: FontWeight.bold)),
Expanded(
child: Text(
message.message,
)),
]),
if (message.media.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
FacebookMediaTimelineComponent(mediaAttachments: message.media)
],
if (message.stickers.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: message.stickers
.map((s) => FacebookMediaWrapperComponent(
mediaAttachment: s,
preferredWidth: stickerSize,
preferredHeight: stickerSize,
))
.toList(),
)
],
if (message.links.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
FacebookLinkElementsComponent(links: message.links)
],
],
),
),
);
}
}

View file

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_event.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_location_data.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/clipboard_helper.dart';
import 'package:provider/provider.dart';
class EventCard extends StatelessWidget {
final FacebookEvent event;
const EventCard({Key? key, required this.event}) : super(key: key);
@override
Widget build(BuildContext context) {
const double spacingHeight = 5.0;
final formatter =
Provider.of<SettingsController>(context).dateTimeFormatter;
final copyButton = Tooltip(
message: 'Copy text version of event to clipboard',
child: IconButton(
onPressed: () async => await copyToClipboard(
context: context,
text: event.toHumanString(formatter),
snackbarMessage: 'Copied Event to clipboard'),
icon: const Icon(Icons.copy)));
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
direction: Axis.horizontal,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
event.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
copyButton,
],
),
if (event.description.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
Text(event.description)
],
_buildStatusLine('You are:', _eventStatusToString(event.eventStatus)),
const SizedBox(height: spacingHeight),
_buildStatusLine(
'Starts: ',
formatter.format(DateTime.fromMillisecondsSinceEpoch(
event.startTimestamp * 1000))),
if (event.endTimestamp != 0) ...[
const SizedBox(height: spacingHeight),
_buildStatusLine(
'Stops: ',
formatter.format(DateTime.fromMillisecondsSinceEpoch(
event.endTimestamp * 1000))),
],
const SizedBox(height: spacingHeight),
if (event.location.hasData()) event.location.toWidget(spacingHeight),
],
),
);
}
Widget _buildStatusLine(String label, String status) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 2),
Text(status),
],
);
}
String _eventStatusToString(FacebookEventStatus status) {
switch (status) {
case FacebookEventStatus.declined:
return 'Declined';
case FacebookEventStatus.interested:
return 'Interested';
case FacebookEventStatus.invited:
return 'Invited';
case FacebookEventStatus.joined:
return 'Joined';
case FacebookEventStatus.owner:
return 'Owner';
case FacebookEventStatus.unknown:
return 'Unknown';
}
}
}

View file

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_messenger_conversation.dart';
import 'package:friendica_archive_browser/src/screens/standin_status_screen.dart';
class FacebookConversationHistoryComponent extends StatefulWidget {
static final FacebookMessengerConversation noConversationSelected =
FacebookMessengerConversation.empty();
final FacebookMessengerConversation conversation;
const FacebookConversationHistoryComponent(
{Key? key, required this.conversation})
: super(key: key);
@override
State<FacebookConversationHistoryComponent> createState() =>
_FacebookConversationHistoryComponentState();
}
class _FacebookConversationHistoryComponentState
extends State<FacebookConversationHistoryComponent> {
@override
Widget build(BuildContext context) {
if (widget.conversation ==
FacebookConversationHistoryComponent.noConversationSelected) {
return const StandInStatusScreen(
title: 'No conversation selected',
subTitle: 'Select a conversation to display here',
);
}
return ListView.separated(
primary: false,
restorationId: 'facebookConversationPane',
itemCount: widget.conversation.messages.length,
itemBuilder: (context, index) {
final message = widget.conversation.messages[index];
return Text(
'${message.from}: ${message.message}',
softWrap: true,
);
},
separatorBuilder: (context, index) {
return const Divider(
color: Colors.black,
thickness: 0.2,
);
});
}
}

View file

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:metadata_fetch/metadata_fetch.dart';
import 'package:url_launcher/url_launcher.dart';
class FacebookLinkElementsComponent extends StatefulWidget {
static final _logger = Logger('$FacebookLinkElementsComponent');
final List<Uri> links;
const FacebookLinkElementsComponent({Key? key, required this.links})
: super(key: key);
@override
State<FacebookLinkElementsComponent> createState() =>
_FacebookLinkElementsComponentState();
}
class _FacebookLinkElementsComponentState
extends State<FacebookLinkElementsComponent> {
final previewWidth = 500.0;
final previewHeight = 165.0;
static final _logger = Logger('$_FacebookLinkElementsComponentState');
final _linkPreviewData = <Metadata>[];
@override
void initState() {
super.initState();
makeLinkPreview();
}
Future<void> makeLinkPreview() async {
try {
for (final url in widget.links) {
if (!url.scheme.startsWith('http')) {
_logger.finest('Attempted to create preview from non-HTTP url: $url');
continue;
}
// Makes a call
var response = await http.get(url);
var document = MetadataFetch.responseToDocument(response);
if (document == null) {
_logger.finest(
'Link provided for preview did not return a viable document, may be broken: $url');
continue;
}
var ogData = MetadataParser.openGraph(document);
ogData.url ??= url.toString();
_linkPreviewData.add(ogData);
}
setState(() {});
} catch (e) {
_logger.warning('Error getting preview for ${widget.links.first}');
}
}
@override
Widget build(BuildContext context) {
if (widget.links.isEmpty) {
return const SizedBox(height: 0, width: 0);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Links: ', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(
height: 5,
),
..._linkPreviewData.map((l) => TextButton(
onPressed: () async {
await canLaunch(l.url!)
? await launch(l.url!)
: FacebookLinkElementsComponent._logger
.info('Failed to launch ${l.url}');
},
child: _buildLinkPreview(context, l))),
],
);
}
Widget _buildLinkPreview(BuildContext context, Metadata previewData) {
const bufferWidth = 5.0;
const bufferHeight = 6.0;
if ((previewData.title?.isEmpty ?? true) &&
(previewData.description?.isEmpty ?? true) &&
(previewData.image?.isEmpty ?? true)) {
return Text(previewData.url ?? 'No Link Provided',
maxLines: 5,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontStyle: FontStyle.italic));
}
return Card(
child: SizedBox(
width: previewWidth,
height: previewHeight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Image.network(previewData.image ?? '',
width: previewHeight,
height: previewHeight,
fit: BoxFit.cover),
const SizedBox(width: bufferWidth),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(previewData.title ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: bufferHeight),
Text(
previewData.url ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontStyle: FontStyle.italic),
),
const SizedBox(height: bufferHeight),
Text(
previewData.description ?? '',
maxLines: 3,
overflow: TextOverflow.ellipsis,
)
],
),
),
],
))));
}
}

View file

@ -0,0 +1,68 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_media_attachment.dart';
import 'package:friendica_archive_browser/src/friendica/screens/facebook_media_slideshow_screen.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:provider/provider.dart';
import 'facebook_media_wrapper_component.dart';
class FacebookMediaTimelineComponent extends StatelessWidget {
static const double _maxHeightWidth = 400.0;
final List<FacebookMediaAttachment> mediaAttachments;
const FacebookMediaTimelineComponent(
{Key? key, required this.mediaAttachments})
: super(key: key);
@override
Widget build(BuildContext context) {
if (mediaAttachments.isEmpty) {
return const SizedBox(width: 0, height: 0);
}
final bool isSingle = mediaAttachments.length == 1;
final double singleWidth = MediaQuery.of(context).size.width / 2.0;
final double threeAcrossWidth = MediaQuery.of(context).size.width / 3.0;
final double preferredMultiWidth = min(threeAcrossWidth, _maxHeightWidth);
final pathMapper = Provider.of<PathMappingService>(context);
final settingsController = Provider.of<SettingsController>(context);
return Container(
constraints: const BoxConstraints(
maxHeight: _maxHeightWidth,
),
child: ListView.separated(
// shrinkWrap: true,
// primary: true,
scrollDirection: Axis.horizontal,
itemCount: mediaAttachments.length,
itemBuilder: (context, index) {
return InkWell(
onTap: () async {
Navigator.push(context, MaterialPageRoute(builder: (context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: settingsController),
Provider.value(value: pathMapper)
],
child: FacebookMediaSlideshowScreen(
mediaAttachments: mediaAttachments,
initialIndex: index));
}));
},
child: FacebookMediaWrapperComponent(
mediaAttachment: mediaAttachments[index],
preferredWidth: isSingle ? singleWidth : preferredMultiWidth,
),
);
},
separatorBuilder: (context, index) {
return const SizedBox(width: 10);
}),
);
}
}

View file

@ -0,0 +1,186 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_media_attachment.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/snackbar_status_builder.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
class FacebookMediaWrapperComponent extends StatelessWidget {
static final _logger = Logger('$FacebookMediaWrapperComponent');
static const double _noPreferredValue = -1.0;
final FacebookMediaAttachment mediaAttachment;
final double preferredWidth;
final double preferredHeight;
const FacebookMediaWrapperComponent(
{Key? key,
required this.mediaAttachment,
this.preferredWidth = _noPreferredValue,
this.preferredHeight = _noPreferredValue})
: super(key: key);
@override
Widget build(BuildContext context) {
final settingsController = Provider.of<SettingsController>(context);
final pathMapper = Provider.of<PathMappingService>(context);
final videoPlayerCommand = settingsController.videoPlayerCommand;
final path = mediaAttachment.uri.scheme.startsWith('http')
? mediaAttachment.uri.toString()
: pathMapper.toFullPath(mediaAttachment.uri.path);
final width =
preferredWidth > 0 ? preferredWidth : MediaQuery.of(context).size.width;
final height = preferredHeight > 0
? preferredHeight
: MediaQuery.of(context).size.height;
if (mediaAttachment.estimatedType() ==
FacebookAttachmentMediaType.unknown) {
return Text('Unable to resolve type for ${mediaAttachment.uri.path}');
}
if (mediaAttachment.estimatedType() == FacebookAttachmentMediaType.video) {
final title = "Video (click to play): " + mediaAttachment.title;
final thumbnailImageResult = _uriToImage(
mediaAttachment.thumbnailUri, pathMapper,
imageTypeName: 'thumbnail image');
if (thumbnailImageResult.image != null) {
return _createFinalWidget(
baseContext: context,
imageAndPath: thumbnailImageResult,
width: width,
height: height,
noImageText: 'No Thumbnail',
noImageOnTapText:
'Click to launch video in external player (No Thumbnail)',
onTap: () async =>
await _attemptToPlay(context, videoPlayerCommand, path));
}
return TextButton(
onPressed: () async {
await _attemptToPlay(context, videoPlayerCommand, path);
},
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(mediaAttachment.description)
]),
);
}
if (mediaAttachment.estimatedType() == FacebookAttachmentMediaType.image) {
final imageResult = _uriToImage(mediaAttachment.uri, pathMapper);
if (imageResult.image == null) {
final errorPath = imageResult.path.isNotEmpty
? imageResult.path
: mediaAttachment.uri.toString();
return SizedBox(
width: width * .8,
child: Text('Image could not be loaded: $errorPath', softWrap: true),
);
}
return _createFinalWidget(
baseContext: context,
imageAndPath: imageResult,
width: width,
height: height,
noImageText: 'No Image',
onTap: null);
}
return const Text('Error creating image widget');
}
Future<void> _attemptToPlay(
BuildContext context, String command, String path) async {
_logger.fine('Attempting to launch video with $command for $path');
try {
await Process.run(command, [path]);
} catch (e) {
_logger
.severe('Exception thrown trying to use $command to play $path: $e');
SnackBarStatusBuilder.buildSnackbar(
context, 'Error using $command to play video $path');
}
}
_ImageAndPathResult _uriToImage(Uri uri, PathMappingService mapper,
{String imageTypeName = 'image'}) {
if (uri.toString().startsWith('https://interncache')) {
return _ImageAndPathResult.none();
}
if (uri.scheme.startsWith('http')) {
final networkUrl = uri.toString();
try {
return _ImageAndPathResult(Image.network(networkUrl), networkUrl);
} catch (e) {
_logger.info(
'Error trying to create network $imageTypeName: $networkUrl. $e');
}
return _ImageAndPathResult.none();
}
if (uri.path.endsWith('mp4')) {
return _ImageAndPathResult.none();
}
final fullPath = mapper.toFullPath(uri.toString());
final imageFile = File(fullPath);
if (imageFile.existsSync()) {
return _ImageAndPathResult(Image.file(imageFile), fullPath);
}
return _ImageAndPathResult.none();
}
Widget _createFinalWidget(
{required BuildContext baseContext,
required _ImageAndPathResult imageAndPath,
required double width,
required double height,
String noImageText = 'No Image',
String noImageOnTapText = 'No Image',
required Future<void> Function()? onTap}) {
final noImage = imageAndPath.image == null;
final errorText = onTap != null ? noImageOnTapText : noImageText;
final imageWidget = noImage
? Text(errorText,
style: Theme.of(baseContext)
.textTheme
.bodyText2
?.copyWith(fontWeight: FontWeight.bold))
: SizedBox(
width: width,
height: height,
child:
Image(image: imageAndPath.image!.image, fit: BoxFit.scaleDown),
);
if (onTap == null) {
return imageWidget;
}
return InkWell(onTap: onTap, child: imageWidget);
}
}
class _ImageAndPathResult {
final Image? image;
final String path;
_ImageAndPathResult(this.image, this.path);
_ImageAndPathResult.none()
: image = null,
path = '';
}

View file

@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
class FilterControl<T1, T2> extends StatefulWidget {
final List<T1> allItems;
final List<T1> filteredItems = [];
final bool Function(T1)? imagesOnlyFilterFunction;
final bool Function(T1)? videosOnlyFilterFunction;
final bool Function(T1, String)? textSearchFilterFunction;
final DateTime Function(T1) itemToDateTimeFunction;
final bool Function(T1, DateTime, DateTime) dateRangeFilterFunction;
final T1 Function(T1)? copyPrimary;
final List<T2> Function(T1)? getSecondary;
final bool Function(T2)? secondaryImagesOnlyFilterFunction;
final bool Function(T2)? secondaryVideosOnlyFilterFunction;
final bool Function(T2, String)? secondaryTextSearchFilterFunction;
final DateTime Function(T2)? secondaryItemToDateTimeFunction;
final bool Function(T2, DateTime, DateTime)? secondaryDateRangeFilterFunction;
final Widget Function(BuildContext, List<T1>) builder;
final bool hasSecondaryFunctions;
FilterControl(
{Key? key,
required this.allItems,
this.imagesOnlyFilterFunction,
this.textSearchFilterFunction,
this.videosOnlyFilterFunction,
required this.itemToDateTimeFunction,
required this.dateRangeFilterFunction,
required this.builder,
this.copyPrimary,
this.getSecondary,
this.secondaryImagesOnlyFilterFunction,
this.secondaryVideosOnlyFilterFunction,
this.secondaryTextSearchFilterFunction,
this.secondaryItemToDateTimeFunction,
this.secondaryDateRangeFilterFunction})
: hasSecondaryFunctions = getSecondary != null ||
secondaryDateRangeFilterFunction != null ||
secondaryImagesOnlyFilterFunction != null ||
secondaryItemToDateTimeFunction != null ||
secondaryTextSearchFilterFunction != null ||
secondaryVideosOnlyFilterFunction != null,
super(key: key) {
if (hasSecondaryFunctions && getSecondary == null) {
throw Exception(
'Secondary filtering functions defined but "getSecondary" method is not.');
}
if (hasSecondaryFunctions && copyPrimary == null) {
throw Exception(
'Primary copy method not defined even though secondary filtering is occurring.');
}
}
@override
State<FilterControl<T1, T2>> createState() => _FilterControlState<T1, T2>();
}
class _FilterControlState<T1, T2> extends State<FilterControl<T1, T2>> {
static final _logger = Logger('$_FilterControlState');
bool _withImagesOnly = false;
bool _withVideosOnly = false;
bool _withDateFilter = false;
bool _withTextFilter = false;
DateTime _filterStartDate = DateTime.now();
DateTime _filterEndDate = DateTime.now();
DateTime _earliestPossibleDate = DateTime.now();
DateTime _latestPossibleDate = DateTime.now();
final _searchText = TextEditingController();
bool _showSearch = false;
@override
void initState() {
_logger.fine('Init state');
final times =
widget.allItems.map((e) => widget.itemToDateTimeFunction(e)).toList();
if (times.isNotEmpty) {
times.sort((t1, t2) => t1.compareTo(t2));
_earliestPossibleDate = times.first;
_latestPossibleDate = times.last;
_filterStartDate = _earliestPossibleDate;
_filterEndDate = _latestPossibleDate;
}
_searchText.text = '';
_searchText.addListener(_updateFilter);
_updateFilter();
super.initState();
}
void _updateFilter() {
_logger.fine('Update Filter');
final bool testForText = _withTextFilter && _searchText.text.length > 2;
final String searchTerm = _searchText.text.trim();
final times =
widget.allItems.map((e) => widget.itemToDateTimeFunction(e)).toList();
if (times.isNotEmpty) {
times.sort((t1, t2) => t1.compareTo(t2));
_earliestPossibleDate = times.first;
_latestPossibleDate = times.last;
}
var currentFilteredItems = widget.allItems.where((p) {
bool passes = true;
if (_withImagesOnly && widget.imagesOnlyFilterFunction != null) {
passes &= widget.imagesOnlyFilterFunction!(p);
}
if (passes &&
_withVideosOnly &&
widget.videosOnlyFilterFunction != null) {
passes &= widget.videosOnlyFilterFunction!(p);
}
if (passes && _withDateFilter) {
passes &=
widget.dateRangeFilterFunction(p, _filterStartDate, _filterEndDate);
}
if (passes && testForText && widget.textSearchFilterFunction != null) {
passes &= widget.textSearchFilterFunction!(p, searchTerm);
}
return passes;
});
if (widget.hasSecondaryFunctions) {
final finalFilteredItems = <T1>[];
for (var item in currentFilteredItems) {
final subList = widget.getSecondary!(item);
final filteredSubList = subList.where((i) {
bool passes = true;
if (_withImagesOnly &&
widget.secondaryImagesOnlyFilterFunction != null) {
passes &= widget.secondaryImagesOnlyFilterFunction!(i);
}
if (passes &&
_withVideosOnly &&
widget.secondaryVideosOnlyFilterFunction != null) {
passes &= widget.secondaryVideosOnlyFilterFunction!(i);
}
if (passes &&
_withDateFilter &&
widget.secondaryDateRangeFilterFunction != null) {
passes &= widget.secondaryDateRangeFilterFunction!(
i, _filterStartDate, _filterEndDate);
}
if (passes &&
testForText &&
widget.secondaryTextSearchFilterFunction != null) {
passes &= widget.secondaryTextSearchFilterFunction!(i, searchTerm);
}
return passes;
});
if (subList.length != filteredSubList.length) {
final finalItem = widget.copyPrimary!(item);
final finalSublist = widget.getSecondary!(finalItem);
finalSublist.clear();
finalSublist.addAll(filteredSubList);
finalFilteredItems.add(finalItem);
} else {
finalFilteredItems.add(item);
}
}
setState(() {
widget.filteredItems.clear();
widget.filteredItems.addAll(finalFilteredItems);
});
} else {
setState(() {
widget.filteredItems.clear();
widget.filteredItems.addAll(currentFilteredItems);
});
}
}
@override
Widget build(BuildContext context) {
_logger.fine('Redrawing');
_updateFilter();
return Scaffold(
body: Column(children: [
if (_showSearch) ...[
_buildFilterBox(context),
const Divider(),
],
Expanded(child: widget.builder(context, widget.filteredItems)),
]),
floatingActionButton: FloatingActionButton(
heroTag: null,
child: const Icon(Icons.search),
tooltip: 'Toggle filter dialog visibility',
onPressed: () {
setState(() {
_logger.fine('Toggling show search');
_showSearch = !_showSearch;
});
},
),
);
}
Widget _buildFilterBox(BuildContext context) {
return Column(children: [
if (widget.textSearchFilterFunction != null) _buildTextFilter(context),
_buildDateFilter(context),
if (widget.imagesOnlyFilterFunction != null) _buildImagesOnly(context),
if (widget.videosOnlyFilterFunction != null) _buildVideosOnly(context),
_buildStatusLine(),
]);
}
Widget _buildStatusLine() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${widget.filteredItems.length} of ${widget.allItems.length} items visible'),
);
}
Widget _buildVideosOnly(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(children: [
Checkbox(
value: _withVideosOnly,
onChanged: (value) => setState(() {
_withVideosOnly = value ?? false;
_updateFilter();
})),
const SizedBox(width: 1),
const Text('Only with videos'),
]),
);
}
Widget _buildImagesOnly(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(children: [
Checkbox(
value: _withImagesOnly,
onChanged: (value) => setState(() {
_withImagesOnly = value ?? false;
_updateFilter();
})),
const SizedBox(width: 1),
const Text('Only with images'),
]),
);
}
Widget _buildTextFilter(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(children: [
Checkbox(
value: _withTextFilter,
onChanged: (value) => setState(() {
_withTextFilter = value ?? false;
_updateFilter();
})),
const Text(
'Search Text:',
),
const SizedBox(width: 5),
TextField(
enabled: _withTextFilter,
readOnly: !_withTextFilter,
controller: _searchText,
decoration: const InputDecoration(
constraints: BoxConstraints(maxWidth: 500.0),
hintText: 'Limit posts to only those with this exact text',
)),
]),
);
}
Widget _buildDateFilter(BuildContext context) {
final formatter = Provider.of<SettingsController>(context).dateFormatter;
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(children: [
Checkbox(
value: _withDateFilter,
onChanged: (value) => setState(() {
_withDateFilter = value ?? false;
_updateFilter();
})),
const Text(
'Only between dates:',
),
const SizedBox(width: 5),
SizedBox(
width: 150,
child: TextField(
enabled: _withDateFilter,
readOnly: true,
controller: TextEditingController(
text: formatter.format(_filterStartDate)),
textAlign: TextAlign.center,
decoration: const InputDecoration(
hintText: 'Earliest',
),
)),
const SizedBox(width: 5),
ElevatedButton(
onPressed: !_withDateFilter
? null
: () async {
final selectedDate = await showDatePicker(
context: context,
initialDate: _filterStartDate,
firstDate: _earliestPossibleDate,
lastDate: _filterEndDate,
currentDate: DateTime.now(),
helpText: 'Select starting date filter',
);
if (selectedDate != null) {
setState(() {
_filterStartDate = selectedDate;
});
}
},
child: const Text('Set Start')),
const SizedBox(width: 5),
const Text('to'),
const SizedBox(width: 5),
SizedBox(
width: 150,
child: TextField(
enabled: _withDateFilter,
readOnly: true,
controller:
TextEditingController(text: formatter.format(_filterEndDate)),
textAlign: TextAlign.center,
)),
const SizedBox(width: 5),
ElevatedButton(
onPressed: !_withDateFilter
? null
: () async {
final selectedDate = await showDatePicker(
context: context,
initialDate: _filterEndDate,
firstDate: _filterStartDate,
lastDate: _latestPossibleDate,
currentDate: DateTime.now(),
helpText: 'Select ending date filter',
);
if (selectedDate != null) {
setState(() {
_filterEndDate = selectedDate;
});
}
},
child: const Text('Set Stop')),
const SizedBox(width: 5),
ElevatedButton(
onPressed: !_withDateFilter
? null
: () {
setState(() {
_filterStartDate = _earliestPossibleDate;
_filterEndDate = _latestPossibleDate;
});
},
child: const Text('Reset')),
]),
);
}
}

View file

@ -0,0 +1,15 @@
import 'dart:ui';
import 'package:friendica_archive_browser/src/friendica/models/facebook_post.dart';
import 'package:latlng/latlng.dart';
import 'package:map/map.dart';
import 'marker_data.dart';
extension GeoSpatialPostExtensions on FacebookPost {
MarkerData toMarkerData(MapTransformer transformer, Color color) {
final latLon = LatLng(locationData.latitude, locationData.longitude);
final offset = transformer.fromLatLngToXYCoords(latLon);
return MarkerData(this, offset, color);
}
}

View file

@ -0,0 +1,82 @@
import 'dart:math';
import 'dart:ui';
import 'package:latlng/latlng.dart';
import 'package:map/map.dart';
class MapBounds {
static final globe = MapBounds(
upperLeft: LatLng(85.0, -180.0),
lowerRight: LatLng(-85, 180.0),
idealCenterPoint: LatLng(0.0, 0.0));
final LatLng upperLeft;
final LatLng lowerRight;
final LatLng idealCenterPoint;
MapBounds(
{required this.upperLeft,
required this.lowerRight,
required this.idealCenterPoint});
static MapBounds computed(MapTransformer transformer) {
final mapSize = transformer.constraints.biggest;
final upperLeft = transformer.fromXYCoordsToLatLng(Offset.zero);
final lowerRight =
transformer.fromXYCoordsToLatLng(Offset(mapSize.width, mapSize.height));
final idealLeftLongitude = max(-180.0, upperLeft.longitude);
final idealRightLongitude = min(180.0, lowerRight.longitude);
final idealUpperLatitude = min(85.0, upperLeft.latitude);
final idealLowerLatitude = max(-85.0, lowerRight.latitude);
final idealCenterLatLon = LatLng(
(idealUpperLatitude + idealLowerLatitude) / 2.0,
(idealRightLongitude + idealLeftLongitude) / 2.0);
return MapBounds(
upperLeft: upperLeft,
lowerRight: lowerRight,
idealCenterPoint: idealCenterLatLon);
}
bool pointInBounds(double latitude, double longitude) {
if (latitude > upperLeft.latitude || latitude < lowerRight.latitude) {
return false;
}
if (longitude < upperLeft.longitude || longitude > lowerRight.longitude) {
return false;
}
return true;
}
bool isOverflowedUpperLeft() {
if (upperLeft.longitude < -180.0) {
return true;
}
if (upperLeft.latitude > 85.0) {
return true;
}
return false;
}
bool isOverflowedLowerRight() {
if (lowerRight.latitude < -85.0) {
return true;
}
if (lowerRight.longitude > 180.0) {
return true;
}
return false;
}
bool isOverflowed() => isOverflowedUpperLeft() || isOverflowedLowerRight();
@override
String toString() {
return 'UpperLeft: (${upperLeft.latitude},${upperLeft.longitude}); LowerRight: (${lowerRight.latitude},${lowerRight.longitude}); overflowed: ${isOverflowed()}';
}
}

View file

@ -0,0 +1,33 @@
import 'dart:ui';
import 'package:friendica_archive_browser/src/friendica/models/facebook_post.dart';
class MarkerData {
final List<FacebookPost> posts;
final Offset pos;
final Color color;
MarkerData(post, this.pos, this.color) : posts = [post];
String toLabel() {
if (posts.isEmpty) {
return 'No Posts';
}
if (posts.length == 1) {
return '1 Post';
}
return '${posts.length} posts';
}
String subLabel() {
final mediaCount = posts
.map((p) => p.mediaAttachments.length)
.reduce((value, element) => value + element);
if (mediaCount == 0) {
return '';
}
return '$mediaCount images/videos';
}
}

View file

@ -0,0 +1,98 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_location_data.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_post.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_timeline_type.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/clipboard_helper.dart';
import 'package:provider/provider.dart';
import 'facebook_link_elements_component.dart';
import 'facebook_media_timeline_component.dart';
class PostCard extends StatelessWidget {
final FacebookPost post;
const PostCard({Key? key, required this.post}) : super(key: key);
@override
Widget build(BuildContext context) {
if (Scrollable.recommendDeferredLoadingForContext(context)) {
return const SizedBox();
}
const double spacingHeight = 5.0;
final formatter =
Provider.of<SettingsController>(context).dateTimeFormatter;
final mapper = Provider.of<PathMappingService>(context);
final title = post.title.isEmpty ? 'Post' : post.title;
final dateStamp = ' At ' +
formatter.format(
DateTime.fromMillisecondsSinceEpoch(post.creationTimestamp * 1000)
.toLocal());
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
direction: Axis.horizontal,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
Text(dateStamp,
style: const TextStyle(
fontStyle: FontStyle.italic,
)),
if (post.timelineType != FacebookTimelineType.active)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Tooltip(
message:
'Post is in ${post.timelineType == FacebookTimelineType.trash ? 'Trash' : 'Archive'}',
child: Icon(
post.timelineType == FacebookTimelineType.trash
? Icons.delete_outline
: Icons.archive_outlined,
color: Theme.of(context).disabledColor,
)),
),
Tooltip(
message: 'Copy text version of post to clipboard',
child: IconButton(
onPressed: () async => await copyToClipboard(
context: context,
text: post.toHumanString(mapper, formatter),
snackbarMessage: 'Copied Post to clipboard'),
icon: const Icon(Icons.copy)),
),
]),
if (post.post.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
Text(post.post)
],
if (post.locationData.hasData())
post.locationData.toWidget(spacingHeight),
if (post.links.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
FacebookLinkElementsComponent(links: post.links)
],
if (post.mediaAttachments.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
FacebookMediaTimelineComponent(
mediaAttachments: post.mediaAttachments)
]
],
),
);
}
}

View file

@ -0,0 +1,63 @@
import 'package:logging/logging.dart';
import 'facebook_comment.dart';
import 'facebook_media_attachment.dart';
import 'model_utils.dart';
class FacebookAlbum {
static final _logger = Logger('$FacebookAlbum');
final String name;
final String description;
final int lastModifiedTimestamp;
final FacebookMediaAttachment coverPhoto;
final List<FacebookMediaAttachment> photos;
final List<FacebookComment> comments;
FacebookAlbum(
{required this.name,
required this.description,
required this.lastModifiedTimestamp,
required this.coverPhoto,
required this.photos,
required this.comments});
static FacebookAlbum fromJson(Map<String, dynamic> json) {
final knownAlbumKeys = [
'name',
'photos',
'cover_photo',
'last_modified_timestamp',
'comments',
'description'
];
logAdditionalKeys(knownAlbumKeys, json.keys, _logger, Level.WARNING,
'Unknown top level album keys');
String name = json['name'] ?? '';
String description = json['description'] ?? '';
int lastModifiedTimestamp = json['last_modified_timestamp'] ?? 0;
FacebookMediaAttachment coverPhoto = json.containsKey('cover_photo')
? FacebookMediaAttachment.fromFacebookJson(json['cover_photo'])
: FacebookMediaAttachment.blank();
final photos = <FacebookMediaAttachment>[];
for (Map<String, dynamic> photoJson in json['photos'] ?? []) {
photos.add(FacebookMediaAttachment.fromFacebookJson(photoJson));
}
final comments = <FacebookComment>[];
for (Map<String, dynamic> commentsJson in json['comments'] ?? []) {
comments.add(FacebookComment.fromInnerCommentJson(commentsJson));
}
return FacebookAlbum(
name: name,
description: description,
lastModifiedTimestamp: lastModifiedTimestamp,
coverPhoto: coverPhoto,
photos: photos,
comments: comments);
}
}

View file

@ -0,0 +1,209 @@
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'facebook_media_attachment.dart';
import 'model_utils.dart';
class FacebookComment {
static final _logger = Logger('$FacebookComment');
final int creationTimestamp;
final String author;
final String comment;
final String group;
final String title;
final List<FacebookMediaAttachment> mediaAttachments;
final List<Uri> links;
FacebookComment(
{this.creationTimestamp = 0,
this.author = '',
this.comment = '',
this.group = '',
this.title = '',
List<FacebookMediaAttachment>? mediaAttachments,
List<Uri>? links})
: mediaAttachments = mediaAttachments ?? <FacebookMediaAttachment>[],
links = links ?? <Uri>[];
FacebookComment.randomBuilt()
: creationTimestamp = DateTime.now().millisecondsSinceEpoch,
author = 'Random Author ${randomId()}',
comment = 'Random comment text ${randomId()}',
group = 'Random Group ${randomId()}',
title = 'Random title ${randomId()}',
links = [
Uri.parse('http://localhost/${randomId()}'),
Uri.parse('http://localhost/${randomId()}')
],
mediaAttachments = [
FacebookMediaAttachment.randomBuilt(),
FacebookMediaAttachment.randomBuilt()
];
FacebookComment copy(
{int? creationTimestamp,
String? author,
String? comment,
String? group,
String? title,
List<FacebookMediaAttachment>? mediaAttachments,
List<Uri>? links}) {
return FacebookComment(
creationTimestamp: creationTimestamp ?? this.creationTimestamp,
author: author ?? this.author,
comment: comment ?? this.comment,
group: group ?? this.group,
title: title ?? this.title,
mediaAttachments: mediaAttachments ?? this.mediaAttachments,
links: links ?? this.links);
}
@override
String toString() {
return 'FacebookPost{creationTimestamp: $creationTimestamp, comment: $comment, author, $author, group: $group, title: $title, mediaAttachments: $mediaAttachments, links: $links}';
}
String toHumanString(PathMappingService mapper, DateFormat formatter) {
final creationDateString = formatter.format(
DateTime.fromMillisecondsSinceEpoch(creationTimestamp * 1000)
.toLocal());
return [
'Title: $title',
'Creation At: $creationDateString',
if (group.isNotEmpty) 'Group: $group',
'Text:',
comment,
'',
if (links.isNotEmpty) 'Links:',
...links.map((e) => e.toString()),
'',
if (mediaAttachments.isNotEmpty) 'Photos and Videos:',
...mediaAttachments.map((e) => e.toHumanString(mapper)),
].join('\n');
}
FacebookComment.fromJson(Map<String, dynamic> json)
: creationTimestamp = json['creationTimeStamp'] ?? 0,
author = json['author'] ?? '',
comment = json['comment'] ?? '',
group = json['group'] ?? '',
title = json['title'] ?? '',
mediaAttachments = (json['mediaAttachments'] as List<dynamic>? ?? [])
.map((j) => FacebookMediaAttachment.fromJson(j))
.toList(),
links = (json['links'] as List<dynamic>? ?? [])
.map((j) => Uri.parse(j))
.toList();
Map<String, dynamic> toJson() => {
'creationTimestamp': creationTimestamp,
'author': author,
'comment': comment,
'group': group,
'title': title,
'mediaAttachments': mediaAttachments.map((m) => m.toJson()).toList(),
'links': links.map((e) => e.path).toList(),
};
bool hasImages() => mediaAttachments
.where((element) =>
element.estimatedType() == FacebookAttachmentMediaType.image)
.isNotEmpty;
bool hasVideos() => mediaAttachments
.where((element) =>
element.estimatedType() == FacebookAttachmentMediaType.video)
.isNotEmpty;
static FacebookComment fromInnerCommentJson(
Map<String, dynamic> commentSubData) {
final knownCommentKeys = ['comment', 'timestamp', 'group', 'author'];
if (_logger.isLoggable(Level.WARNING)) {
logAdditionalKeys(knownCommentKeys, commentSubData.keys, _logger,
Level.WARNING, 'Unknown comment level comment keys');
}
final comment = commentSubData['comment'] ?? '';
final group = commentSubData['group'] ?? '';
final author = commentSubData['author'] ?? '';
final timestamp = commentSubData['timestamp'] ?? 0;
return FacebookComment(
creationTimestamp: timestamp,
author: author,
group: group,
comment: comment,
);
}
static FacebookComment fromFacebookJson(Map<String, dynamic> json) {
final knownTopLevelKeys = ['timestamp', 'data', 'title', 'attachments'];
final knownExternalContextKeys = ['external_context', 'media', 'name'];
int timestamp = json['timestamp'] ?? 0;
logAdditionalKeys(knownTopLevelKeys, json.keys, _logger, Level.WARNING,
'Unknown top level comment keys');
FacebookComment basicCommentData = FacebookComment();
if (json.containsKey('data')) {
final data = json['data'];
for (var dataItem in data) {
if (dataItem.containsKey('comment')) {
basicCommentData =
FacebookComment.fromInnerCommentJson(dataItem['comment']);
} else {
_logger.warning(
"No comment or update key sequence in post @$timestamp: ${dataItem.keys}");
}
}
}
final String title = json['title'] ?? '';
final links = <Uri>[];
final mediaAttachments = <FacebookMediaAttachment>[];
if (json.containsKey('attachments')) {
for (Map<String, dynamic> attachment in json['attachments']) {
if (!attachment.containsKey('data')) {
continue;
}
for (var dataItem in attachment['data']) {
if (_logger.isLoggable(Level.WARNING)) {
logAdditionalKeys(
knownExternalContextKeys,
dataItem.keys,
_logger,
Level.WARNING,
'Unknown comment external context key level keys in attachment data');
}
if (dataItem.containsKey('external_context')) {
final String linkText = dataItem['external_context']['url'] ?? '';
if (linkText.isNotEmpty) {
links.add(Uri.parse(linkText));
}
} else if (dataItem.containsKey('media')) {
mediaAttachments.add(
FacebookMediaAttachment.fromFacebookJson(dataItem['media']));
}
}
}
}
return FacebookComment(
creationTimestamp: timestamp,
author: basicCommentData.author,
comment: basicCommentData.comment,
group: basicCommentData.group,
title: title,
links: links,
mediaAttachments: mediaAttachments);
}
}

View file

@ -0,0 +1,92 @@
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'facebook_location_data.dart';
import 'model_utils.dart';
enum FacebookEventStatus {
declined,
interested,
invited,
joined,
owner,
unknown,
}
class FacebookEvent {
static final _logger = Logger('$FacebookEvent');
final String name;
final String description;
final int creationTimestamp;
final int startTimestamp;
final int endTimestamp;
final FacebookLocationData location;
final FacebookEventStatus eventStatus;
FacebookEvent(
{required this.name,
required this.description,
required this.creationTimestamp,
required this.startTimestamp,
required this.endTimestamp,
required this.location,
required this.eventStatus});
@override
String toString() {
return 'FacebookEvent{name: $name, description: $description, creationTimestamp: $creationTimestamp, startTimestamp: $startTimestamp, endTimestamp: $endTimestamp, location: $location, eventStatus: $eventStatus}';
}
String toHumanString(DateFormat formatter) {
final creationDateString = formatter.format(
DateTime.fromMillisecondsSinceEpoch(creationTimestamp * 1000)
.toLocal());
final startTimeString = formatter.format(
DateTime.fromMillisecondsSinceEpoch(startTimestamp * 1000).toLocal());
final endTimeString = formatter.format(
DateTime.fromMillisecondsSinceEpoch(endTimestamp * 1000).toLocal());
return [
if (name.isNotEmpty) 'Name: $name',
if (description.isNotEmpty) 'Description:\n$description',
'Creation At: $creationDateString',
if (startTimestamp != 0) 'Start Time: $startTimeString',
if (endTimestamp != 0) 'End Time: $endTimeString',
'Your Status: $eventStatus',
if (location.hasPosition) location.toHumanString(),
].join('\n');
}
static FacebookEvent fromJson(Map<String, dynamic> json,
{FacebookEventStatus statusType = FacebookEventStatus.unknown}) {
final knownTopLevelKeys = [
'name',
'start_timestamp',
'end_timestamp',
'place',
'description',
'create_timestamp'
];
logAdditionalKeys(knownTopLevelKeys, json.keys, _logger, Level.WARNING,
'Unknown top level event keys');
final name = json['name'] ?? '';
final description = json['description'] ?? '';
final int creationTimestamp = json['create_timestamp'] ?? 0;
final int startTimestamp = json['start_timestamp'] ?? 0;
final int endTimestamp = json['end_timestamp'] ?? 0;
final FacebookLocationData location = json.containsKey('place')
? FacebookLocationData.fromJson(json['place'])
: const FacebookLocationData();
return FacebookEvent(
name: name,
description: description,
creationTimestamp: creationTimestamp,
startTimestamp: startTimestamp,
endTimestamp: endTimestamp,
location: location,
eventStatus: statusType);
}
}

View file

@ -0,0 +1,123 @@
import 'package:logging/logging.dart';
import 'model_utils.dart';
class FacebookFriend {
static final _logger = Logger('$FacebookFriend');
final FriendStatus status;
final String name;
final String contactInfo;
final int friendSinceTimestamp;
final int receivedTimestamp;
final int rejectedTimestamp;
final int removeTimestamp;
final int sentTimestamp;
final bool markedAsSpam;
FacebookFriend(
{this.status = FriendStatus.unknown,
required this.name,
this.contactInfo = '',
this.friendSinceTimestamp = 0,
this.receivedTimestamp = 0,
this.rejectedTimestamp = 0,
this.removeTimestamp = 0,
this.sentTimestamp = 0,
this.markedAsSpam = false});
@override
String toString() {
return 'FacebookFriend{status: $status, name: $name, contactInfo: $contactInfo, friendSinceTimestamp: $friendSinceTimestamp, receivedTimestamp: $receivedTimestamp, rejectedTimestamp: $rejectedTimestamp, removeTimestamp: $removeTimestamp, sentTimestamp: $sentTimestamp, markedAsSpam: $markedAsSpam}';
}
static FacebookFriend fromJson(
Map<String, dynamic> json, FriendStatus status) {
final knownTopLevelKeys = [
'timestamp',
'name',
'contact_info',
'marked_as_spam'
];
int timestamp = json['timestamp'] ?? 0;
final name = json['name'] ?? '';
final contactInfo = json['contact_info'] ?? '';
final markedAsSpam = json['marked_as_spam'] ?? false;
logAdditionalKeys(knownTopLevelKeys, json.keys, _logger, Level.WARNING,
'Unknown top level friend keys');
switch (status) {
case FriendStatus.friends:
return FacebookFriend(
name: name,
status: status,
contactInfo: contactInfo,
markedAsSpam: markedAsSpam,
friendSinceTimestamp: timestamp);
case FriendStatus.requestReceived:
return FacebookFriend(
name: name,
status: status,
contactInfo: contactInfo,
markedAsSpam: markedAsSpam,
receivedTimestamp: timestamp);
case FriendStatus.rejectedRequest:
return FacebookFriend(
name: name,
status: status,
contactInfo: contactInfo,
markedAsSpam: markedAsSpam,
rejectedTimestamp: timestamp);
case FriendStatus.removed:
return FacebookFriend(
name: name,
status: status,
contactInfo: contactInfo,
markedAsSpam: markedAsSpam,
removeTimestamp: timestamp);
case FriendStatus.sentFriendRequest:
return FacebookFriend(
name: name,
status: status,
contactInfo: contactInfo,
markedAsSpam: markedAsSpam,
sentTimestamp: timestamp);
case FriendStatus.unknown:
return FacebookFriend(
name: name,
status: status,
contactInfo: contactInfo,
markedAsSpam: markedAsSpam,
);
}
}
}
enum FriendStatus {
friends,
requestReceived,
rejectedRequest,
removed,
sentFriendRequest,
unknown,
}
extension FriendStatusWriter on FriendStatus {
String name() {
switch (this) {
case FriendStatus.friends:
return "Friends";
case FriendStatus.requestReceived:
return "Requested";
case FriendStatus.rejectedRequest:
return "Rejected";
case FriendStatus.removed:
return "Removed";
case FriendStatus.sentFriendRequest:
return "Sent Request";
case FriendStatus.unknown:
return "Unknown";
}
}
}

View file

@ -0,0 +1,121 @@
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:friendica_archive_browser/src/friendica/components/facebook_link_elements_component.dart';
import 'model_utils.dart';
class FacebookLocationData {
final String name;
final double latitude;
final double longitude;
final double altitude;
final bool hasPosition;
final String address;
final String url;
const FacebookLocationData(
{this.name = '',
this.latitude = 0.0,
this.longitude = 0.0,
this.altitude = 0.0,
this.hasPosition = false,
this.address = '',
this.url = ''});
FacebookLocationData.randomBuilt()
: name = 'Location name ${randomId()}',
latitude = Random().nextDouble(),
longitude = Random().nextDouble(),
altitude = Random().nextDouble(),
hasPosition = true,
address = 'Address ${randomId()}',
url = 'http://localhost/${randomId()}';
@override
String toString() {
return 'FacebookLocationData{name: $name, latitude: $latitude, longitude: $longitude, altitude: $altitude, hasPosition: $hasPosition, address: $address, url: $url}';
}
String toHumanString() {
if (!hasPosition) {
return '';
}
return [
if (name.isNotEmpty) 'Name: $name',
if (address.isNotEmpty) 'Address: $address',
'Latitude: $latitude',
'Longitude: $longitude',
].join('\n');
}
bool hasData() =>
name.isNotEmpty || address.isNotEmpty || url.isNotEmpty || hasPosition;
static FacebookLocationData fromJson(Map<String, dynamic> json) {
final name = json['name'] ?? '';
final address = json['address'] ?? '';
final url = json['url'] ?? '';
var latitude = 0.0;
var longitude = 0.0;
var altitude = 0.0;
var hasPosition = json.containsKey('coordinate');
if (hasPosition) {
final position = json['coordinate'];
latitude = position['latitude'] ?? 0.0;
longitude = position['longitude'] ?? 0.0;
altitude = position['altitude'] ?? 0.0;
}
return FacebookLocationData(
name: name,
address: address,
url: url,
hasPosition: hasPosition,
latitude: latitude,
longitude: longitude,
altitude: altitude);
}
}
extension WidgetExtensions on FacebookLocationData {
Widget toWidget(double spacingHeight) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'At: ',
style: TextStyle(fontWeight: FontWeight.bold),
),
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (name.isNotEmpty) ...[Text(name)],
if (address.isNotEmpty) ...[
SizedBox(height: spacingHeight),
Text(address)
],
if (name.isEmpty && hasPosition) ...[
SizedBox(height: spacingHeight),
Text('Latitude: $latitude, Longitude: $longitude')
],
if (url.isNotEmpty) ...[
SizedBox(height: spacingHeight),
FacebookLinkElementsComponent(
links: [Uri.parse(url)],
),
],
],
),
]);
}
}

View file

@ -0,0 +1,170 @@
import 'dart:io';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:logging/logging.dart';
import 'facebook_comment.dart';
import 'model_utils.dart';
enum FacebookAttachmentMediaType { unknown, image, video }
class FacebookMediaAttachment {
static final _logger = Logger('$FacebookMediaAttachment');
static final _graphicsExtensions = ['jpg', 'png', 'gif', 'tif'];
static final _movieExtensions = ['avi', 'mp4', 'mpg', 'wmv'];
final Uri uri;
final int creationTimestamp;
final Map<String, String> metadata;
final List<FacebookComment> comments;
final Uri thumbnailUri;
final String title;
final String description;
FacebookMediaAttachment(
{required this.uri,
required this.creationTimestamp,
required this.metadata,
required this.thumbnailUri,
required this.title,
required this.description,
required this.comments});
FacebookMediaAttachment.randomBuilt()
: uri = Uri.parse('http://localhost/${randomId()}'),
creationTimestamp = DateTime.now().millisecondsSinceEpoch,
title = 'Random title ${randomId()}',
thumbnailUri = Uri.parse('${randomId()}.jpg'),
description = 'Random description ${randomId()}',
comments = [
FacebookComment.randomBuilt(),
FacebookComment.randomBuilt()
],
metadata = {'value1': randomId(), 'value2': randomId()};
FacebookMediaAttachment.fromUriOnly(this.uri)
: creationTimestamp = 0,
thumbnailUri = Uri.file(''),
title = '',
description = '',
comments = [],
metadata = {};
FacebookMediaAttachment.fromUriAndTime(this.uri, this.creationTimestamp)
: thumbnailUri = Uri.file(''),
title = '',
description = '',
comments = [],
metadata = {};
FacebookMediaAttachment.blank()
: uri = Uri(),
creationTimestamp = 0,
thumbnailUri = Uri.file(''),
title = '',
description = '',
comments = [],
metadata = {};
@override
String toString() {
return 'FacebookMediaAttachment{uri: $uri, creationTimestamp: $creationTimestamp, metadata: $metadata, title: $title, description: $description, comments: $comments}';
}
String toHumanString(PathMappingService mapper) {
if (uri.scheme.startsWith('http')) {
return uri.toString();
}
return mapper.toFullPath(uri.toString());
}
FacebookAttachmentMediaType estimatedType() => mediaTypeFromString(uri.path);
FacebookMediaAttachment.fromJson(Map<String, dynamic> json)
: uri = Uri.parse(json['uri']),
creationTimestamp = json['creationTimestamp'],
metadata = (json['metadata'] as Map<String, dynamic>? ?? {})
.map((key, value) => MapEntry(key, value.toString())),
comments = (json['comments'] as List<dynamic>? ?? [])
.map((j) => FacebookComment.fromJson(j))
.toList(),
thumbnailUri = Uri.parse(json['thumbnailUri'] ?? ''),
title = json['title'] ?? '',
description = json['description'] ?? '';
Map<String, dynamic> toJson() => {
'uri': uri.toString(),
'creationTimestamp': creationTimestamp,
'metadata': metadata,
'comments': comments.map((c) => c.toJson()).toList(),
'thumbnailUri': thumbnailUri.toString(),
'title': title,
'description': description,
};
static FacebookMediaAttachment fromFacebookJson(Map<String, dynamic> json) {
final Uri uri = Uri.parse(json['uri']);
final int timestamp = json['creation_timestamp'] ?? 0;
final String title = json['title'] ?? '';
final String description = json['description'] ?? '';
final metadata = <String, String>{};
final thumbnailUrlString = json['thumbnail']?['uri'] ?? '';
final thumbnailUri = thumbnailUrlString.startsWith('http')
? Uri.parse(thumbnailUrlString)
: Uri.file(thumbnailUrlString);
json['media_metadata']?.forEach((key, value) {
if (key == 'photo_metadata' || key == 'video_metadata') {
final exifData = value['exif_data'] ?? [];
for (final exif in exifData) {
exif.forEach((k2, v2) => metadata[k2] = v2.toString());
}
} else {
_logger.fine("Unknown media key $key");
metadata[key] = value;
}
});
final comments = <FacebookComment>[];
for (Map<String, dynamic> commentJson in json['comments'] ?? {}) {
final comment = FacebookComment.fromInnerCommentJson(commentJson);
comments.add(comment);
}
return FacebookMediaAttachment(
uri: uri,
creationTimestamp: timestamp,
metadata: metadata,
thumbnailUri: thumbnailUri,
title: title,
comments: comments,
description: description);
}
static FacebookAttachmentMediaType mediaTypeFromString(String path) {
final separator = Platform.isWindows ? '\\' : '/';
final lastSlash = path.lastIndexOf(separator) + 1;
final filename = path.substring(lastSlash);
final lastPeriod = filename.lastIndexOf('.') + 1;
if (lastPeriod == 0) {
return FacebookAttachmentMediaType.unknown;
}
final extension = filename.substring(lastPeriod).toLowerCase();
if (_graphicsExtensions.contains(extension)) {
return FacebookAttachmentMediaType.image;
}
if (_movieExtensions.contains(extension)) {
return FacebookAttachmentMediaType.video;
}
return FacebookAttachmentMediaType.unknown;
}
}

View file

@ -0,0 +1,95 @@
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
import 'facebook_messenger_message.dart';
import 'model_utils.dart';
class Copy<T> {
T? copy() => null;
}
class FacebookMessengerConversation with Copy<FacebookMessengerConversation> {
static final _logger = Logger('$FacebookMessengerConversation');
final String id;
final Set<String> participants;
final List<FacebookMessengerMessage> messages;
final String title;
FacebookMessengerConversation(
{required this.id,
required this.participants,
required this.messages,
required this.title});
factory FacebookMessengerConversation.empty() =>
FacebookMessengerConversation(
id: '', participants: {}, messages: [], title: '');
@override
FacebookMessengerConversation copy() => FacebookMessengerConversation(
id: id,
participants: {...participants},
messages: [...messages],
title: title);
@override
String toString() {
return 'FacebookMessengerConversation{participants: $participants, messages: $messages, title: $title}';
}
int earliestTimestampMS() => messages.isEmpty ? 0 : messages.last.timestampMS;
int latestTimestampMS() => messages.isEmpty ? 0 : messages.first.timestampMS;
bool hasImages() => messages.where((m) => m.hasImages()).isNotEmpty;
bool hasVideos() => messages.where((m) => m.hasVideos()).isNotEmpty;
FacebookMessengerConversation.fromJson(Map<String, dynamic> json)
: id = json['id'] ?? '',
participants = {...json['participants'] as List<dynamic>? ?? []},
messages = (json['messages'] as List<dynamic>? ?? [])
.map((j) => FacebookMessengerMessage.fromJson(j))
.toList(),
title = json['title'] ?? '';
Map<String, dynamic> toJson() => {
'id': id,
'participants': participants.toList(),
'messages': messages.map((m) => m.toJson()).toList(),
'title': title,
};
static FacebookMessengerConversation fromFacebookJson(
Map<String, dynamic> json) {
final id = json['thread_path'] ?? const Uuid().v4();
const knownTopLevelKeys = [
'participants',
'messages',
'title',
'is_still_participant',
'thread_type',
'thread_path',
'magic_words',
];
logAdditionalKeys(knownTopLevelKeys, json.keys, _logger, Level.WARNING,
'Unknown top level conversation keys: ');
final title = json['title'] ?? '';
final participants = <String>{};
final messages = <FacebookMessengerMessage>[];
for (Map<String, dynamic> p in json['messages'] ?? <Map, dynamic>{}) {
messages.add(FacebookMessengerMessage.fromFacebookJson(p));
}
for (Map<String, dynamic> p in json['participants'] ?? <Map, dynamic>{}) {
participants.add(p['name'] ?? '');
}
return FacebookMessengerConversation(
id: id, participants: participants, messages: messages, title: title);
}
}

View file

@ -0,0 +1,179 @@
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'facebook_media_attachment.dart';
import 'model_utils.dart';
class FacebookMessengerMessage {
static final _logger = Logger('$FacebookMessengerMessage');
final String from;
final String message;
final int timestampMS;
final List<FacebookMediaAttachment> media;
final List<FacebookMediaAttachment> stickers;
final List<Uri> links;
final Map<String, String> reactions;
FacebookMessengerMessage(
{required this.from,
required this.message,
required this.timestampMS,
this.media = const [],
this.stickers = const [],
this.links = const [],
this.reactions = const {}});
@override
String toString() {
return 'FacebookMessengerMessage{from: $from, message: $message, timestampMS: $timestampMS, media: $media, stickers: $stickers, links: $links, reactions: $reactions}';
}
String toHumanString(PathMappingService mapper, DateFormat formatter) {
final creationDateString = formatter
.format(DateTime.fromMillisecondsSinceEpoch(timestampMS).toLocal());
return [
'Creation At: $creationDateString',
if (message.isNotEmpty) 'Message: $message',
'',
if (links.isNotEmpty) 'Links:',
...links.map((e) => e.toString()),
'',
if (stickers.isNotEmpty) 'Stickers:',
...stickers.map((e) => e.toHumanString(mapper)),
if (media.isNotEmpty) 'Media:',
...media.map((e) => e.toHumanString(mapper)),
].join('\n');
}
FacebookMessengerMessage copy(
{String? from,
String? message,
int? timestampMS,
List<FacebookMediaAttachment>? media,
List<FacebookMediaAttachment>? stickers,
List<Uri>? links,
Map<String, String>? reactions}) {
return FacebookMessengerMessage(
from: from ?? this.from,
message: message ?? this.message,
timestampMS: timestampMS ?? this.timestampMS,
media: media ?? this.media,
stickers: stickers ?? this.stickers,
links: links ?? this.links,
reactions: reactions ?? this.reactions,
);
}
FacebookMessengerMessage.fromJson(Map<String, dynamic> json)
: from = json['from'] ?? '',
message = json['message'] ?? '',
timestampMS = json['timestampMS'] ?? '',
media = (json['media'] as List<dynamic>? ?? [])
.map((j) => FacebookMediaAttachment.fromJson(j))
.toList(),
stickers = (json['stickers'] as List<dynamic>? ?? [])
.map((j) => FacebookMediaAttachment.fromJson(j))
.toList(),
links = (json['links'] as List<dynamic>? ?? [])
.map((j) => Uri.parse(j))
.toList(),
reactions = (json['reactions'] as Map<String, dynamic>? ?? {})
.map((key, value) => MapEntry(key, value.toString()));
Map<String, dynamic> toJson() => {
'from': from,
'message': message,
'timestampMS': timestampMS,
'media': media.map((m) => m.toJson()).toList(),
'stickers': stickers.map((m) => m.toJson()).toList(),
'links': links.map((e) => e.toString()).toList(),
'reactions': reactions,
};
bool hasImages() => media
.where((element) =>
element.estimatedType() == FacebookAttachmentMediaType.image)
.isNotEmpty;
bool hasVideos() => media
.where((element) =>
element.estimatedType() == FacebookAttachmentMediaType.video)
.isNotEmpty;
static FacebookMessengerMessage fromFacebookJson(Map<String, dynamic> json) {
const knownTopLevelKeys = [
'sender_name',
'timestamp_ms',
'photos',
'reactions',
'gifs',
'content',
'type',
'share',
'videos',
'users',
'sticker',
'files',
'call_duration',
'missed',
'audio_files',
'is_unsent',
'ip',
];
logAdditionalKeys(knownTopLevelKeys, json.keys, _logger, Level.WARNING,
'Unknown top level message keys: ');
final from = json['sender_name'] ?? '';
final timestamp = json['timestamp_ms'] ?? 0;
final message = json['content'] ?? '';
final type = json['Generic'] ?? 'Generic';
if (!['Generic', 'Share'].contains(type)) {
_logger.severe("New message type: $type");
}
final links = <Uri>[];
final String linkString = json['share']?['link'] ?? '';
if (linkString.isNotEmpty) {
links.add(Uri.parse(linkString));
}
// TODO Add Reactions
List<FacebookMediaAttachment> mediaAttachments = [];
for (Map<String, dynamic> photo in json['photos'] ?? []) {
final media = FacebookMediaAttachment.fromFacebookJson(photo);
mediaAttachments.add(media);
}
for (Map<String, dynamic> video in json['videos'] ?? []) {
final media = FacebookMediaAttachment.fromFacebookJson(video);
mediaAttachments.add(media);
}
for (Map<String, dynamic> audioFile in json['audio_files'] ?? []) {
final path = audioFile['uri'];
links.add(Uri.file(path));
}
for (Map<String, dynamic> gif in json['gifs'] ?? []) {
final media = FacebookMediaAttachment.fromFacebookJson(gif);
mediaAttachments.add(media);
}
final stickers = <FacebookMediaAttachment>[];
final String path = json['sticker']?['uri'] ?? '';
if (path.isNotEmpty) {
stickers.add(FacebookMediaAttachment.fromUriOnly(Uri.file(path)));
}
return FacebookMessengerMessage(
from: from,
message: message,
timestampMS: timestamp,
media: mediaAttachments,
stickers: stickers,
links: links);
}
}

View file

@ -0,0 +1,214 @@
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'facebook_location_data.dart';
import 'facebook_media_attachment.dart';
import 'facebook_timeline_type.dart';
import 'model_utils.dart';
class FacebookPost {
static final _logger = Logger('$FacebookPost');
final int creationTimestamp;
final int backdatedTimestamp;
final int modificationTimestamp;
final String post;
final String title;
final List<FacebookMediaAttachment> mediaAttachments;
final FacebookLocationData locationData;
final List<Uri> links;
final FacebookTimelineType timelineType;
FacebookPost(
{this.creationTimestamp = 0,
this.backdatedTimestamp = 0,
this.modificationTimestamp = 0,
this.post = '',
this.title = '',
this.locationData = const FacebookLocationData(),
required this.timelineType,
List<FacebookMediaAttachment>? mediaAttachments,
List<Uri>? links})
: mediaAttachments = mediaAttachments ?? <FacebookMediaAttachment>[],
links = links ?? <Uri>[];
FacebookPost.randomBuilt()
: creationTimestamp = DateTime.now().millisecondsSinceEpoch,
backdatedTimestamp = DateTime.now().millisecondsSinceEpoch,
modificationTimestamp = DateTime.now().millisecondsSinceEpoch,
post = 'Random post text ${randomId()}',
title = 'Random title ${randomId()}',
locationData = FacebookLocationData.randomBuilt(),
timelineType = FacebookTimelineType.active,
links = [
Uri.parse('http://localhost/${randomId()}'),
Uri.parse('http://localhost/${randomId()}')
],
mediaAttachments = [
FacebookMediaAttachment.randomBuilt(),
FacebookMediaAttachment.randomBuilt()
];
FacebookPost copy(
{int? creationTimestamp,
int? backdatedTimestamp,
int? modificationTimestamp,
String? post,
String? title,
FacebookLocationData? locationData,
List<FacebookMediaAttachment>? mediaAttachments,
FacebookTimelineType? timelineType,
List<Uri>? links}) {
return FacebookPost(
creationTimestamp: creationTimestamp ?? this.creationTimestamp,
backdatedTimestamp: backdatedTimestamp ?? this.backdatedTimestamp,
modificationTimestamp:
modificationTimestamp ?? this.modificationTimestamp,
post: post ?? this.post,
title: title ?? this.title,
locationData: locationData ?? this.locationData,
mediaAttachments: mediaAttachments ?? this.mediaAttachments,
timelineType: timelineType ?? this.timelineType,
links: links ?? this.links);
}
@override
String toString() {
return 'FacebookPost{creationTimestamp: $creationTimestamp, modificationTimestamp: $modificationTimestamp, backdatedTimeStamp: $backdatedTimestamp, timelineType: $timelineType, post: $post, title: $title, mediaAttachments: $mediaAttachments, links: $links}';
}
String toHumanString(PathMappingService mapper, DateFormat formatter) {
final creationDateString = formatter.format(
DateTime.fromMillisecondsSinceEpoch(creationTimestamp * 1000)
.toLocal());
return [
'Title: $title',
'Creation At: $creationDateString',
'Text:',
post,
'',
if (links.isNotEmpty) 'Links:',
...links.map((e) => e.toString()),
'',
if (mediaAttachments.isNotEmpty) 'Photos and Videos:',
...mediaAttachments.map((e) => e.toHumanString(mapper)),
if (locationData.hasPosition) locationData.toHumanString(),
].join('\n');
}
bool hasImages() => mediaAttachments
.where((element) =>
element.estimatedType() == FacebookAttachmentMediaType.image)
.isNotEmpty;
bool hasVideos() => mediaAttachments
.where((element) =>
element.estimatedType() == FacebookAttachmentMediaType.video)
.isNotEmpty;
static FacebookPost fromJson(
Map<String, dynamic> json, FacebookTimelineType timelineType) {
final int timestamp = json['timestamp'] ?? 0;
var modificationTimestamp = timestamp;
var backdatedTimestamp = timestamp;
var locationData = const FacebookLocationData();
String post = '';
if (json.containsKey('data')) {
final data = json['data'];
for (var dataItem in data) {
if (dataItem.containsKey('post')) {
post = dataItem['post'];
} else if (dataItem.containsKey('update_timestamp')) {
modificationTimestamp = dataItem['update_timestamp'];
} else if (dataItem.containsKey('backdated_timestamp')) {
backdatedTimestamp = dataItem['backdated_timestamp'];
} else {
_logger.fine(
"No post or update key sequence in post @$timestamp: ${dataItem.keys}");
}
}
}
final String title = json['title'] ?? '';
final links = <Uri>[];
final mediaAttachments = <FacebookMediaAttachment>[];
if (json.containsKey('attachments')) {
for (Map<String, dynamic> attachment in json['attachments']) {
if (!attachment.containsKey('data')) {
continue;
}
for (var dataItem in attachment['data']) {
if (dataItem.containsKey('external_context')) {
final String linkText = dataItem['external_context']['url'] ?? '';
if (linkText.isNotEmpty) {
links.add(Uri.parse(linkText));
}
} else if (dataItem.containsKey('media')) {
mediaAttachments.add(
FacebookMediaAttachment.fromFacebookJson(dataItem['media']));
} else if (dataItem.containsKey('place')) {
locationData = FacebookLocationData.fromJson(dataItem['place']);
} else {
//TODO Add Facebook Post Poll Processing
if (dataItem.containsKey('poll')) continue;
//TODO Add Facebook Post attachment text processing
if (dataItem.containsKey('text')) continue;
//TODO Add Facebook Post external context detailed link processing (not just the URL)
if (dataItem.containsKey('name')) continue;
//TODO Add Facebook Post event processing
if (dataItem.containsKey('event')) continue;
_logger.fine('Unknown post key type: ${dataItem.keys}');
}
}
}
}
late final FacebookLocationData actualLocationData;
if (locationData.hasPosition) {
actualLocationData = locationData;
} else {
final mediaWithPosition = mediaAttachments.where((m) =>
m.metadata.containsKey('latitude') &&
m.metadata.containsKey('longitude'));
if (mediaWithPosition.isNotEmpty) {
final metadata = mediaWithPosition.first.metadata;
final latitude = double.tryParse(metadata['latitude'] ?? '') ?? 0.0;
final longitude = double.tryParse(metadata['longitude'] ?? '') ?? 0.0;
actualLocationData = FacebookLocationData(
latitude: latitude, longitude: longitude, hasPosition: true);
} else {
actualLocationData = locationData;
}
}
final String actualTitle = title.isNotEmpty
? title
: mediaAttachments
.map((m) => m.title)
.firstWhere((t) => t.isNotEmpty, orElse: () => '');
return FacebookPost(
creationTimestamp: timestamp,
modificationTimestamp: modificationTimestamp,
backdatedTimestamp: backdatedTimestamp,
locationData: actualLocationData,
post: post,
title: actualTitle,
links: links,
mediaAttachments: mediaAttachments,
timelineType: timelineType,
);
}
}

View file

@ -0,0 +1,101 @@
import 'package:logging/logging.dart';
import 'model_utils.dart';
class FacebookSavedItem {
static final _logger = Logger('$FacebookSavedItem');
final String externalName;
final int timestamp;
final String title;
final String text;
final Uri uri;
FacebookSavedItem(
{this.externalName = '',
this.timestamp = 0,
this.title = '',
this.text = '',
required this.uri});
FacebookSavedItem copy(
{String? externalName,
int? timestamp,
String? title,
String? text,
Uri? uri}) {
return FacebookSavedItem(
externalName: externalName ?? this.externalName,
timestamp: timestamp ?? this.timestamp,
title: title ?? this.title,
text: text ?? this.text,
uri: uri ?? this.uri,
);
}
static FacebookSavedItem fromFacebookJson(Map<String, dynamic> json) {
final knownTopLevelKeys = ['attachments', 'title', 'timestamp'];
final knownExternalContextKeys = ['name', 'source', 'url'];
int timestamp = json['timestamp'] ?? 0;
logAdditionalKeys(knownTopLevelKeys, json.keys, _logger, Level.WARNING,
'Unknown root key');
final title = json['title'] ?? '';
var name = '';
var linkUri = Uri.parse('');
var externalName = '';
if (json.containsKey('attachments')) {
final attachments = json['attachments'] ?? <Map<String, dynamic>>[];
if (attachments.length > 1) {
_logger.severe(
'Saved item has multiple attachment items, will only use first: ${attachments.length}');
}
var found = false;
for (Map<String, dynamic> attachment in attachments) {
final dataItem = attachment['data'] ?? <Map<String, dynamic>>[];
if (dataItem.length > 1) {
_logger.severe(
'Attachment has multiple data items, will only use first: ${dataItem.length}');
}
for (Map<String, dynamic> externalItem in dataItem) {
logAdditionalKeys(['external_context'], externalItem.keys, _logger,
Level.WARNING, 'Unknown external data item key');
final externalData =
externalItem['external_context'] ?? <String, String>{};
logAdditionalKeys(knownExternalContextKeys, externalData.keys,
_logger, Level.WARNING, 'Unknown external context key');
name = externalData['name'] ?? '';
final source = externalData['source'] ?? '';
final url = externalData['url'] ?? '';
final sourceUri = Uri.parse(source);
final urlUri = Uri.parse(url);
if (sourceUri.scheme.startsWith('http')) {
linkUri = sourceUri;
externalName = url;
} else {
linkUri = urlUri;
externalName = source;
}
found = true;
break;
}
if (found) {
break;
}
}
}
return FacebookSavedItem(
timestamp: timestamp,
externalName: externalName,
title: title,
text: name,
uri: linkUri);
}
}

View file

@ -0,0 +1,5 @@
enum FacebookTimelineType {
active,
archive,
trash,
}

View file

@ -0,0 +1,35 @@
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
void logAdditionalKeys<K>(Iterable<K> expectedSet, Iterable<K> actualSet,
Logger logger, Level level, String label) {
if (!logger.isLoggable(level)) {
return;
}
final extraKeys =
actualSet.where((element) => !expectedSet.contains(element));
for (var k in extraKeys) {
logger.log(level, '$label: $k');
}
}
String randomId() => const Uuid().v4();
bool timestampInRange(int timestampinMS, DateTime start, DateTime stop) {
final startMS = start.millisecondsSinceEpoch;
final stopMS = stop.millisecondsSinceEpoch;
return timestampinMS >= startMS && timestampinMS <= stopMS;
}
bool dateTimeInRange(DateTime timestamp, DateTime start, DateTime stop) {
final timestampMS = timestamp.millisecondsSinceEpoch;
final startMS = start.millisecondsSinceEpoch;
final stopMS = stop.millisecondsSinceEpoch;
return timestampMS >= startMS && timestampMS <= stopMS;
}
RegExp wholeWordRegEx(String word) => RegExp('\\b$word\\b');

View file

@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/comment_card.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_comment.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../../screens/loading_status_screen.dart';
import '../../screens/standin_status_screen.dart';
class FacebookCommentsScreen extends StatelessWidget {
static final _logger = Logger('$FacebookCommentsScreen');
const FacebookCommentsScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final service = Provider.of<FacebookArchiveDataService>(context);
final username = Provider.of<SettingsController>(context).facebookName;
_logger.fine('Build FacebookPostListView');
return FutureBuilder<Result<List<FacebookComment>, ExecError>>(
future: service.getComments(),
builder: (context, snapshot) {
_logger.fine('Future Comment builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading Comments');
}
final commentsResult = snapshot.requireData;
if (commentsResult.isFailure) {
return ErrorScreen(
title: 'Error getting comments', error: commentsResult.error);
}
final comments = commentsResult.value;
if (comments.isEmpty) {
return const StandInStatusScreen(title: 'No comments were found');
}
_logger.fine('Build Comments ListView');
return _FacebookCommentsScreenWidget(
comments: comments, username: username);
});
}
}
class _FacebookCommentsScreenWidget extends StatelessWidget {
static final _logger = Logger('$_FacebookCommentsScreenWidget');
final List<FacebookComment> comments;
final String username;
const _FacebookCommentsScreenWidget(
{Key? key, required this.comments, required this.username})
: super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine('Redrawing');
return FilterControl<FacebookComment, dynamic>(
allItems: comments,
imagesOnlyFilterFunction: (comment) => comment.hasImages(),
videosOnlyFilterFunction: (comment) => comment.hasVideos(),
textSearchFilterFunction: (comment, text) =>
comment.title.contains(text) || comment.comment.contains(text),
itemToDateTimeFunction: (comment) =>
DateTime.fromMillisecondsSinceEpoch(
comment.creationTimestamp * 1000),
dateRangeFilterFunction: (comment, start, stop) =>
timestampInRange(comment.creationTimestamp * 1000, start, stop),
builder: (context, items) {
if (items.isEmpty) {
return const StandInStatusScreen(
title: 'No comments meet filter criteria');
}
return ScrollConfiguration(
behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.separated(
primary: false,
restorationId: 'facebookCommentsListView',
itemCount: items.length,
itemBuilder: (context, index) {
_logger.finer('Rendering FacebookComment List Item');
final comment = items[index];
final newTitle = username.isEmpty
? comment.title
: comment.title
.replaceAll(username, 'You')
.replaceAll(wholeWordRegEx('his'), 'your')
.replaceAll(wholeWordRegEx('her'), 'your');
final cardComment = username.isEmpty
? comment
: comment.copy(title: newTitle);
return CommentCard(comment: cardComment);
},
separatorBuilder: (context, index) {
return const Divider(
color: Colors.black,
thickness: 0.2,
);
}),
);
});
}
}

View file

@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/conversation_message_card.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_messenger_conversation.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_messenger_message.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../screens/loading_status_screen.dart';
import '../../screens/standin_status_screen.dart';
class FacebookConversationScreen extends StatelessWidget {
static final _logger = Logger('$FacebookConversationScreen');
const FacebookConversationScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final service = Provider.of<FacebookArchiveDataService>(context);
_logger.info('Build Facebook Conversation Screen');
return FutureBuilder<
Result<List<FacebookMessengerConversation>, ExecError>>(
future: service.getConvos(),
builder: (context, snapshot) {
_logger.fine('Future Conversation builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
_logger.finer('No data yet, just return status screen');
return const LoadingStatusScreen(
title: 'Loading Conversations',
subTitle:
'This can take several minutes the first time loading the archive.',
);
}
final convoResult = snapshot.requireData;
if (convoResult.isFailure) {
return ErrorScreen(error: convoResult.error);
}
_logger.finer(
'Now have data! ${snapshot.requireData.value.length} conversations');
final conversations = convoResult.value;
if (conversations.isEmpty) {
return const StandInStatusScreen(
title: 'No conversations were found');
}
return _FacebookConversionsFilteredWidget(
conversations: conversations);
});
}
}
class _FacebookConversionsFilteredWidget extends StatelessWidget {
final List<FacebookMessengerConversation> conversations;
const _FacebookConversionsFilteredWidget(
{Key? key, required this.conversations})
: super(key: key);
@override
Widget build(BuildContext context) {
return FilterControl<FacebookMessengerConversation,
FacebookMessengerMessage>(
allItems: conversations,
imagesOnlyFilterFunction: (convo) => convo.hasImages(),
videosOnlyFilterFunction: (convo) => convo.hasVideos(),
textSearchFilterFunction: (convo, text) =>
convo.title.contains(text) ||
convo.messages
.map((e) => e.message)
.where((element) => element.contains(text))
.isNotEmpty,
itemToDateTimeFunction: (convo) =>
DateTime.fromMillisecondsSinceEpoch(convo.latestTimestampMS()),
dateRangeFilterFunction: (convo, start, stop) =>
timestampInRange(convo.earliestTimestampMS(), start, stop) ||
timestampInRange(convo.latestTimestampMS(), start, stop),
getSecondary: (convo) => convo.messages,
copyPrimary: (convo) => convo.copy(),
secondaryItemToDateTimeFunction: (message) =>
DateTime.fromMillisecondsSinceEpoch(message.timestampMS),
secondaryDateRangeFilterFunction: (message, start, stop) =>
timestampInRange(message.timestampMS, start, stop),
secondaryImagesOnlyFilterFunction: (message) =>
message.hasImages() || message.stickers.isNotEmpty,
secondaryVideosOnlyFilterFunction: (message) => message.hasVideos(),
secondaryTextSearchFilterFunction: (message, text) =>
message.message.contains(text),
builder: (context, conversations) {
return _FacebookConversationsScreenWidget(
conversations: conversations);
});
}
}
class _FacebookConversationsScreenWidget extends StatefulWidget {
final List<FacebookMessengerConversation> conversations;
const _FacebookConversationsScreenWidget(
{Key? key, required this.conversations})
: super(key: key);
@override
State<_FacebookConversationsScreenWidget> createState() =>
_FacebookConversationsScreenWidgetState();
}
class _FacebookConversationsScreenWidgetState
extends State<_FacebookConversationsScreenWidget> {
static final _logger = Logger('$_FacebookConversationsScreenWidget');
static final FacebookMessengerConversation noConversationSelected =
FacebookMessengerConversation.empty();
FacebookMessengerConversation _currentConversation = noConversationSelected;
final ItemScrollController _scrollController = ItemScrollController();
_setConversation(int index) {
if (index > widget.conversations.length) {
_logger.severe(
'Requested participants index greater then max: $index > ${widget.conversations.length}');
return;
}
final conversation =
index < 0 ? noConversationSelected : widget.conversations[index];
if (conversation == _currentConversation) {
return;
}
_logger.finer('Jumping to $index');
final scrollToIndex = index > 0 ? index - 1 : 0;
_scrollController.scrollTo(
index: scrollToIndex, duration: const Duration(seconds: 1));
setState(() {
_currentConversation = conversation;
});
}
@override
Widget build(BuildContext context) {
_logger.fine('Build _FacebookConversationsScreenWidget');
if (!widget.conversations.contains(_currentConversation)) {
final selectedIndex = widget.conversations
.indexWhere((c) => c.id == _currentConversation.id);
_setConversation(selectedIndex);
}
return Row(
children: [
SizedBox(
width: 200,
child:
_buildConversationParticipantsList(context, widget.conversations),
),
SizedBox(width: 1, child: Container(color: Colors.grey)),
Expanded(child: _buildConversationPanel(context)),
],
);
}
Widget _buildConversationParticipantsList(
BuildContext context, List<FacebookMessengerConversation> conversations) {
_logger.fine('Build _buildConversationParticipantsList');
final textTheme = Theme.of(context).textTheme;
return ScrollablePositionedList.separated(
itemScrollController: _scrollController,
itemCount: conversations.length,
itemBuilder: (context, index) {
final conversation = conversations[index];
return TextButton(
onPressed: () => _setConversation(index),
style: _currentConversation == conversation
? TextButton.styleFrom(
backgroundColor:
textTheme.bodyText1?.decorationColor ?? Colors.blue)
: null,
child: Align(
alignment: Alignment.centerLeft,
child: Text(conversation.title,
softWrap: true,
textAlign: TextAlign.start,
style: _currentConversation == conversation
? textTheme.bodyText1
: textTheme.bodyText2),
));
},
separatorBuilder: (context, index) {
return const Divider(
color: Colors.black,
thickness: 0.2,
);
});
}
Widget _buildConversationPanel(BuildContext context) {
_logger.fine('Build _buildConversationPanel');
if (_currentConversation == noConversationSelected) {
return const StandInStatusScreen(
title: 'No conversation selected',
subTitle: 'Select a conversation to display here',
);
}
final settings = Provider.of<SettingsController>(context);
final username = settings.facebookName;
return ListView.separated(
primary: false,
restorationId: 'facebookConversationPane',
itemCount: _currentConversation.messages.length,
itemBuilder: (context, index) {
final msg = _currentConversation.messages[index];
return ConversationMessageCard(
message: msg.from == username ? msg.copy(from: 'You') : msg);
},
separatorBuilder: (context, index) {
return const SizedBox(height: 5);
});
}
}

View file

@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/event_card.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_event.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../../screens/loading_status_screen.dart';
import '../../screens/standin_status_screen.dart';
class FacebookEventsScreen extends StatelessWidget {
static final _logger = Logger('$FacebookEventsScreen');
const FacebookEventsScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final service = Provider.of<FacebookArchiveDataService>(context);
_logger.fine('Build FacebookEventsScreen');
return FutureBuilder<Result<List<FacebookEvent>, ExecError>>(
future: service.getEvents(),
builder: (context, snapshot) {
_logger.fine('Future Events builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading events');
}
final eventsResult = snapshot.requireData;
if (eventsResult.isFailure) {
return ErrorScreen(error: eventsResult.error);
}
final events = eventsResult.value;
if (events.isEmpty) {
return const StandInStatusScreen(title: 'No events were found');
}
_logger.fine('Build events ListView');
return _FacebookEventsScreenWidget(events: events);
});
}
}
class _FacebookEventsScreenWidget extends StatelessWidget {
static final _logger = Logger('$_FacebookEventsScreenWidget');
final List<FacebookEvent> events;
const _FacebookEventsScreenWidget({Key? key, required this.events})
: super(key: key);
@override
Widget build(BuildContext context) {
return FilterControl<FacebookEvent, dynamic>(
allItems: events,
textSearchFilterFunction: (event, text) =>
event.name.contains(text) ||
event.description.contains(text) ||
event.location.name.contains(text) ||
event.location.address.contains(text),
itemToDateTimeFunction: (event) {
if (event.endTimestamp == 0) {
return DateTime.fromMillisecondsSinceEpoch(
event.startTimestamp * 1000);
}
return DateTime.fromMillisecondsSinceEpoch(event.endTimestamp * 1000);
},
dateRangeFilterFunction: (event, start, stop) =>
timestampInRange(event.startTimestamp * 1000, start, stop) ||
timestampInRange(event.endTimestamp * 1000, start, stop),
builder: (context, items) {
if (items.isEmpty) {
return const StandInStatusScreen(
title: 'No events meet filter criteria');
}
return ListView.separated(
primary: false,
restorationId: 'facebookEventsListView',
itemCount: items.length,
itemBuilder: (context, index) {
_logger.finer('Rendering Facebook Event List Item');
return EventCard(event: items[index]);
},
separatorBuilder: (context, index) {
return const Divider(
color: Colors.black,
thickness: 0.2,
);
});
});
}
}

View file

@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_friend.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:intl/intl.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/screens/loading_status_screen.dart';
import 'package:friendica_archive_browser/src/screens/standin_status_screen.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
class FacebookFriendsScreen extends StatelessWidget {
static final _logger = Logger('$FacebookFriendsScreen');
const FacebookFriendsScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final service = Provider.of<FacebookArchiveDataService>(context);
final rootPath = Provider.of<SettingsController>(context).rootFolder;
_logger.fine('Build FacebookFriendsScreen');
return FutureBuilder<Result<List<FacebookFriend>, ExecError>>(
future: service.getFriends(),
builder: (context, snapshot) {
_logger.fine('Future Friends builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading Friends');
}
final friendsResult = snapshot.requireData;
if (friendsResult.isFailure) {
return ErrorScreen(
title: 'Error getting friends', error: friendsResult.error);
}
final friends = friendsResult.value;
if (friends.isEmpty) {
return const StandInStatusScreen(title: 'No friends were found');
}
_logger.fine('Build Friends Data Grid View');
return _FacebookFriendsScreenWidget(
friends: friends, rootPath: rootPath);
});
}
}
class _FacebookFriendsScreenWidget extends StatelessWidget {
final List<FacebookFriend> friends;
final String rootPath;
const _FacebookFriendsScreenWidget(
{Key? key, required this.friends, required this.rootPath})
: super(key: key);
@override
Widget build(BuildContext context) {
final formatter = Provider.of<SettingsController>(context).dateFormatter;
final headerStyle = Theme.of(context)
.textTheme
.bodyText1
?.copyWith(fontWeight: FontWeight.bold);
const nameSize = 250.0;
const statusSize = 100.0;
const dateSize = 150.0;
return ListView.separated(
restorationId: 'friendListView',
itemCount: friends.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
SizedBox(
width: nameSize, child: Text('Title', style: headerStyle)),
SizedBox(
width: statusSize,
child: Text('Status', style: headerStyle)),
SizedBox(
width: dateSize,
child: Text('Friends Since', style: headerStyle)),
],
),
);
}
final friend = friends[index - 1];
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
SizedBox(width: nameSize, child: SelectableText(friend.name)),
SizedBox(width: statusSize, child: Text(friend.status.name())),
SizedBox(
width: dateSize,
child:
Text(_dateText(friend.friendSinceTimestamp, formatter))),
],
),
);
},
separatorBuilder: (context, index) {
return Divider(
color: Colors.black,
thickness: index == 0 ? 1.0 : 0.2,
);
},
);
}
String _dateText(int timestamp, DateFormat formatter) => timestamp == 0
? 'Not Available'
: formatter.format(DateTime.fromMillisecondsSinceEpoch(timestamp * 1000));
}

View file

@ -0,0 +1,386 @@
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/geo/geo_extensions.dart';
import 'package:friendica_archive_browser/src/friendica/components/geo/map_bounds.dart';
import 'package:friendica_archive_browser/src/friendica/components/geo/marker_data.dart';
import 'package:friendica_archive_browser/src/friendica/components/post_card.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_post.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:intl/intl.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/screens/loading_status_screen.dart';
import 'package:friendica_archive_browser/src/screens/standin_status_screen.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:friendica_archive_browser/src/utils/temp_file_builder.dart';
import 'package:latlng/latlng.dart';
import 'package:logging/logging.dart';
import 'package:map/map.dart';
import 'package:multi_split_view/multi_split_view.dart';
import 'package:network_to_file_image/network_to_file_image.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
class FacebookGeospatialViewScreen extends StatelessWidget {
static final _logger = Logger('$FacebookGeospatialViewScreen');
const FacebookGeospatialViewScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
_logger.info('Build FacebookGeospatialViewScreen');
final service = Provider.of<FacebookArchiveDataService>(context);
final username = Provider.of<SettingsController>(context).facebookName;
return FutureBuilder<Result<List<FacebookPost>, ExecError>>(
future: service.getPosts(),
builder: (context, snapshot) {
_logger.info('FacebookGeospatialViewScreen Future builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading posts');
}
final postsResult = snapshot.requireData;
if (postsResult.isFailure) {
return ErrorScreen(
title: 'Error getting posts', error: postsResult.error);
}
final allPosts = postsResult.value;
final filteredPosts =
allPosts.where((p) => p.locationData.hasPosition);
final posts = username.isEmpty
? filteredPosts.toList()
: filteredPosts.map((p) {
var newTitle = p.title;
if (p.title == username) {
newTitle = 'You posted';
} else {
newTitle = p.title
.replaceAll(username, 'You')
.replaceAll(wholeWordRegEx('his'), 'your')
.replaceAll(wholeWordRegEx('her'), 'your');
}
if (newTitle == p.title) {
return p;
} else {
return p.copy(title: newTitle);
}
}).toList();
if (posts.isEmpty) {
return const StandInStatusScreen(title: 'No posts were found');
}
_logger.fine('Build Posts ListView');
return GeospatialView(posts: posts);
});
}
}
class GeospatialView extends StatefulWidget {
final List<FacebookPost> posts;
const GeospatialView({Key? key, required this.posts}) : super(key: key);
@override
_GeospatialViewState createState() => _GeospatialViewState();
}
class _GeospatialViewState extends State<GeospatialView> {
static final _logger = Logger('$_GeospatialViewState');
static const billboardXSize = 150.0;
static const billboardYSize = 60.0;
static const maxZoom = 19.957;
static const minZoom = 2.0;
MapBounds bounds = MapBounds.globe;
final controller = MapController(
location: LatLng(0.0, 0.0),
zoom: 3,
);
Offset? dragStart;
final postsInList = <FacebookPost>[];
final postsInView = <FacebookPost>[];
double scaleStart = 1.0;
@override
void initState() {
_logger.finer('_GeospatialViewState initState');
double latitudeSum = 0.0;
double longitudeSum = 0.0;
for (final p in widget.posts) {
latitudeSum += p.locationData.latitude;
longitudeSum += p.locationData.longitude;
}
double averageLatitude = latitudeSum / widget.posts.length.toDouble();
double averageLongitude = longitudeSum / widget.posts.length.toDouble();
controller.center = LatLng(averageLatitude, averageLongitude);
super.initState();
}
void _onDoubleTap() {
controller.zoom += 0.5;
setState(() {});
}
void _updatePostsInBoundsFilter() {
postsInView.clear();
postsInView.addAll(widget.posts.where((p) => bounds.pointInBounds(
p.locationData.latitude, p.locationData.longitude)));
_logger.finest(() => 'Posts in view? ${postsInView.length}');
}
void _onScaleStart(ScaleStartDetails details) {
_logger.finest('Drag update');
dragStart = details.focalPoint;
scaleStart = 1.0;
}
void _onScaleUpdate(ScaleUpdateDetails details, MapTransformer transformer) {
_logger.finest('_onScaleUpdate');
final now = details.focalPoint;
final scaleDiff = details.scale - scaleStart;
scaleStart = details.scale;
if (scaleDiff > 0) {
_tryZoom(controller.zoom + 0.02, transformer);
} else if (scaleDiff < 0) {
_tryZoom(controller.zoom - 0.02, transformer);
} else {
final diff = now - dragStart!;
dragStart = now;
controller.drag(diff.dx, diff.dy);
_logger.finest('Dragged map by: ${diff.dx}, ${diff.dy}');
if (MapBounds.computed(transformer).isOverflowed()) {
controller.drag(-diff.dx, -diff.dy);
}
}
setState(() {});
}
void _tryZoom(double newZoom, MapTransformer transformer) {
final originalZoom = controller.zoom;
final tryZoomValue = max(minZoom, min(maxZoom, newZoom));
controller.zoom = tryZoomValue;
if (MapBounds.computed(transformer).isOverflowed()) {
_logger.finest(
() => 'This zoom overflowed map so setting back: ${controller.zoom}');
controller.zoom = originalZoom;
} else {
_logger.finest(() => 'New zoom: ${controller.zoom}');
}
}
void _fixOutOfBounds(MapTransformer transformer, {double increment = 0.5}) {
_logger.finest(
'Map somehow out of bounds (maybe window enlargement), attempting to correct by zooming in');
var overflowed = true;
while (overflowed && controller.zoom < (maxZoom - increment)) {
controller.zoom += increment;
bounds = MapBounds.computed(transformer);
overflowed = bounds.isOverflowed();
}
}
@override
Widget build(BuildContext context) {
_logger.finer('Call Geospatial builder');
final formatter =
Provider.of<SettingsController>(context).dateTimeFormatter;
final mapper = Provider.of<PathMappingService>(context);
_updatePostsInBoundsFilter();
final map = _buildMap(context, formatter, mapper);
final postList = _buildPostList(context, formatter, mapper);
final panel = MultiSplitView(
axis: Axis.vertical,
children: [
map,
postList,
],
initialWeights: const [0.3],
minimalWeight: 0.2,
);
return MultiSplitViewTheme(
child: panel,
data: MultiSplitViewThemeData(
dividerPainter: DividerPainters.grooved1(
size: 50,
highlightedSize: 75,
color: Colors.indigo[100]!,
highlightedColor: Colors.indigo[900]!)));
}
Widget _buildPostList(
BuildContext context, DateFormat formatter, PathMappingService mapper) {
_logger.finest(() => 'Building PostList with ${postsInList.length} items');
if (postsInList.isEmpty) {
return const StandInStatusScreen(
title: 'No Selected Posts',
subTitle:
'Click on summary bubbles to select posts\n(and right click on map to clear selection)',
);
}
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.separated(
itemBuilder: (context, index) => PostCard(post: postsInList[index]),
separatorBuilder: (context, index) => const Divider(height: 1),
itemCount: postsInList.length),
);
}
Widget _buildMap(
BuildContext context, DateFormat formatter, PathMappingService mapper) {
final settings = Provider.of<SettingsController>(context);
final shouldDebugCache =
_logger.level <= Level.FINEST; // compare to logger level
return MapLayoutBuilder(
controller: controller,
builder: (context, transformer) {
_logger.finer('Call MapLayoutBuilder');
bounds = MapBounds.computed(transformer);
if (bounds.isOverflowed()) {
_fixOutOfBounds(transformer);
}
_updatePostsInBoundsFilter();
final markerData =
postsInView.map((p) => p.toMarkerData(transformer, Colors.blue));
final collapsedMarkerData = <MarkerData>[];
_logger.finest(() =>
'Markers in view (of ${widget.posts.length}): ${markerData.length}');
for (final data in markerData) {
if (collapsedMarkerData.isEmpty) {
collapsedMarkerData.add(data);
continue;
}
MarkerData? includedMarker;
for (final cd in collapsedMarkerData) {
final dx = (cd.pos.dx - data.pos.dx).abs();
final dy = (cd.pos.dy - data.pos.dy).abs();
if (dx <= billboardXSize && dy <= billboardYSize) {
includedMarker = cd;
break;
}
}
if (includedMarker != null) {
includedMarker.posts.addAll(data.posts);
} else {
collapsedMarkerData.add(data);
}
}
final markerWidgets = collapsedMarkerData
.map((m) => _buildMarkerWidget(m, formatter, mapper));
return GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTap: _onDoubleTap,
onScaleStart: _onScaleStart,
onScaleUpdate: (details) => _onScaleUpdate(details, transformer),
onSecondaryTapUp: (event) {
setState(() {
postsInList.clear();
});
},
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
final delta = event.scrollDelta;
final newZoom = controller.zoom - (delta.dy / 1000.0);
setState(() {
_tryZoom(newZoom, transformer);
});
}
},
child: Stack(
children: [
Map(
controller: controller,
builder: (context, x, y, z) {
final filename = '${z}_${x}_$y.png';
final imageFile =
getTileCachedFile(settings.geoCacheDirectory, filename);
//Legal notice: This url is only used for demo and educational purposes. You need a license key for production use.
//Google Maps
// final url =
// 'https://www.google.com/maps/vt/pb=!1m4!1m3!1i$z!2i$x!3i$y!2m3!1e0!2sm!3i420120488!3m7!2sen!5e1105!12m4!1e68!2m2!1sset!2sRoadmap!4e0!5m1!1e0!23i4111425';
//
// final darkUrl =
// 'https://maps.googleapis.com/maps/vt?pb=!1m5!1m4!1i$z!2i$x!3i$y!4i256!2m3!1e0!2sm!3i556279080!3m17!2sen-US!3sUS!5e18!12m4!1e68!2m2!1sset!2sRoadmap!12m3!1e37!2m1!1ssmartmaps!12m4!1e26!2m2!1sstyles!2zcC52Om9uLHMuZTpsfHAudjpvZmZ8cC5zOi0xMDAscy5lOmwudC5mfHAuczozNnxwLmM6I2ZmMDAwMDAwfHAubDo0MHxwLnY6b2ZmLHMuZTpsLnQuc3xwLnY6b2ZmfHAuYzojZmYwMDAwMDB8cC5sOjE2LHMuZTpsLml8cC52Om9mZixzLnQ6MXxzLmU6Zy5mfHAuYzojZmYwMDAwMDB8cC5sOjIwLHMudDoxfHMuZTpnLnN8cC5jOiNmZjAwMDAwMHxwLmw6MTd8cC53OjEuMixzLnQ6NXxzLmU6Z3xwLmM6I2ZmMDAwMDAwfHAubDoyMCxzLnQ6NXxzLmU6Zy5mfHAuYzojZmY0ZDYwNTkscy50OjV8cy5lOmcuc3xwLmM6I2ZmNGQ2MDU5LHMudDo4MnxzLmU6Zy5mfHAuYzojZmY0ZDYwNTkscy50OjJ8cy5lOmd8cC5sOjIxLHMudDoyfHMuZTpnLmZ8cC5jOiNmZjRkNjA1OSxzLnQ6MnxzLmU6Zy5zfHAuYzojZmY0ZDYwNTkscy50OjN8cy5lOmd8cC52Om9ufHAuYzojZmY3ZjhkODkscy50OjN8cy5lOmcuZnxwLmM6I2ZmN2Y4ZDg5LHMudDo0OXxzLmU6Zy5mfHAuYzojZmY3ZjhkODl8cC5sOjE3LHMudDo0OXxzLmU6Zy5zfHAuYzojZmY3ZjhkODl8cC5sOjI5fHAudzowLjIscy50OjUwfHMuZTpnfHAuYzojZmYwMDAwMDB8cC5sOjE4LHMudDo1MHxzLmU6Zy5mfHAuYzojZmY3ZjhkODkscy50OjUwfHMuZTpnLnN8cC5jOiNmZjdmOGQ4OSxzLnQ6NTF8cy5lOmd8cC5jOiNmZjAwMDAwMHxwLmw6MTYscy50OjUxfHMuZTpnLmZ8cC5jOiNmZjdmOGQ4OSxzLnQ6NTF8cy5lOmcuc3xwLmM6I2ZmN2Y4ZDg5LHMudDo0fHMuZTpnfHAuYzojZmYwMDAwMDB8cC5sOjE5LHMudDo2fHAuYzojZmYyYjM2Mzh8cC52Om9uLHMudDo2fHMuZTpnfHAuYzojZmYyYjM2Mzh8cC5sOjE3LHMudDo2fHMuZTpnLmZ8cC5jOiNmZjI0MjgyYixzLnQ6NnxzLmU6Zy5zfHAuYzojZmYyNDI4MmIscy50OjZ8cy5lOmx8cC52Om9mZixzLnQ6NnxzLmU6bC50fHAudjpvZmYscy50OjZ8cy5lOmwudC5mfHAudjpvZmYscy50OjZ8cy5lOmwudC5zfHAudjpvZmYscy50OjZ8cy5lOmwuaXxwLnY6b2Zm!4e0&key=AIzaSyAOqYYyBbtXQEtcHG7hwAwyCPQSYidG8yU&token=31440';
//Mapbox Streets
// final url =
// 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/$z/$x/$y';
final url = 'https://tile.openstreetmap.org/$z/$x/$y.png';
_logger
.finest(() => 'Attempting to display tile from $url');
return Image(
image: NetworkToFileImage(
url: url, file: imageFile, debug: shouldDebugCache),
fit: BoxFit.cover,
);
},
),
...markerWidgets,
],
),
),
);
},
);
}
Widget _buildMarkerWidget(
MarkerData data, DateFormat formatter, PathMappingService mapper) {
return Positioned(
left: data.pos.dx - 16,
top: data.pos.dy - 16,
width: billboardXSize,
height: billboardYSize,
child: InkWell(
onTap: () {
setState(() {
postsInList.clear();
postsInList.addAll(data.posts);
_logger.finest(
() => 'Reset post list with ${data.posts.length} posts');
});
},
child: Center(
child: SizedBox(
width: billboardXSize,
height: billboardYSize,
child: Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
data.toLabel() + '\n' + data.subLabel(),
textAlign: TextAlign.center,
),
),
)),
)),
);
}
}

View file

@ -0,0 +1,185 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:friendica_archive_browser/src/friendica/components/facebook_media_wrapper_component.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_media_attachment.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/themes.dart';
import 'package:friendica_archive_browser/src/utils/snackbar_status_builder.dart';
import 'package:provider/provider.dart';
class FacebookMediaSlideshowScreen extends StatefulWidget {
static const _spacing = 5.0;
final List<FacebookMediaAttachment> mediaAttachments;
final int initialIndex;
const FacebookMediaSlideshowScreen(
{Key? key, required this.mediaAttachments, required this.initialIndex})
: super(key: key);
@override
State<FacebookMediaSlideshowScreen> createState() =>
_FacebookMediaSlideshowScreenState();
}
class _FacebookMediaSlideshowScreenState
extends State<FacebookMediaSlideshowScreen> {
static const fastestChangeMS = 250;
FacebookMediaAttachment media = FacebookMediaAttachment.blank();
int index = 0;
int lastKeyInducedIndexChange = 0;
@override
void initState() {
index = widget.initialIndex;
media = widget.mediaAttachments[index];
super.initState();
}
void updateIndex(int newIndex) {
setState(() {
index = newIndex;
media = widget.mediaAttachments[index];
});
}
void previousImage() {
if (index == 0) {
return;
}
updateIndex(--index);
}
void nextImage() {
if (index == widget.mediaAttachments.length - 1) {
return;
}
updateIndex(++index);
}
@override
Widget build(BuildContext context) {
final formatter =
Provider.of<SettingsController>(context).dateTimeFormatter;
const toolBarHeight = 50.0;
final width = MediaQuery.of(context).size.width;
final height = MediaQuery.of(context).size.height - toolBarHeight;
return Theme(
data: FriendicaArchiveBrowserTheme.darkroom,
child: KeyboardListener(
focusNode: FocusNode(),
autofocus: true,
onKeyEvent: (event) {
final key = event.logicalKey;
final now = DateTime.now().millisecondsSinceEpoch;
if (key == LogicalKeyboardKey.arrowLeft) {
if (now - lastKeyInducedIndexChange >= fastestChangeMS) {
previousImage();
lastKeyInducedIndexChange = now;
}
} else if (key == LogicalKeyboardKey.arrowRight) {
if (now - lastKeyInducedIndexChange >= fastestChangeMS) {
nextImage();
lastKeyInducedIndexChange = now;
}
} else if (key == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
}
},
child: Scaffold(
appBar: AppBar(
toolbarHeight: toolBarHeight,
title: Text(media.title),
elevation: 0.0,
),
body: Stack(
children: [
Positioned(
width: width,
height: height,
child: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: FacebookMediaWrapperComponent(
mediaAttachment: media,
),
),
const SizedBox(
height: FacebookMediaSlideshowScreen._spacing),
SelectableText(media.description),
const SizedBox(
height: FacebookMediaSlideshowScreen._spacing),
SelectableText(
formatter.format(DateTime.fromMillisecondsSinceEpoch(
media.creationTimestamp * 1000)),
style: const TextStyle(
fontStyle: FontStyle.italic,
),
),
],
),
)),
),
Container(
width: width,
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: index == 0 ? null : previousImage,
child: const Icon(Icons.arrow_back))),
Container(
width: width,
alignment: Alignment.centerRight,
child: TextButton(
onPressed: index == widget.mediaAttachments.length - 1
? null
: nextImage,
child: const Icon(Icons.arrow_forward))),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _saveFile(context),
tooltip: 'Save file to disk',
child: const Icon(Icons.save)),
),
),
);
}
Future<void> _saveFile(BuildContext context) async {
final pathMapper = Provider.of<PathMappingService>(context, listen: false);
final filename = media.uri.pathSegments.last;
final initialPath = pathMapper.toFullPath(media.uri.toFilePath());
final newPath = await FilePicker.platform.saveFile(
dialogTitle: 'Export Image',
fileName: filename,
);
if (newPath == null) {
return;
}
final initialFile = File(initialPath);
final copiedFile = await initialFile.copy(newPath);
final copiedFileExists = await copiedFile.exists();
final message = copiedFileExists
? 'File exported to: $newPath'
: 'Error exporting file to: $newPath';
SnackBarStatusBuilder.buildSnackbar(context, message);
}
}

View file

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/facebook_media_wrapper_component.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_album.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../../screens/loading_status_screen.dart';
import '../../screens/standin_status_screen.dart';
import 'facebook_photo_album_screen.dart';
class FacebookPhotoAlbumsBrowserScreen extends StatelessWidget {
static final _logger = Logger('$FacebookPhotoAlbumsBrowserScreen');
const FacebookPhotoAlbumsBrowserScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine('Build FacebookAlbumListView');
final service = Provider.of<FacebookArchiveDataService>(context);
return FutureBuilder<Result<List<FacebookAlbum>, ExecError>>(
future: service.getAlbums(),
builder: (futureBuilderContext, snapshot) {
_logger.fine('FacebookAlbumListView Future builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading albums');
}
final albumsResult = snapshot.requireData;
if (albumsResult.isFailure) {
return ErrorScreen(
title: 'Error getting comments', error: albumsResult.error);
}
final albums = albumsResult.value;
if (albums.isEmpty) {
return const StandInStatusScreen(title: 'No albums were found');
}
_logger.fine('Build Photo Albums Grid View');
return _FacebookPhotoAlbumsBrowserScreenWidget(albums: albums);
});
}
}
class _FacebookPhotoAlbumsBrowserScreenWidget extends StatelessWidget {
final List<FacebookAlbum> albums;
const _FacebookPhotoAlbumsBrowserScreenWidget(
{Key? key, required this.albums})
: super(key: key);
@override
Widget build(BuildContext context) {
final settingsController = Provider.of<SettingsController>(context);
final pathMapper = Provider.of<PathMappingService>(context);
return FilterControl<FacebookAlbum, dynamic>(
allItems: albums,
textSearchFilterFunction: (album, text) =>
album.name.contains(text) || album.description.contains(text),
itemToDateTimeFunction: (album) => DateTime.fromMillisecondsSinceEpoch(
album.lastModifiedTimestamp * 1000),
dateRangeFilterFunction: (album, start, stop) =>
timestampInRange(album.lastModifiedTimestamp * 1000, start, stop),
builder: (context, albums) {
return Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 16,
),
child: GridView.builder(
itemCount: albums.length,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
mainAxisExtent: 255,
maxCrossAxisExtent: 225,
),
itemBuilder: (itemBuilderContext, index) {
final album = albums[index];
return InkWell(
onTap: () {
Navigator.push(context,
MaterialPageRoute(builder: (routeContext) {
return MultiProvider(providers: [
ChangeNotifierProvider.value(value: settingsController),
Provider.value(value: pathMapper)
], child: FacebookPhotoAlbumScreen(album: album));
}));
},
child: SizedBox(
width: 200,
height: 200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FacebookMediaWrapperComponent(
preferredWidth: 150,
preferredHeight: 150,
mediaAttachment: album.coverPhoto),
const SizedBox(height: 5),
Text(
'${album.name} ',
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 5),
Text('(${album.photos.length} photos)'),
])),
);
},
),
);
});
}
}

View file

@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/facebook_media_wrapper_component.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_album.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_media_attachment.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/screens/standin_status_screen.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'facebook_media_slideshow_screen.dart';
class FacebookPhotoAlbumScreen extends StatelessWidget {
static final _logger = Logger('$FacebookPhotoAlbumScreen');
final FacebookAlbum album;
const FacebookPhotoAlbumScreen({Key? key, required this.album})
: super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine(
'Build FacebookPhotoAlbumScreen for ${album.name} w/ ${album.photos.length} photos');
return album.photos.isEmpty
? _buildEmptyGalleryScrene(context)
: FilterControl<FacebookMediaAttachment, dynamic>(
allItems: album.photos,
textSearchFilterFunction: (photo, text) =>
photo.title.contains(text) || photo.description.contains(text),
itemToDateTimeFunction: (photo) =>
DateTime.fromMillisecondsSinceEpoch(
photo.creationTimestamp * 1000),
dateRangeFilterFunction: (photo, start, stop) =>
timestampInRange(photo.creationTimestamp * 1000, start, stop),
builder: (context, photos) => _FacebookPhotoAlbumScreenWidget(
photos: photos,
albumName: album.name,
albumDescription: album.description,
),
);
}
_buildEmptyGalleryScrene(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(album.name),
backgroundColor: Theme.of(context).canvasColor,
foregroundColor: Theme.of(context).primaryColor,
elevation: 0.0,
),
body: const StandInStatusScreen(title: 'No photos in album'));
}
}
class _FacebookPhotoAlbumScreenWidget extends StatelessWidget {
static final _logger = Logger('$_FacebookPhotoAlbumScreenWidget');
final List<FacebookMediaAttachment> photos;
final String albumName;
final String albumDescription;
const _FacebookPhotoAlbumScreenWidget(
{Key? key,
required this.photos,
this.albumName = '',
this.albumDescription = ''})
: super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine('Rebuilding album widget w/${photos.length} photos');
final pathMapper = Provider.of<PathMappingService>(context);
final settingsController = Provider.of<SettingsController>(context);
return Scaffold(
appBar: AppBar(
title: Text(albumName),
backgroundColor: Theme.of(context).canvasColor,
foregroundColor: Theme.of(context).primaryColor,
elevation: 0.0,
),
body: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (albumDescription.isNotEmpty) ...[
Text(
albumDescription,
softWrap: true,
),
const SizedBox(height: 5)
],
Expanded(
child: GridView.builder(
itemCount: photos.length,
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
mainAxisExtent: 400.0, maxCrossAxisExtent: 400.0),
itemBuilder: (itemBuilderContext, index) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () async {
Navigator.push(context,
MaterialPageRoute(builder: (context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(
value: settingsController),
Provider.value(value: pathMapper)
],
child: FacebookMediaSlideshowScreen(
mediaAttachments: photos,
initialIndex: index));
}));
},
child: FacebookMediaWrapperComponent(
mediaAttachment: photos[index],
preferredWidth: 300,
preferredHeight: 300,
),
),
);
},
),
),
],
)));
}
}

View file

@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/components/post_card.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_post.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../../screens/loading_status_screen.dart';
import '../../screens/standin_status_screen.dart';
class FacebookPostsScreen extends StatelessWidget {
static final _logger = Logger('$FacebookPostsScreen');
const FacebookPostsScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
_logger.info('Build FacebookPostListView');
final service = Provider.of<FacebookArchiveDataService>(context);
final username = Provider.of<SettingsController>(context).facebookName;
return FutureBuilder<Result<List<FacebookPost>, ExecError>>(
future: service.getPosts(),
builder: (context, snapshot) {
_logger.info('FacebookPostListView Future builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading posts');
}
final postsResult = snapshot.requireData;
if (postsResult.isFailure) {
return ErrorScreen(
title: 'Error getting posts', error: postsResult.error);
}
final allPosts = postsResult.value;
final filteredPosts = username.isEmpty
? allPosts
: allPosts.where((p) =>
p.title != username ||
p.post.isNotEmpty ||
p.mediaAttachments.isNotEmpty ||
p.links.isNotEmpty);
final posts = username.isEmpty
? filteredPosts.toList()
: filteredPosts.map((p) {
var newTitle = p.title;
if (p.title == username) {
newTitle = 'You posted';
} else {
newTitle = p.title
.replaceAll(username, 'You')
.replaceAll(wholeWordRegEx('his'), 'your')
.replaceAll(wholeWordRegEx('her'), 'your');
}
if (newTitle == p.title) {
return p;
} else {
return p.copy(title: newTitle);
}
}).toList();
if (posts.isEmpty) {
return const StandInStatusScreen(title: 'No posts were found');
}
_logger.fine('Build Posts ListView');
return _FacebookPostsScreenWidget(posts: posts);
});
}
}
class _FacebookPostsScreenWidget extends StatelessWidget {
static final _logger = Logger('$_FacebookPostsScreenWidget');
final List<FacebookPost> posts;
const _FacebookPostsScreenWidget({Key? key, required this.posts})
: super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine('Redrawing');
return FilterControl<FacebookPost, dynamic>(
allItems: posts,
imagesOnlyFilterFunction: (post) => post.hasImages(),
videosOnlyFilterFunction: (post) => post.hasVideos(),
textSearchFilterFunction: (post, text) =>
post.title.contains(text) || post.post.contains(text),
itemToDateTimeFunction: (post) =>
DateTime.fromMillisecondsSinceEpoch(post.creationTimestamp * 1000),
dateRangeFilterFunction: (post, start, stop) =>
timestampInRange(post.creationTimestamp * 1000, start, stop),
builder: (context, items) {
if (items.isEmpty) {
return const StandInStatusScreen(
title: 'No posts meet filter criteria');
}
return ScrollConfiguration(
behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.separated(
primary: false,
physics: const RangeMaintainingScrollPhysics(),
restorationId: 'facebookPostsListView',
itemCount: items.length,
itemBuilder: (context, index) {
_logger.finer('Rendering FacebookPost List Item');
return PostCard(post: items[index]);
},
separatorBuilder: (context, index) {
return const Divider(
color: Colors.black,
thickness: 0.2,
);
}),
);
});
}
}

View file

@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/components/post_card.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_post.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_saved_item.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_timeline_type.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../../screens/loading_status_screen.dart';
import '../../screens/standin_status_screen.dart';
class FacebookSavedItemsScreen extends StatelessWidget {
static final _logger = Logger('$FacebookSavedItemsScreen');
const FacebookSavedItemsScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
_logger.info('Build FacebookSavedItemsScreen');
final service = Provider.of<FacebookArchiveDataService>(context);
final username = Provider.of<SettingsController>(context).facebookName;
return FutureBuilder<Result<List<FacebookSavedItem>, ExecError>>(
future: service.getSavedItems(),
builder: (context, snapshot) {
_logger.info('FacebookSavedItemsScreen Future builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading savedItems');
}
final savedItemsResult = snapshot.requireData;
if (savedItemsResult.isFailure) {
return ErrorScreen(
title: 'Error getting saved items',
error: savedItemsResult.error);
}
final allSavedItems = savedItemsResult.value;
final savedItems = username.isEmpty
? allSavedItems.toList()
: allSavedItems.map((item) {
var newTitle = item.title;
if (item.title == username) {
newTitle = 'You posted';
} else {
newTitle = item.title
.replaceAll(username, 'You')
.replaceAll(wholeWordRegEx('his'), 'your')
.replaceAll(wholeWordRegEx('her'), 'your');
}
if (newTitle == item.title) {
return item;
} else {
return item.copy(title: newTitle);
}
}).toList();
if (savedItems.isEmpty) {
return const StandInStatusScreen(
title: 'No saved items were found');
}
_logger.fine('Build Saved Items ListView');
final savedItemsAsPosts = savedItems
.map((item) => FacebookPost(
creationTimestamp: item.timestamp,
title: item.title,
post: item.text,
links: item.uri.toString().isNotEmpty ? [item.uri] : [],
timelineType: FacebookTimelineType.active))
.toList();
return _FacebookSavedItemsScreenWidget(
savedItemsAsPosts: savedItemsAsPosts);
});
}
}
class _FacebookSavedItemsScreenWidget extends StatelessWidget {
static final _logger = Logger('$_FacebookSavedItemsScreenWidget');
final List<FacebookPost> savedItemsAsPosts;
const _FacebookSavedItemsScreenWidget(
{Key? key, required this.savedItemsAsPosts})
: super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine('Redrawing');
return FilterControl<FacebookPost, dynamic>(
allItems: savedItemsAsPosts,
textSearchFilterFunction: (post, text) =>
post.title.contains(text) ||
post.post.contains(text) ||
post.links.where((l) => l.toString().contains(text)).isNotEmpty,
itemToDateTimeFunction: (post) =>
DateTime.fromMillisecondsSinceEpoch(post.creationTimestamp * 1000),
dateRangeFilterFunction: (post, start, stop) =>
timestampInRange(post.creationTimestamp * 1000, start, stop),
builder: (context, items) {
if (items.isEmpty) {
return const StandInStatusScreen(
title: 'No saved items meet filter criteria');
}
return ScrollConfiguration(
behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.separated(
primary: false,
restorationId: 'facebookSavedItemsListView',
itemCount: items.length,
itemBuilder: (context, index) {
_logger.finer('Rendering Saved Item List Item');
return PostCard(post: items[index]);
},
separatorBuilder: (context, index) {
return const Divider(
color: Colors.black,
thickness: 0.2,
);
}),
);
});
}
}

View file

@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/components/heatmap_widget.dart';
import 'package:friendica_archive_browser/src/components/timechart_widget.dart';
import 'package:friendica_archive_browser/src/components/word_frequency_widget.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_media_attachment.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:friendica_archive_browser/src/models/time_element.dart';
import 'package:friendica_archive_browser/src/screens/standin_status_screen.dart';
import 'package:friendica_archive_browser/src/utils/snackbar_status_builder.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
class FacebookStatsScreen extends StatefulWidget {
const FacebookStatsScreen({Key? key}) : super(key: key);
@override
State<FacebookStatsScreen> createState() => _FacebookStatsScreenState();
}
class _FacebookStatsScreenState extends State<FacebookStatsScreen> {
static final _logger = Logger("$_FacebookStatsScreenState");
FacebookArchiveDataService? archiveDataService;
final allItems = <TimeElement>[];
StatType statType = StatType.selectType;
bool hasText = true;
@override
void initState() {
super.initState();
}
void _updateSelection(BuildContext context, StatType newType) async {
statType = newType;
await _updateItems(context);
setState(() {});
}
Future<void> _updateItems(BuildContext context) async {
if (archiveDataService == null) {
_logger.severe(
"Can't update stats because archive data service is not set yet");
}
allItems.clear();
Iterable<TimeElement> newItems = [];
switch (statType) {
case StatType.post:
newItems = (await archiveDataService!.getPosts()).fold(
onSuccess: (posts) => posts.map((e) => TimeElement(
timeInMS: e.creationTimestamp * 1000,
hasImages: e.hasImages(),
hasVideos: e.hasVideos(),
title: e.title,
text: e.post)),
onError: (error) {
_logger.severe('Error getting posts: $error');
return [];
});
break;
case StatType.comment:
newItems = (await archiveDataService!.getComments()).fold(
onSuccess: (comments) => comments.map((e) => TimeElement(
timeInMS: e.creationTimestamp * 1000,
hasImages: e.hasImages(),
hasVideos: e.hasVideos(),
title: e.title,
text: e.comment)),
onError: (error) {
_logger.severe('Error getting comments: $error');
return [];
});
break;
case StatType.photo:
newItems = (await archiveDataService!.getAlbums()).fold(
onSuccess: (albums) => albums.expand((album) => album.photos).map(
(photo) => TimeElement(
timeInMS: photo.creationTimestamp * 1000,
hasImages: true,
hasVideos: false,
title: photo.title,
text: photo.description)),
onError: (error) {
_logger.severe('Error getting photos: $error');
return [];
});
break;
case StatType.video:
newItems = (await archiveDataService!.getPosts()).fold(
onSuccess: (posts) => posts
.where((post) => post.hasVideos())
.expand((post) => post.mediaAttachments.where((m) =>
m.estimatedType() == FacebookAttachmentMediaType.video))
.map((e) => TimeElement(
timeInMS: e.creationTimestamp * 1000,
hasImages: false,
hasVideos: true,
title: e.title,
text: e.description)),
onError: (error) {
_logger.severe('Error getting comments: $error');
return [];
});
break;
case StatType.selectType:
break;
default:
_logger.severe('Unknown stat type');
Future.delayed(
Duration.zero,
() => SnackBarStatusBuilder.buildSnackbar(
context, 'Unknown stat type'));
}
allItems.addAll(newItems);
}
@override
Widget build(BuildContext context) {
archiveDataService = Provider.of<FacebookArchiveDataService>(context);
return FilterControl<TimeElement, dynamic>(
allItems: allItems,
imagesOnlyFilterFunction: (item) => item.hasImages,
videosOnlyFilterFunction: (item) => item.hasVideos,
textSearchFilterFunction: (item, text) => item.hasText(text),
itemToDateTimeFunction: (item) => item.timestamp,
dateRangeFilterFunction: (item, start, stop) =>
dateTimeInRange(item.timestamp, start, stop),
builder: (context, items) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
constraints: const BoxConstraints(maxWidth: 800),
child: Column(
children: [
Row(
children: [
const Text('Statistic Type: '),
DropdownButton(
hint: const Text('Select type'),
value:
statType == StatType.selectType ? null : statType,
onChanged: (StatType? type) =>
_updateSelection(context, type!),
items: StatType.values
.map((value) => DropdownMenuItem(
enabled: value != StatType.selectType,
value: value,
child: Text(value.toLabel())))
.where((element) => element.enabled)
.toList()),
],
),
statType == StatType.selectType
? const Expanded(
child: StandInStatusScreen(
title: 'Select data type to show graphs'),
)
: _buildChartPanel(context, items)
],
),
),
);
});
}
Widget _buildChartPanel(BuildContext context, List<TimeElement> items) {
if (items.isEmpty) {
return Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
StandInStatusScreen(
title: 'No items for statistics',
subTitle: 'Adjust the filter or select a new archive',
)
],
),
);
}
return Expanded(
child: SingleChildScrollView(
primary: false,
child: Column(children: [
..._buildGraphScreens(context, items),
const Divider(),
WordFrequencyWidget(items),
]),
));
}
List<Widget> _buildGraphScreens(
BuildContext context, List<TimeElement> items) {
return [
TimeChartWidget(timeElements: items),
const Divider(),
HeatMapWidget(timeElements: items),
];
}
}
enum StatType {
post,
comment,
photo,
video,
selectType,
}
extension StatTypeString on StatType {
String toLabel() {
switch (this) {
case StatType.post:
return "Posts";
case StatType.comment:
return "Comments";
case StatType.photo:
return "Photos";
case StatType.video:
return "Videos";
case StatType.selectType:
return "Select Type";
}
}
StatType fromLabel(String text) {
if (text == 'Posts') {
return StatType.post;
}
if (text == 'Comments') {
return StatType.comment;
}
if (text == 'Photos') {
return StatType.photo;
}
if (text == 'Videos') {
return StatType.video;
}
if (text == 'Select Type') {
return StatType.selectType;
}
throw ArgumentError(['Unknown enum type: $text', 'text']);
}
}

View file

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/components/post_card.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_media_attachment.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_post.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../../screens/loading_status_screen.dart';
import '../../screens/standin_status_screen.dart';
class FacebookVideosScreen extends StatelessWidget {
static final _logger = Logger('$FacebookVideosScreen');
const FacebookVideosScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine('Build FacebookVideosScreen');
final service = Provider.of<FacebookArchiveDataService>(context);
return FutureBuilder<Result<List<FacebookPost>, ExecError>>(
future: service.getPosts(),
builder: (context, snapshot) {
_logger.fine('FacebookVideosScreen Future builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading videos');
}
final result = snapshot.requireData;
if (result.isFailure) {
return ErrorScreen(
title: 'Error getting video posts', error: result.error);
}
final videos = result.value
.where((p) => p.mediaAttachments
.where((m) =>
m.estimatedType() == FacebookAttachmentMediaType.video)
.isNotEmpty)
.toList();
if (videos.isEmpty) {
return const StandInStatusScreen(title: 'No videos were found');
}
_logger.fine('Build Videos ListView');
return _FacebookVideosScreenWidget(posts: videos);
});
}
}
class _FacebookVideosScreenWidget extends StatelessWidget {
static final _logger = Logger('$_FacebookVideosScreenWidget');
final List<FacebookPost> posts;
const _FacebookVideosScreenWidget({Key? key, required this.posts})
: super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine('Redrawing');
return FilterControl<FacebookPost, dynamic>(
allItems: posts,
textSearchFilterFunction: (post, text) =>
post.title.contains(text) || post.post.contains(text),
itemToDateTimeFunction: (post) =>
DateTime.fromMillisecondsSinceEpoch(post.creationTimestamp * 1000),
dateRangeFilterFunction: (post, start, stop) =>
timestampInRange(post.creationTimestamp * 1000, start, stop),
builder: (context, items) {
if (items.isEmpty) {
return const StandInStatusScreen(
title: 'No videos meet filter criteria');
}
return ScrollConfiguration(
behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.separated(
primary: false,
restorationId: 'facebookVideosListView',
itemCount: items.length,
itemBuilder: (context, index) {
_logger.finer('Rendering Facebook Video List Item');
return PostCard(post: items[index]);
},
separatorBuilder: (context, index) {
return const Divider(
color: Colors.black,
thickness: 0.2,
);
}),
);
});
}
}

View file

@ -0,0 +1,524 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_album.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_comment.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_event.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_friend.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_messenger_conversation.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_post.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_saved_item.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_timeline_type.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_file_reader.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import '../../utils/temp_file_builder.dart';
class FacebookArchiveFolderReader extends ChangeNotifier {
static final _logger = Logger('$FacebookArchiveFolderReader');
static final expectedDirectories = [
'posts',
'comments_and_reactions',
'saved_items_and_collections',
'posts/media',
'posts/album',
'events',
'messages',
];
String _rootDirectoryPath = '';
String get rootDirectoryPath => _rootDirectoryPath;
set rootDirectoryPath(String value) {
_rootDirectoryPath = value;
notifyListeners();
}
FacebookArchiveFolderReader(String rootDirectoryPath) {
_rootDirectoryPath = rootDirectoryPath;
_logger.fine('Create new FacebookArchiveFolderReader');
}
FutureResult<List<FacebookPost>, ExecError> readPosts() async {
final posts = <FacebookPost>[];
final errors = <ExecError>[];
final yourPostPath = '$rootDirectoryPath/posts/your_posts_1.json';
if (File(yourPostPath).existsSync()) {
(await _getJsonList(yourPostPath))
.andThen(
(json) => _parsePostResults(json, FacebookTimelineType.active))
.match(
onSuccess: (newPosts) => posts.addAll(newPosts),
onError: (error) {
_logger
.severe('Error $error responses json for ${yourPostPath}');
errors.add(error);
});
}
final archivedPostsPath = '$rootDirectoryPath/posts/archive.json';
if (File(archivedPostsPath).existsSync()) {
(await _getJson(archivedPostsPath))
.andThen((json) => json.containsKey('archive_v2')
? Result.ok(json['archive_v2'])
: Result.error(
ExecError.message('No archive_v2 key in $archivedPostsPath')))
.andThen((archivedPostsJson) => _parsePostResults(
archivedPostsJson, FacebookTimelineType.archive))
.match(
onSuccess: (archivedPosts) => posts.addAll(archivedPosts),
onError: (error) {
_logger.severe(
'Error $error responses json for $archivedPostsPath');
errors.add(error);
});
}
final trashPostsPath = '$rootDirectoryPath/posts/trash.json';
if (File(trashPostsPath).existsSync()) {
(await _getJson(trashPostsPath))
.andThen((json) => json.containsKey('trash_v2')
? Result.ok(json['trash_v2'])
: Result.error(
ExecError.message('No trash_v2 key in $trashPostsPath')))
.andThen((archivedPostsJson) =>
_parsePostResults(archivedPostsJson, FacebookTimelineType.trash))
.match(
onSuccess: (archivedPosts) => posts.addAll(archivedPosts),
onError: (error) {
_logger
.severe('Error $error responses json for $trashPostsPath');
errors.add(error);
});
}
if (errors.isNotEmpty) {
return Result.error(ExecError.message(
'Error reading one or more present post files. Check logs for more details.'));
}
return Result.ok(posts);
}
FutureResult<List<FacebookComment>, ExecError> readComments() async {
final path = '$rootDirectoryPath/comments_and_reactions/comments.json';
final jsonResult = await _getJson(path);
if (jsonResult.isFailure) {
return Result.error(jsonResult.error);
}
final jsonData = jsonResult.value;
if (!jsonData.containsKey('comments_v2')) {
return Result.error(
ExecError(errorMessage: 'Comments JSON file is malformed: $path'));
}
final commentsJson = jsonData['comments_v2'] as List<dynamic>;
final commentsResult = runCatching(() => Result.ok(
commentsJson.map((e) => FacebookComment.fromFacebookJson(e)).toList()));
commentsResult.match(
onSuccess: (value) => _logger.fine('Comments processed into PODOs'),
onError: (error) =>
_logger.severe('Error mapping JSON to post data: $error'));
return commentsResult.mapExceptionErrorToExecError();
}
FutureResult<List<FacebookAlbum>, ExecError> readPhotoAlbums() async {
final albumFolderPath = '$rootDirectoryPath/posts/album';
final folder = Directory(albumFolderPath);
final albums = <FacebookAlbum>[];
if (!folder.existsSync()) {
final msg = 'Photos folder does not exist; $albumFolderPath';
_logger.severe(msg);
return Result.error(ExecError(errorMessage: msg));
}
await for (var entity in folder.list(recursive: true)) {
final filePath = entity.path;
if (entity.statSync().type != FileSystemEntityType.file) {
_logger
.severe("Unexpected file/folder in photo albums folder: $filePath");
continue;
}
if (!entity.path.toLowerCase().endsWith('json')) {
_logger
.severe("Unexpected file type in photo albums folder: $filePath");
continue;
}
final jsonResult = await _getJson(filePath);
jsonResult.match(
onSuccess: (json) {
final albumResult =
runCatching(() => Result.ok(FacebookAlbum.fromJson(json)));
albumResult.match(
onSuccess: (album) {
albums.add(album);
_logger.fine('Album converted to PODO');
},
onError: (error) =>
_logger.severe('Error parsing album JSON for $filePath'));
},
onError: (error) =>
_logger.severe('Error parsing photo album: $filePath'));
}
return Result.ok(albums);
}
FutureResult<List<FacebookFriend>, ExecError> readFriends() async {
final basePath = '$rootDirectoryPath/friends_and_followers';
final friendsFile = File('$basePath/friends.json');
final receivedFile = File('$basePath/friend_requests_received.json');
final rejectedFile = File('$basePath/rejected_friend_requests.json');
final removedFile = File('$basePath/removed_friends.json');
final sentFile = File('$basePath/friend_requests_sent.json');
final allFriends = <FacebookFriend>[];
if (!Directory(basePath).existsSync()) {
_logger.severe('Friends base folder does not exist: $basePath');
return Result.error(
ExecError(errorMessage: 'Friends data does not exist'));
}
(await _readFriendsJsonFile(
friendsFile, FriendStatus.friends, "friends_v2"))
.match(
onSuccess: (friends) => allFriends.addAll(friends),
onError: (error) => _logger.info(
"Errors processing friends.json, continuing on without that data"));
(await _readFriendsJsonFile(
receivedFile, FriendStatus.requestReceived, "received_requests_v2"))
.match(
onSuccess: (friends) => allFriends.addAll(friends),
onError: (error) => _logger.info(
"Errors processing received_friend_requests.json, continuing on without that data"));
(await _readFriendsJsonFile(
rejectedFile, FriendStatus.rejectedRequest, "rejected_requests_v2"))
.match(
onSuccess: (friends) => allFriends.addAll(friends),
onError: (error) => _logger.info(
"Errors processing rejected_friend_requests.json, continuing on without that data"));
(await _readFriendsJsonFile(
removedFile, FriendStatus.removed, "deleted_friends_v2"))
.match(
onSuccess: (friends) => allFriends.addAll(friends),
onError: (error) => _logger.info(
"Errors processing removed_friends.json, continuing on without that data"));
(await _readFriendsJsonFile(
sentFile, FriendStatus.removed, "sent_requests_v2"))
.match(
onSuccess: (friends) => allFriends.addAll(friends),
onError: (error) => _logger.info(
"Errors processing sent_friend_requests.json, continuing on without that data"));
return Result.ok(allFriends);
}
FutureResult<List<FacebookEvent>, ExecError> readEvents() async {
final basePath = '$rootDirectoryPath/events';
final invitationsFile = File('$basePath/event_invitations.json');
final responsesFile = File('$basePath/your_event_responses.json');
final yourEventsFile = File('$basePath/your_events.json');
final events = <FacebookEvent>[];
if (!Directory(basePath).existsSync()) {
_logger.severe('Events base folder does not exist: $basePath');
return Result.error(
ExecError(errorMessage: 'Events data does not exist'));
}
if (invitationsFile.existsSync()) {
final json = (await _getJson(invitationsFile.path)).fold(
onSuccess: (json) => json,
onError: (error) {
_logger.severe(
'Error $error reading json for ${invitationsFile.path}');
return <String, dynamic>{};
});
final List<dynamic> invited =
json['events_invited_v2'] ?? <Map<String, dynamic>>[];
try {
events.addAll(invited.map((e) => FacebookEvent.fromJson(e,
statusType: FacebookEventStatus.invited)));
} catch (e) {
_logger.severe(
'Error $e processing JSON invitations file: ${invitationsFile.path}');
}
} else {
_logger.info('Invitations file does not exist; ${invitationsFile.path}');
}
if (responsesFile.existsSync()) {
final json = (await _getJson(responsesFile.path)).fold(
onSuccess: (json) => json,
onError: (error) {
_logger.severe(
'Error $error responses json for ${responsesFile.path}');
return <String, dynamic>{};
});
final Map<String, dynamic> responses =
json['event_responses_v2'] ?? <String, dynamic>{};
final List<dynamic> joined = responses['events_joined'] ?? [];
try {
events.addAll(joined.map((e) =>
FacebookEvent.fromJson(e, statusType: FacebookEventStatus.joined)));
} catch (e) {
_logger.severe(
'Error $e processing JSON joined events file: ${invitationsFile.path}');
}
final List<dynamic> declined = responses['events_declined'] ?? [];
try {
events.addAll(declined.map((e) => FacebookEvent.fromJson(e,
statusType: FacebookEventStatus.declined)));
} catch (e) {
_logger.severe(
'Error $e processing JSON declined events file: ${invitationsFile.path}');
}
final List<dynamic> interested = responses['events_interested'] ?? [];
try {
events.addAll(interested.map((e) => FacebookEvent.fromJson(e,
statusType: FacebookEventStatus.declined)));
} catch (e) {
_logger.severe(
'Error $e processing JSON interested events file: ${invitationsFile.path}');
}
} else {
_logger.info('Responses file does not exist; ${responsesFile.path}');
}
if (yourEventsFile.existsSync()) {
final json = (await _getJson(yourEventsFile.path)).fold(
onSuccess: (json) => json,
onError: (error) {
_logger.severe(
'Error $error your events file json for ${responsesFile.path}');
return <String, dynamic>{};
});
final List<dynamic> yourEvents =
json['your_events_v2'] ?? <Map<String, dynamic>>[];
try {
events.addAll(yourEvents.map((e) =>
FacebookEvent.fromJson(e, statusType: FacebookEventStatus.owner)));
} catch (e) {
_logger.severe(
'Error $e processing JSON your events file: ${yourEventsFile.path}');
}
} else {
_logger.info('Your events file does not exist ${yourEventsFile.path}');
}
events.sort((e1, e2) => -e1.startTimestamp.compareTo(e2.startTimestamp));
return Result.ok(events);
}
FutureResult<List<FacebookMessengerConversation>, ExecError>
readConversations() async {
final path = '$rootDirectoryPath/messages';
final folder = Directory(path);
final conversations = <String, FacebookMessengerConversation>{};
if (!folder.existsSync()) {
_logger.severe('Messages folder does not exist; $path');
return Result.ok([]);
}
await for (var entity in folder.list(recursive: true)) {
if (entity.path.toLowerCase().endsWith('json')) {
if (entity is Directory) {
continue;
}
try {
final jsonResult = await _getJson(entity.path, level: Level.FINEST);
if (jsonResult.isFailure) {
_logger.severe(
'Error ${jsonResult.error} reading JSON data for ${entity.path}');
continue;
}
final conversation =
FacebookMessengerConversation.fromFacebookJson(jsonResult.value);
if (conversations.containsKey(conversation.id)) {
final existingConvo = conversations[conversation.id]!;
existingConvo.messages.addAll(conversation.messages);
existingConvo.messages
.sort((m1, m2) => -m1.timestampMS.compareTo(m2.timestampMS));
} else {
conversations[conversation.id] = conversation;
}
} catch (e) {
_logger.severe('Error $e processing conversation ${entity.path}');
}
}
}
return Result.ok(conversations.values.toList());
}
FutureResult<List<FacebookSavedItem>, ExecError> readSavedItems() async {
final path =
'$rootDirectoryPath/saved_items_and_collections/saved_items_and_collections.json';
final jsonResult = await _getJson(path);
if (jsonResult.isFailure) {
return Result.error(jsonResult.error);
}
final jsonData = jsonResult.value;
if (!jsonData.containsKey('saves_and_collections_v2')) {
return Result.error(ExecError(
errorMessage:
'Saved Items and Collections JSON file is malformed: $path'));
}
final savedItemsJson =
jsonData['saves_and_collections_v2'] as List<dynamic>;
final savedItemsResult = runCatching(() => Result.ok(savedItemsJson
.map((e) => FacebookSavedItem.fromFacebookJson(e))
.toList()));
savedItemsResult
.andThen(
(items) => Result.ok(items.where((e) => e.timestamp != 0).toList()))
.match(
onSuccess: (value) =>
_logger.fine('Saved Items processed into PODOs'),
onError: (error) => _logger
.severe('Error mapping JSON to saved items data: $error'));
return savedItemsResult.mapExceptionErrorToExecError();
}
static bool validateCanReadArchive(String path) {
_logger.fine('Validating whether path is a valid Facebook Archive: $path');
final baseDir = Directory(path);
if (!baseDir.existsSync()) {
_logger.severe('Unable to find base directory: $path');
return false;
}
try {
baseDir.listSync();
} catch (e) {
_logger.severe('Unable to access base directory: $path');
return false;
}
return true;
}
Result<List<FacebookPost>, ExecError> _parsePostResults(
List<dynamic> json, FacebookTimelineType timelineType) {
final postsResult = runCatching(() => Result.ok(
json.map((e) => FacebookPost.fromJson(e, timelineType)).toList()));
postsResult.match(
onSuccess: (value) => _logger.fine('Posts processed into PODOs'),
onError: (error) =>
_logger.severe('Error mapping JSON to post data: $error'));
return postsResult.mapError((error) =>
error is ExecError ? error : ExecError.message(error.toString()));
}
static FutureResult<Map<String, dynamic>, ExecError> _getJson(String path,
{Level level = Level.FINE}) async {
final file = File(path);
final result = await (await _readFacebookFile(file, level)).andThenAsync(
(jsonText) async => await _parseJsonFileText<Map<String, dynamic>>(
jsonText, file, level));
return result.mapError((error) => error as ExecError);
}
static FutureResult<List<dynamic>, ExecError> _getJsonList(String path,
{Level level = Level.FINE}) async {
final file = File(path);
final fileTextResponse = await _readFacebookFile(file, level);
if (fileTextResponse.isFailure) {
return Result.error(fileTextResponse.error);
}
final jsonText = fileTextResponse.value.trim();
if (!jsonText.startsWith('[')) {
final parsedJsonResult =
await _parseJsonFileText<Map<String, dynamic>>(jsonText, file, level);
return parsedJsonResult.mapValue((value) => [value]);
}
return await _parseJsonFileText<List<dynamic>>(jsonText, file, level);
}
static FutureResult<String, ExecError> _readFacebookFile(
File file, Level level) async {
_logger.log(level, 'Attempting to open and read ${file.path}');
final response = await file.readFacebookEncodedFileAsString();
response.match(
onSuccess: (value) => _logger.log(level, 'Text read from ${file.path}'),
onError: (error) async {
final tmpPath =
await getTempFile(file.uri.pathSegments.last, '.fragment.json');
await File(tmpPath).writeAsString(response.error.errorMessage);
_logger.severe('Wrote partial read of ${file.path} to $tmpPath');
});
return response;
}
static FutureResult<T, ExecError> _parseJsonFileText<T>(
String text, File originalFile, Level levelForFullDump) async {
final jsonParseResult = runCatching(() => Result.ok(jsonDecode(text) as T))
.mapExceptionErrorToExecError();
final msg = jsonParseResult.fold(
onSuccess: (value) => 'JSON decoded from ${originalFile.path}',
onError: (error) async {
final tmpPath = await getTempFile(
originalFile.uri.pathSegments.last, '.ingested.json');
await File(tmpPath).writeAsString(text);
_logger.severe(
'Wrote ingested JSON stream text read of ${originalFile.path} to $tmpPath');
return 'Error parsing json for ${originalFile.path}';
});
_logger.log(levelForFullDump, msg);
return jsonParseResult;
}
FutureResult<List<FacebookFriend>, ExecError> _readFriendsJsonFile(
File file, FriendStatus status, String topKey) async {
final friends = <FacebookFriend>[];
if (file.existsSync()) {
final json = (await _getJson(file.path)).fold(
onSuccess: (json) => json,
onError: (error) {
_logger.severe('Error $error reading json for ${file.path}');
return <String, dynamic>{};
});
final List<dynamic> invited = json[topKey] ?? <Map<String, dynamic>>[];
try {
final entries = invited.map((f) => FacebookFriend.fromJson(f, status));
_logger.fine(
'${entries.length} friends of type $status found in ${file.path}');
friends.addAll(entries);
} catch (e) {
_logger.severe('Error $e processing JSON $topKey file: ${file.path}');
}
} else {
_logger.info('$topKey file does not exist; ${file.path}');
}
return Result.ok(friends);
}
}

View file

@ -0,0 +1,497 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_album.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_comment.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_event.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_friend.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_media_attachment.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_messenger_conversation.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_post.dart';
import 'package:friendica_archive_browser/src/friendica/models/facebook_saved_item.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:result_monad/result_monad.dart';
import 'facebook_archive_reader.dart';
class FacebookArchiveDataService extends ChangeNotifier {
static final _logger = Logger('$FacebookArchiveDataService');
final PathMappingService pathMappingService;
final String appDataDirectory;
final List<FacebookAlbum> albums = [];
final List<FacebookPost> posts = [];
final List<FacebookComment> comments = [];
final List<FacebookEvent> events = [];
final List<FacebookFriend> friends = [];
final List<FacebookMessengerConversation> convos = [];
final List<FacebookSavedItem> savedItems = [];
bool canUseConvoCacheFile = true;
FacebookArchiveDataService(
{required this.pathMappingService, required this.appDataDirectory}) {
_logger.info('Facebook Archive Service created');
}
void clearCaches() {
_logger.fine('clearCaches called');
_logger.finer('Clearing caches');
albums.clear();
posts.clear();
comments.clear();
events.clear();
convos.clear();
friends.clear();
savedItems.clear();
notifyListeners();
canUseConvoCacheFile = false;
_logger.finer('Deleting files');
try {
final convoCacheFile = File(_conversationCachePath);
if (convoCacheFile.existsSync()) {
convoCacheFile.deleteSync();
}
if (convoCacheFile.existsSync()) {
_logger.severe(
'Attempted to delete conversations cache file but it did not succeed. ${convoCacheFile.path}');
}
} catch (e) {
_logger.severe(
'Exception thrown while attempting to clear conversations cache file: $e');
}
canUseConvoCacheFile = true;
_logger.fine('clearCaches complete');
}
FutureResult<List<FacebookPost>, ExecError> getPosts() async {
_logger.fine('Request for posts');
if (posts.isNotEmpty) {
_logger.fine(
'Posts already loaded, returning existing ${posts.length} posts');
return Result.ok(List.unmodifiable(posts));
}
_logger.finer('No previously pulled posts reading from disk');
final postsResult = await _readAllPosts();
postsResult.match(
onSuccess: (newPosts) {
posts.clear();
posts.addAll(newPosts);
posts.sort((p1, p2) =>
-p1.creationTimestamp.compareTo(p2.creationTimestamp));
},
onError: (error) => _logger.severe('Error loading posts: $error'));
_logger.fine('Returning ${posts.length} posts');
return Result.ok(List.unmodifiable(posts));
}
FutureResult<List<FacebookComment>, ExecError> getComments() async {
_logger.fine('Request for comments');
if (comments.isNotEmpty) {
_logger.fine(
'Comments already loaded, returning existing ${comments.length} comments');
return Result.ok(List.unmodifiable(comments));
}
_logger.finer('No previously pulled comments reading from disk');
final commentsResult = await _readAllComments();
commentsResult.match(
onSuccess: (newComments) {
comments.clear();
comments.addAll(newComments);
comments.sort((c1, c2) =>
-c1.creationTimestamp.compareTo(c2.creationTimestamp));
},
onError: (error) => _logger.severe('Error loading comments: $error'));
_logger.fine('Returning ${comments.length} comments');
return Result.ok(List.unmodifiable(comments));
}
FutureResult<List<FacebookEvent>, ExecError> getEvents() async {
_logger.fine('Request for events');
if (events.isNotEmpty) {
_logger.fine(
'Events already loaded, returning existing ${events.length} events');
return Result.ok(List.unmodifiable(events));
}
_logger.finer('No previously pulled events reading from disk');
final eventsResult = await _readAllEvents();
eventsResult.match(
onSuccess: (newEvents) {
events.clear();
events.addAll(newEvents);
events.sort((e1, e2) =>
-e1.creationTimestamp.compareTo(e2.creationTimestamp));
},
onError: (error) => _logger.severe('Error loading events: $error'));
_logger.fine('Returning ${comments.length} events');
return Result.ok(List.unmodifiable(events));
}
FutureResult<List<FacebookFriend>, ExecError> getFriends() async {
_logger.fine('Request for friends');
if (friends.isNotEmpty) {
_logger.fine(
'Friends already loaded, returning existing ${friends.length} friends');
return Result.ok(List.unmodifiable(friends));
}
_logger.finer('No previously pulled friends reading from disk');
final friendResult = await _readAllFriends();
friendResult.match(
onSuccess: (newFriends) {
friends.clear();
friends.addAll(newFriends);
},
onError: (error) => _logger.severe('Error loading friends: $error'));
_logger.fine('Returning ${friends.length} friends');
return Result.ok(List.unmodifiable(friends));
}
FutureResult<List<FacebookAlbum>, ExecError> getAlbums() async {
_logger.fine('Request for albums');
if (albums.isNotEmpty) {
_logger.fine(
'Albums already loaded, returning existing ${albums.length} albums');
return Result.ok(List.unmodifiable(albums));
}
_logger.finer('No previously pulled albums reading from disk');
final albumResult = await _readAllAlbums();
albumResult.match(
onSuccess: (newAlbums) {
albums.clear();
albums.addAll(newAlbums);
},
onError: (error) => _logger.severe('Error loading albums: $error'));
final postsAlbum = await _generatePostsAlbum();
postsAlbum.match(
onSuccess: (album) => albums.add(album),
onError: (error) =>
_logger.severe('Error generating posts album: $error'));
albums.sort((a1, a2) =>
-a1.lastModifiedTimestamp.compareTo(a2.lastModifiedTimestamp));
_logger.fine('Returning ${albums.length} albums');
return Result.ok(List.unmodifiable(albums));
}
FutureResult<List<FacebookMessengerConversation>, ExecError>
getConvos() async {
_logger.fine('Request for conversations');
if (convos.isNotEmpty) {
_logger.fine(
'Conversations already loaded, returning existing ${convos.length} posts');
return Result.ok(List.unmodifiable(convos));
}
final convoCacheFile = File(_conversationCachePath);
try {
if (canUseConvoCacheFile && convoCacheFile.existsSync()) {
_logger.finer(
'Attempt to load conversations from: $_conversationCachePath');
final newConvosTextResult = await convoCacheFile.readAsString();
if (newConvosTextResult.isNotEmpty) {
final newConvosData =
jsonDecode(newConvosTextResult) as List<dynamic>;
final newConvos = newConvosData
.map((json) => FacebookMessengerConversation.fromJson(json))
.toList();
convos.clear();
convos.addAll(newConvos);
_logger.fine(
'${newConvos.length} conversations loaded from disk. Returning ${convos.length} conversations');
return Result.ok(List.unmodifiable(convos));
}
}
} catch (e) {
_logger.severe('Exception thrown trying to read from cache, $e');
}
_logger.finer('No cache data available so reading from original archive');
final conversationsResult = await _readAllConvos();
conversationsResult.match(onSuccess: (newConversations) {
convos.clear();
convos.addAll(newConversations);
convos.sort((c1, c2) =>
-c1.latestTimestampMS().compareTo(c2.latestTimestampMS()));
}, onError: (error) {
_logger.severe('Error loading posts: $error');
});
try {
_logger.finer(
'Writing ${convos.length} to conversation cache file $_conversationCachePath');
String json = jsonEncode(convos);
await convoCacheFile.writeAsString(json, flush: true);
} catch (e) {
_logger.severe('Error trying to write to cache file, $e');
}
_logger.fine('Returning ${convos.length} conversations');
return Result.ok(List.unmodifiable(convos));
}
FutureResult<List<FacebookSavedItem>, ExecError> getSavedItems() async {
_logger.fine('Request for saved items');
if (savedItems.isNotEmpty) {
_logger.fine(
'Saved items already loaded, returning existing ${savedItems.length} comments');
return Result.ok(List.unmodifiable(savedItems));
}
_logger.finer('No previously pulled saved items, reading from disk');
final savedItemsResult = await _readAllSavedItems();
savedItemsResult.match(
onSuccess: (newSavedItems) {
savedItems.clear();
savedItems.addAll(newSavedItems);
savedItems.sort((c1, c2) => -c1.timestamp.compareTo(c2.timestamp));
},
onError: (error) => _logger.severe('Error loading savedItems: $error'));
_logger.fine('Returning ${savedItems.length} saved items');
return Result.ok(List.unmodifiable(savedItems));
}
String get _conversationCachePath =>
p.join(appDataDirectory, 'convo_cache.json');
FutureResult<List<FacebookPost>, ExecError> _readAllPosts() async {
final allPosts = <FacebookPost>[];
bool hadSuccess = false;
for (final topLevelDir in _topLevelDirs) {
try {
_logger.fine(
'Attempting to find/parse Post JSON data in ${topLevelDir.path}');
final reader = FacebookArchiveFolderReader(topLevelDir.path);
final postsResult = await reader.readPosts();
postsResult.match(
onSuccess: (newPosts) {
allPosts.addAll(newPosts);
hadSuccess = true;
},
onError: (error) => _logger.fine(error));
} catch (e) {
_logger.severe('Exception thrown trying to read posts, $e');
}
}
if (hadSuccess) {
return Result.ok(allPosts);
}
return Result.error(ExecError.message(
'Unable to find any post JSON files in $_baseArchiveFolder'));
}
FutureResult<List<FacebookComment>, ExecError> _readAllComments() async {
final allComments = <FacebookComment>[];
bool hadSuccess = false;
for (final topLevelDir in _topLevelDirs) {
try {
_logger.fine(
'Attempting to find/parse comment JSON data in ${topLevelDir.path}');
final reader = FacebookArchiveFolderReader(topLevelDir.path);
final commentsResult = await reader.readComments();
commentsResult.match(
onSuccess: (newEvents) {
allComments.addAll(newEvents);
hadSuccess = true;
},
onError: (error) => _logger.fine(error));
} catch (e) {
_logger.severe('Exception thrown trying to read comments, $e');
}
}
if (hadSuccess) {
return Result.ok(allComments);
}
return Result.error(ExecError.message(
'Unable to find any comment JSON files in $_baseArchiveFolder'));
}
FutureResult<List<FacebookEvent>, ExecError> _readAllEvents() async {
final allEvents = <FacebookEvent>[];
bool hadSuccess = false;
for (final topLevelDir in _topLevelDirs) {
try {
_logger.fine(
'Attempting to find/parse event JSON data in ${topLevelDir.path}');
final reader = FacebookArchiveFolderReader(topLevelDir.path);
final eventsResult = await reader.readEvents();
eventsResult.match(
onSuccess: (newEvents) {
allEvents.addAll(newEvents);
hadSuccess = true;
},
onError: (error) => _logger.fine(error));
} catch (e) {
_logger.severe('Exception thrown trying to read events, $e');
}
}
if (hadSuccess) {
return Result.ok(allEvents);
}
return Result.error(ExecError.message(
'Unable to find any event JSON files in $_baseArchiveFolder'));
}
FutureResult<List<FacebookFriend>, ExecError> _readAllFriends() async {
final allFriends = <FacebookFriend>[];
bool hadSuccess = false;
for (final topLevelDir in _topLevelDirs) {
try {
_logger.fine(
'Attempting to find/parse friend JSON data in ${topLevelDir.path}');
final reader = FacebookArchiveFolderReader(topLevelDir.path);
final friendsResult = await reader.readFriends();
friendsResult.match(
onSuccess: (newFriends) {
allFriends.addAll(newFriends);
hadSuccess = true;
},
onError: (error) => _logger.fine(error));
} catch (e) {
_logger.severe('Exception thrown trying to read friends, $e');
}
}
if (hadSuccess) {
return Result.ok(allFriends);
}
return Result.error(ExecError.message(
'Unable to find any album JSON files in $_baseArchiveFolder'));
}
FutureResult<List<FacebookAlbum>, ExecError> _readAllAlbums() async {
final allAlbums = <FacebookAlbum>[];
bool hadSuccess = false;
for (final topLevelDir in _topLevelDirs) {
try {
_logger.fine(
'Attempting to find/parse album JSON data in ${topLevelDir.path}');
final reader = FacebookArchiveFolderReader(topLevelDir.path);
final albumResult = await reader.readPhotoAlbums();
albumResult.match(
onSuccess: (newAlbums) {
allAlbums.addAll(newAlbums);
hadSuccess = true;
},
onError: (error) => _logger.fine(error));
} catch (e) {
_logger.severe('Exception thrown trying to read albums, $e');
}
}
if (hadSuccess) {
return Result.ok(allAlbums);
}
return Result.error(ExecError.message(
'Unable to find any album JSON files in $_baseArchiveFolder'));
}
FutureResult<List<FacebookMessengerConversation>, ExecError>
_readAllConvos() async {
final allConvos = <FacebookMessengerConversation>[];
bool hadSuccess = false;
for (final topLevelDir in _topLevelDirs) {
try {
_logger.fine(
'Attempting to find/parse conversation JSON data in ${topLevelDir.path}');
final reader = FacebookArchiveFolderReader(topLevelDir.path);
final convosResult = await reader.readConversations();
convosResult.match(
onSuccess: (newConvos) {
allConvos.addAll(newConvos);
hadSuccess = true;
},
onError: (error) => _logger.fine(error));
} catch (e) {
_logger.severe('Exception thrown trying to read conversations, $e');
}
}
if (hadSuccess) {
return Result.ok(allConvos);
}
return Result.error(ExecError.message(
'Unable to find any event JSON files in $_baseArchiveFolder'));
}
FutureResult<List<FacebookSavedItem>, ExecError> _readAllSavedItems() async {
final allSavedItems = <FacebookSavedItem>[];
bool hadSuccess = false;
for (final topLevelDir in _topLevelDirs) {
try {
_logger.fine(
'Attempting to find/parse saved items JSON data in ${topLevelDir.path}');
final reader = FacebookArchiveFolderReader(topLevelDir.path);
final savedItemsResult = await reader.readSavedItems();
savedItemsResult.match(
onSuccess: (newSavedItem) {
allSavedItems.addAll(newSavedItem);
hadSuccess = true;
},
onError: (error) => _logger.fine(error));
} catch (e) {
_logger.severe('Exception thrown trying to read saved items, $e');
}
}
if (hadSuccess) {
return Result.ok(allSavedItems);
}
return Result.error(ExecError.message(
'Unable to find any saved items JSON files in $_baseArchiveFolder'));
}
FutureResult<FacebookAlbum, ExecError> _generatePostsAlbum() async {
const name = 'Photos in Posts';
const description = 'Photos that were added to posts';
final posts = await getPosts();
if (posts.isFailure) {
return Result.error(posts.error);
}
final photos = posts.value
.map((p) => p.mediaAttachments)
.expand((m) => m)
.where((m) => m.estimatedType() == FacebookAttachmentMediaType.image)
.toList();
photos
.sort((p1, p2) => p1.creationTimestamp.compareTo(p2.creationTimestamp));
final lastModified = photos.isEmpty ? 0 : photos.last.creationTimestamp;
final coverPhoto =
photos.isEmpty ? FacebookMediaAttachment.blank() : photos.last;
final album = FacebookAlbum(
name: name,
description: description,
lastModifiedTimestamp: lastModified,
coverPhoto: coverPhoto,
photos: photos,
comments: []);
return Result.ok(album);
}
String get _baseArchiveFolder => pathMappingService.rootFolder;
List<FileSystemEntity> get _topLevelDirs =>
pathMappingService.archiveDirectories;
}

View file

@ -0,0 +1,50 @@
import 'dart:convert';
import 'dart:io';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
final _facebookFileReadingLogger = Logger('File.FacebookFileReading');
extension FacebookFileReading on File {
FutureResult<String, ExecError> readFacebookEncodedFileAsString() async {
const leadingSlash = 92;
const leadingU = 117;
final data = await readAsBytes();
final buffer = StringBuffer();
int i = 0;
try {
while (i < data.length) {
if (data[i] == leadingSlash && data[i + 1] == leadingU) {
final byteBuffer = <int>[];
while (i < data.length - 1 &&
data[i] == leadingSlash &&
data[i + 1] == leadingU) {
final chars = data
.sublist(i + 2, i + 6)
.map((e) => e < 97 ? e - 48 : e - 87)
.toList(growable: false);
final byte = (chars[0] << 12) +
(chars[1] << 8) +
(chars[2] << 4) +
(chars[3]);
byteBuffer.add(byte);
i += 6;
}
final unicodeChar = utf8.decode(byteBuffer);
buffer.write(unicodeChar);
} else {
buffer.writeCharCode(data[i]);
i++;
}
}
} catch (e) {
_facebookFileReadingLogger.severe('Error parsing $path, $e');
return Result.error(ExecError(
exception: e as Exception, errorMessage: buffer.toString()));
}
return Result.ok(buffer.toString());
}
}

View file

@ -0,0 +1,121 @@
import 'dart:io';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
class PathMappingService {
static final _logger = Logger('$PathMappingService');
final SettingsController settings;
final _archiveDirectories = <FileSystemEntity>[];
PathMappingService(this.settings) {
refresh();
}
String get rootFolder => settings.rootFolder;
List<FileSystemEntity> get archiveDirectories =>
List.unmodifiable(_archiveDirectories);
void refresh() {
_logger.fine('Refreshing path mapping service directory data.');
if (!Directory(settings.rootFolder).existsSync()) {
_logger.severe(
"Base directory does not exist! can't do mapping of ${settings.rootFolder}");
return;
}
_archiveDirectories.clear();
try {
if (_calcRootIsSingleArchiveFolder()) {
_archiveDirectories.add(Directory(rootFolder));
return;
}
} catch (e) {
_logger
.severe('Error thrown while trying to calculate root structure: $e');
return;
}
_archiveDirectories.addAll(Directory(settings.rootFolder)
.listSync(recursive: false)
.where((element) =>
element.statSync().type == FileSystemEntityType.directory));
}
String toFullPath(String relPath) {
for (final file in _archiveDirectories) {
final fullPath = p.join(file.path, relPath);
if (File(fullPath).existsSync()) {
return fullPath;
}
}
_logger.fine(
'Did not find a file with this relPath anywhere therefore returning the relPath');
return relPath;
}
bool _calcRootIsSingleArchiveFolder() {
for (final entity in Directory(rootFolder).listSync(recursive: false)) {
if (_knownRootFilesAndFolders.contains(entity.uri.pathSegments
.where((element) => element.isNotEmpty)
.last)) {
return true;
}
}
return false;
}
static final _knownRootFilesAndFolders = [
"facebook_100000044480872.zip.enc",
"activity_messages",
"ads_information",
"apps_and_websites_off_of_facebook",
"bug_bounty",
"campus",
"comments_and_reactions",
"events",
"facebook_accounts_center",
"facebook_assistant",
"facebook_gaming",
"facebook_marketplace",
"facebook_news",
"facebook_payments",
"friends_and_followers",
"fundraisers",
"groups",
"journalist_registration",
"live_audio_rooms",
"location",
"messages",
"music_recommendations",
"news_feed",
"notifications",
"other_activity",
"other_logged_information",
"other_personal_information",
"pages",
"polls",
"posts",
"preferences",
"privacy_checkup",
"profile_information",
"reviews",
"saved_items_and_collections",
"search",
"security_and_login_information",
"shops_questions_&_answers",
"short_videos",
"soundbites",
"stories",
"volunteering",
"voting_location_and_reminders",
"your_interactions_on_facebook",
"your_places",
"your_problem_reports",
"your_topics",
];
}

View file

@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'friendica/screens/facebook_photo_album_browser_screen.dart';
import 'friendica/screens/facebook_posts_screen.dart';
import 'friendica/screens/facebook_stats_screen.dart';
import 'friendica/services/facebook_archive_reader.dart';
import 'settings/settings_controller.dart';
import 'settings/settings_view.dart';
class Home extends StatefulWidget {
final SettingsController settingsController;
const Home({Key? key, required this.settingsController}) : super(key: key);
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> {
static final Widget notInitialiedWidget = Container();
final List<AppPageData> _pageData = [];
final List<Widget> _pages = [];
int _selectedIndex = 0;
@override
void initState() {
_pageData.addAll([
AppPageData('Posts', Icons.home, () => const FacebookPostsScreen()),
AppPageData('Photos', Icons.photo_library,
() => const FacebookPhotoAlbumsBrowserScreen()),
AppPageData('Stats', Icons.bar_chart, () => const FacebookStatsScreen()),
AppPageData('Settings', Icons.settings, () => _buildSettingsView()),
]);
for (var i = 0; i < _pageData.length; i++) {
_pages.add(notInitialiedWidget);
}
if (FacebookArchiveFolderReader.validateCanReadArchive(
widget.settingsController.rootFolder)) {
_setSelectedIndex(0);
} else {
_setSelectedIndex(_pageData.length - 1);
}
super.initState();
}
@override
void dispose() {
_pages.clear();
super.dispose();
}
void _setSelectedIndex(int value) {
setState(() {
if (_pages[value] == notInitialiedWidget) {
_pages[value] = _pageData[value].widget;
}
_selectedIndex = value;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
_buildNavBar(),
SizedBox(width: 1, child: Container(color: Colors.grey)),
_buildMainArea(),
],
),
);
}
Widget _buildNavBar() {
return LayoutBuilder(builder: (context, constraint) {
return Scrollbar(
isAlwaysShown: true,
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraint.maxHeight),
child: IntrinsicHeight(
child: NavigationRail(
destinations:
_pageData.map((p) => p.navRailDestination).toList(),
selectedIndex: _selectedIndex,
onDestinationSelected: _setSelectedIndex,
labelType: NavigationRailLabelType.all,
),
),
),
),
);
});
}
Widget _buildMainArea() {
return Expanded(
child: IndexedStack(index: _selectedIndex, children: _pages));
}
Widget _buildSettingsView() {
return SettingsView(controller: widget.settingsController);
}
}
class AppPageData {
final String label;
final IconData icon;
final Widget Function() _widgetBuilder;
late final Widget widget = _widgetBuilder();
final NavigationRailDestination navRailDestination;
AppPageData(this.label, this.icon, widgetBuilder)
: _widgetBuilder = widgetBuilder,
navRailDestination =
NavigationRailDestination(icon: Icon(icon), label: Text(label));
}

View file

@ -1,6 +1,6 @@
{
"appTitle": "friendica_archive_browser",
"appTitle": "Kyanite",
"@appTitle": {
"description": "The title of the application"
"description": "A viewer of Facebook Archive Folders"
}
}

View file

@ -0,0 +1,29 @@
class StatBin {
static final DateTime noData = DateTime.fromMillisecondsSinceEpoch(0);
final DateTime? _binEpoch;
final int _index;
int _count;
DateTime get binEpoch => _binEpoch ?? noData;
bool get hasEpoch => _binEpoch != null;
int get count => _count;
int get index => _index;
StatBin({required index, DateTime? binEpoch, int initialCount = 0})
: _count = initialCount,
_index = index,
_binEpoch = binEpoch;
int increment({int amount = 1}) {
_count += amount;
return _count;
}
@override
String toString() {
return 'StatBin{index: $_index, binEpoch: $_binEpoch, count: $_count}';
}
}

View file

@ -0,0 +1,18 @@
class TimeElement {
final DateTime timestamp;
final bool hasImages;
final bool hasVideos;
final String text;
final String title;
TimeElement(
{int timeInMS = 0,
this.hasImages = false,
this.hasVideos = false,
this.text = '',
this.title = ''})
: timestamp = DateTime.fromMillisecondsSinceEpoch(timeInMS);
bool hasText(String phrase) =>
text.contains(phrase) || title.contains(phrase);
}

View file

@ -1,6 +0,0 @@
/// A placeholder class that represents an entity or model.
class SampleItem {
const SampleItem(this.id);
final int id;
}

View file

@ -1,20 +0,0 @@
import 'package:flutter/material.dart';
/// Displays detailed information about a SampleItem.
class SampleItemDetailsView extends StatelessWidget {
const SampleItemDetailsView({Key? key}) : super(key: key);
static const routeName = '/sample_item';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Item Details'),
),
body: const Center(
child: Text('More Information Here'),
),
);
}
}

View file

@ -1,71 +0,0 @@
import 'package:flutter/material.dart';
import '../settings/settings_view.dart';
import 'sample_item.dart';
import 'sample_item_details_view.dart';
/// Displays a list of SampleItems.
class SampleItemListView extends StatelessWidget {
const SampleItemListView({
Key? key,
this.items = const [SampleItem(1), SampleItem(2), SampleItem(3)],
}) : super(key: key);
static const routeName = '/';
final List<SampleItem> items;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sample Items'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
// Navigate to the settings page. If the user leaves and returns
// to the app after it has been killed while running in the
// background, the navigation stack is restored.
Navigator.restorablePushNamed(context, SettingsView.routeName);
},
),
],
),
// To work with lists that may contain a large number of items, its best
// to use the ListView.builder constructor.
//
// In contrast to the default ListView constructor, which requires
// building all Widgets up front, the ListView.builder constructor lazily
// builds Widgets as theyre scrolled into view.
body: ListView.builder(
// Providing a restorationId allows the ListView to restore the
// scroll position when a user leaves and returns to the app after it
// has been killed while running in the background.
restorationId: 'sampleItemListView',
itemCount: items.length,
itemBuilder: (BuildContext context, int index) {
final item = items[index];
return ListTile(
title: Text('SampleItem ${item.id}'),
leading: const CircleAvatar(
// Display the Flutter Logo image asset.
foregroundImage: AssetImage('assets/images/flutter_logo.png'),
),
onTap: () {
// Navigate to the details page. If the user leaves and returns to
// the app after it has been killed while running in the
// background, the navigation stack is restored.
Navigator.restorablePushNamed(
context,
SampleItemDetailsView.routeName,
);
}
);
},
),
);
}
}

View file

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:provider/provider.dart';
class ErrorScreen extends StatelessWidget {
final ExecError error;
final String title;
const ErrorScreen(
{Key? key, this.title = 'Error executing', required this.error})
: super(key: key);
@override
Widget build(BuildContext context) {
final logPath = Provider.of<SettingsController>(context).logPath;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline6,
softWrap: true,
),
const SizedBox(height: 5),
SelectableText('See logfile for more details: $logPath'),
const SizedBox(height: 5),
if (error.exception != null)
SelectableText('Error with exception: ${error.exception}'),
const SizedBox(height: 5),
if (error.errorMessage.isNotEmpty) SelectableText(error.errorMessage),
],
));
}
}

View file

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class LoadingStatusScreen extends StatelessWidget {
final String title;
final String subTitle;
const LoadingStatusScreen({Key? key, required this.title, this.subTitle = ''})
: super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(title, style: const TextStyle(fontSize: 18)),
const SizedBox(height: 20),
if (subTitle.isNotEmpty) ...[
Text(subTitle, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 20)
],
const CircularProgressIndicator()
]),
);
}
}

View file

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class StandInStatusScreen extends StatelessWidget {
final String title;
final String subTitle;
const StandInStatusScreen({Key? key, required this.title, this.subTitle = ''})
: super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 28),
softWrap: true,
),
const SizedBox(height: 5),
if (subTitle.isNotEmpty)
Text(
subTitle,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20),
softWrap: true,
),
],
));
}
}

View file

@ -1,43 +1,97 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/settings/video_player_settings.dart';
import 'package:friendica_archive_browser/src/utils/temp_file_builder.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'settings_service.dart';
/// A class that many Widgets can interact with to read user settings, update
/// user settings, or listen to user settings changes.
///
/// Controllers glue Data Services to Flutter Widgets. The SettingsController
/// uses the SettingsService to store and retrieve user settings.
class SettingsController with ChangeNotifier {
SettingsController(this._settingsService);
// Make SettingsService a private variable so it is not used directly.
final String logPath;
final SettingsService _settingsService;
// Make ThemeMode a private variable so it is not updated directly without
// also persisting the changes with the SettingsService.
late ThemeMode _themeMode;
SettingsController({required this.logPath})
: _settingsService = SettingsService();
// Allow Widgets to read the user's preferred ThemeMode.
ThemeMode get themeMode => _themeMode;
/// Load the user's settings from the SettingsService. It may load from a
/// local database or the internet. The controller only knows it can load the
/// settings from the service.
Future<void> loadSettings() async {
_themeMode = await _settingsService.themeMode();
// Important! Inform listeners a change has occurred.
_rootFolder = await _settingsService.rootFolder();
_videoPlayerSettingType = await _settingsService.videoPlayerSettingType();
_videoPlayerCommand = await _settingsService.videoPlayerCommand();
_dateTimeFormatter = DateFormat('MMMM dd yyyy h:mm a');
_dateFormatter = DateFormat('MMMM dd yyyy');
_logLevel = await _settingsService.logLevel();
_appDataDirectory = await getApplicationSupportDirectory();
_facebookName = await _settingsService.facebookName();
_geoCacheDirectory = await getTileCachedDirectory();
Logger.root.level = _logLevel;
notifyListeners();
}
/// Update and persist the ThemeMode based on the user's selection.
late Directory _geoCacheDirectory;
Directory get geoCacheDirectory => _geoCacheDirectory;
late Directory _appDataDirectory;
Directory get appDataDirectory => _appDataDirectory;
late Level _logLevel;
Level get logLevel => _logLevel;
Future<void> updateLogLevel(Level newLevel) async {
if (newLevel == _logLevel) return;
_logLevel = newLevel;
Logger.root.level = _logLevel;
await _settingsService.updateLevel(newLevel);
notifyListeners();
}
late DateFormat _dateTimeFormatter;
DateFormat get dateTimeFormatter => _dateTimeFormatter;
late DateFormat _dateFormatter;
DateFormat get dateFormatter => _dateFormatter;
late String _rootFolder;
String get rootFolder => _rootFolder;
Future<void> updateRootFolder(String newPath) async {
if (newPath == _rootFolder) return;
_rootFolder = newPath;
notifyListeners();
await _settingsService.updateRootFolder(newPath);
}
late String _facebookName;
String get facebookName => _facebookName;
Future<void> updateFacebookName(String newName) async {
if (newName == _facebookName) return;
_facebookName = newName;
notifyListeners();
await _settingsService.updateFacebookName(newName);
}
late ThemeMode _themeMode;
ThemeMode get themeMode => _themeMode;
Future<void> updateThemeMode(ThemeMode? newThemeMode) async {
if (newThemeMode == null) return;
// Do not perform any work if new and old ThemeMode are identical
// Dot not perform any work if new and old ThemeMode are identical
if (newThemeMode == _themeMode) return;
// Otherwise, store the new ThemeMode in memory
// Otherwise, store the new theme mode in memory
_themeMode = newThemeMode;
// Important! Inform listeners a change has occurred.
@ -47,4 +101,34 @@ class SettingsController with ChangeNotifier {
// SettingService.
await _settingsService.updateThemeMode(newThemeMode);
}
late VideoPlayerSettingType _videoPlayerSettingType;
VideoPlayerSettingType get videoPlayerSettingType => _videoPlayerSettingType;
Future<void> updateVideoPlayerSettingType(VideoPlayerSettingType type) async {
if (type == _videoPlayerSettingType) return;
_videoPlayerSettingType = type;
if (_videoPlayerSettingType != VideoPlayerSettingType.custom) {
await _resetVideoPlayerCommand();
}
notifyListeners();
await _settingsService.updateVideoPlayerSettingType(type);
}
late String _videoPlayerCommand;
String get videoPlayerCommand => _videoPlayerCommand;
Future<void> updateVideoPlayerCommand(String newCommand) async {
if (newCommand == _videoPlayerCommand) return;
_videoPlayerCommand = newCommand;
notifyListeners();
await _settingsService.updateVideoPlayerCommand(newCommand);
}
Future<void> _resetVideoPlayerCommand() async {
_videoPlayerCommand = _videoPlayerSettingType.toAppPath();
await _settingsService.updateVideoPlayerCommand(_videoPlayerCommand);
}
}

View file

@ -1,17 +1,122 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'video_player_settings.dart';
/// A service that stores and retrieves user settings.
///
/// By default, this class does not persist user settings. If you'd like to
/// persist the user settings locally, use the shared_preferences package. If
/// you'd like to store settings on a web server, use the http package.
class SettingsService {
/// Loads the User's preferred ThemeMode from local or remote storage.
Future<ThemeMode> themeMode() async => ThemeMode.system;
static const themeDarknessKey = 'themeDarkness';
static const rootFolderKey = 'rootFolder';
static const videoPlayerSettingTypeKey = 'videoPlayerSettingType';
static const videoPlayerCommandKey = 'videoPlayerCustomPath';
static const logLevelKey = "logLevel";
static const facebookNameKey = 'facebookName';
Future<Level> logLevel() async {
const defaultLevelIndex = 5; //INFO
final prefs = await SharedPreferences.getInstance();
final levelIndex = prefs.getInt(logLevelKey) ?? defaultLevelIndex;
if (levelIndex > Level.LEVELS.length - 1 || levelIndex < 0) {
return Level.INFO;
}
return Level.LEVELS[levelIndex];
}
Future<void> updateLevel(Level newLevel) async {
final prefs = await SharedPreferences.getInstance();
final index = Level.LEVELS.indexOf(newLevel);
prefs.setInt(logLevelKey, index);
}
Future<ThemeMode> themeMode() async {
final prefs = await SharedPreferences.getInstance();
final themeIndex = prefs.getInt(themeDarknessKey) ?? 0;
if (themeIndex > ThemeMode.values.length - 1 || themeIndex < 0) {
return ThemeMode.system;
}
return ThemeMode.values[themeIndex];
}
/// Persists the user's preferred ThemeMode to local or remote storage.
Future<void> updateThemeMode(ThemeMode theme) async {
// Use the shared_preferences package to persist settings locally or the
// http package to persist settings over the network.
final prefs = await SharedPreferences.getInstance();
prefs.setInt(themeDarknessKey, theme.index);
}
Future<String> rootFolder() async {
final prefs = await SharedPreferences.getInstance();
final result = prefs.getString(rootFolderKey) ?? '';
return result;
}
Future<void> updateRootFolder(String folder) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(rootFolderKey, folder);
}
Future<String> facebookName() async {
final prefs = await SharedPreferences.getInstance();
final result = prefs.getString(facebookNameKey) ?? '';
return result;
}
Future<void> updateFacebookName(String folder) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(facebookNameKey, folder);
}
Future<VideoPlayerSettingType> videoPlayerSettingType() async {
final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey(videoPlayerSettingTypeKey)) {
return _platformDefaultVideoType();
}
final type = prefs.getInt(videoPlayerSettingTypeKey) ?? 0;
if (type > VideoPlayerSettingType.values.length - 1 || type < 0) {
return _platformDefaultVideoType();
}
return VideoPlayerSettingType.values[type];
}
Future<void> updateVideoPlayerSettingType(
VideoPlayerSettingType videoPlayerType) async {
final prefs = await SharedPreferences.getInstance();
prefs.setInt(videoPlayerSettingTypeKey, videoPlayerType.index);
}
Future<String> videoPlayerCommand() async {
final prefs = await SharedPreferences.getInstance();
final result = prefs.getString(videoPlayerCommandKey);
if (result != null) {
return result;
}
final currentType = await videoPlayerSettingType();
return currentType.toAppPath();
}
Future<void> updateVideoPlayerCommand(String videoPlayerCommand) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(videoPlayerCommandKey, videoPlayerCommand);
}
VideoPlayerSettingType _platformDefaultVideoType() {
if (Platform.isWindows) {
return VideoPlayerSettingType.windows;
}
if (Platform.isMacOS) {
return VideoPlayerSettingType.macOS;
}
if (Platform.isLinux) {
return VideoPlayerSettingType.linuxVlc;
}
return VideoPlayerSettingType.custom;
}
}

View file

@ -1,35 +1,215 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_reader.dart';
import 'package:friendica_archive_browser/src/settings/video_player_settings.dart';
import 'package:friendica_archive_browser/src/utils/clipboard_helper.dart';
import 'package:friendica_archive_browser/src/utils/snackbar_status_builder.dart';
import 'package:logging/logging.dart';
import 'settings_controller.dart';
/// Displays the various settings that can be customized by the user.
///
/// When a user changes a setting, the SettingsController is updated and
/// Widgets that listen to the SettingsController are rebuilt.
class SettingsView extends StatelessWidget {
const SettingsView({Key? key, required this.controller}) : super(key: key);
class SettingsView extends StatefulWidget {
const SettingsView({Key? key, required SettingsController controller})
: _settingsController = controller,
super(key: key);
static const routeName = '/settings';
final SettingsController controller;
final SettingsController _settingsController;
@override
State<SettingsView> createState() => _SettingsViewState();
}
class _SettingsViewState extends State<SettingsView> {
static final _logger = Logger('$_SettingsViewState');
final _facebookNameController = TextEditingController();
final _folderPathController = TextEditingController();
final _videoPlayerPathController = TextEditingController();
String? _invalidFolderString;
VideoPlayerSettingType _videoPlayerTypeOption = VideoPlayerSettingType.custom;
bool _validRootFolder = false;
bool _differentSettingValues = false;
Level _logLevel = Level.SEVERE;
@override
void initState() {
_folderPathController.addListener(_validateRootFolder);
_facebookNameController.addListener(() {
_updateSettingsValueDiffs();
});
_videoPlayerPathController.addListener(() {
_updateSettingsValueDiffs();
});
_setInitialValues();
super.initState();
}
@override
Widget build(BuildContext context) {
_updateSettingsValueDiffs();
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
backgroundColor: Theme.of(context).canvasColor,
foregroundColor: Theme.of(context).primaryColor,
elevation: 0.0,
),
body: Padding(
padding: const EdgeInsets.all(16),
// Glue the SettingsController to the theme selection DropdownButton.
//
// When a user selects a theme from the dropdown list, the
// SettingsController is updated, which rebuilds the MaterialApp.
child: DropdownButton<ThemeMode>(
// Read the selected themeMode from the controller
value: controller.themeMode,
// Call the updateThemeMode method any time the user selects a theme.
onChanged: controller.updateThemeMode,
padding: const EdgeInsets.all(16.0),
child: Column(children: [
_buildThemeOptions(context),
const SizedBox(height: 10),
const Divider(),
const SizedBox(height: 10),
_buildLoggingOptions(context),
const SizedBox(height: 10),
_buildFacebookNameOptions(context),
const SizedBox(height: 10),
_buildLogFilePath(context),
const SizedBox(height: 10),
_buildGeocacheOptions(context),
const SizedBox(height: 10),
_buildRootFolderOption(context),
const SizedBox(height: 10),
_buildVideoPlayerOption(context),
const SizedBox(height: 10),
_buildSaveCancelButtonRow(),
]),
));
}
Widget _buildLoggingOptions(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('Logging Level: ', style: Theme.of(context).textTheme.bodyText1),
const SizedBox(width: 10),
DropdownButton<Level>(
value: _logLevel,
onChanged: (newLevel) async {
_logLevel = newLevel ?? Level.INFO;
setState(() {});
},
items: Level.LEVELS
.map((level) =>
DropdownMenuItem(value: level, child: Text(level.name)))
.toList()),
]);
}
Widget _buildLogFilePath(BuildContext context) {
final path = widget._settingsController.logPath;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('Log file: ', style: Theme.of(context).textTheme.bodyText1),
const SizedBox(width: 10),
Expanded(
child: Text(path,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyText2)),
const SizedBox(width: 10),
IconButton(
onPressed: () async {
await copyToClipboard(
context: context,
text: path,
snackbarMessage: 'Copied "$path" to clipboard');
},
icon: const Icon(Icons.copy)),
]);
}
Widget _buildGeocacheOptions(BuildContext context) {
final path = widget._settingsController.geoCacheDirectory.path;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('Map Tile Directory: ',
style: Theme.of(context).textTheme.bodyText1),
const SizedBox(width: 10),
Expanded(
child: Text(path,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyText2)),
const SizedBox(width: 10),
IconButton(
onPressed: () async {
try {
_logger.fine('Flushing tile cache folder: $path');
await Directory(path).delete(recursive: true);
Directory(path).createSync(recursive: true);
SnackBarStatusBuilder.buildSnackbar(
context, 'Geocache cleared');
_logger.fine('Tile cache cleared: $path');
} catch (e) {
_logger.severe('Error flushing tile cache: $e');
}
},
icon: const Icon(Icons.delete_sweep)),
]);
}
Widget _buildRootFolderOption(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('Archive Folder: ',
style: Theme.of(context).textTheme.bodyText1),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _folderPathController,
decoration: InputDecoration(
hintText:
'Root folder of the unzipped Facebook archive file',
errorText: _invalidFolderString,
))),
const SizedBox(width: 15),
IconButton(
onPressed: _setNewRootFolder,
icon: const Icon(Icons.folder_outlined)),
]);
}
Widget _buildFacebookNameOptions(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text("Facebook User's Name:",
style: Theme.of(context).textTheme.bodyText1),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _facebookNameController,
decoration: const InputDecoration(
hintText: 'Displayed user name (used for filtering titles)',
))),
]);
}
Widget _buildThemeOptions(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('Application Theme: ',
style: Theme.of(context).textTheme.bodyText1),
const SizedBox(width: 10),
DropdownButton<ThemeMode>(
value: widget._settingsController.themeMode,
onChanged: (newMode) async {
await widget._settingsController.updateThemeMode(newMode);
setState(() {});
},
items: const [
DropdownMenuItem(
value: ThemeMode.system,
@ -45,7 +225,149 @@ class SettingsView extends StatelessWidget {
)
],
),
),
],
);
}
Widget _buildVideoPlayerOption(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('Video Player: ', style: Theme.of(context).textTheme.bodyText1),
const SizedBox(width: 10),
DropdownButton<VideoPlayerSettingType>(
value: _videoPlayerTypeOption,
onChanged: (newPlayer) async {
setState(() {
_videoPlayerTypeOption =
newPlayer ?? VideoPlayerSettingType.custom;
_videoPlayerPathController.text =
_videoPlayerTypeOption.toAppPath();
});
},
items: VideoPlayerSettingType.values
.map((e) => e.toDropDownMenuItem())
.toList(),
),
const SizedBox(width: 10),
Expanded(
child: TextField(
enabled:
_videoPlayerTypeOption == VideoPlayerSettingType.custom,
controller: _videoPlayerPathController,
decoration: const InputDecoration(
hintText: 'Command to play videos',
))),
const SizedBox(width: 15),
IconButton(
onPressed: _setNewCustomPlayerPath,
icon: const Icon(Icons.folder_outlined)),
]);
}
Widget _buildSaveCancelButtonRow() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _differentSettingValues ? _saveSettings : null,
child: const Text('Save Settings')),
const SizedBox(width: 10),
ElevatedButton(
onPressed: _setInitialValues, child: const Text('Cancel Changes'))
],
);
}
Future<void> _saveSettings() async {
await widget._settingsController
.updateRootFolder(_folderPathController.text);
await widget._settingsController
.updateVideoPlayerSettingType(_videoPlayerTypeOption);
if (_videoPlayerTypeOption == VideoPlayerSettingType.custom) {
await widget._settingsController
.updateVideoPlayerCommand(_videoPlayerPathController.text);
}
await widget._settingsController.updateLogLevel(_logLevel);
await widget._settingsController
.updateFacebookName(_facebookNameController.text);
setState(() {});
}
void _setInitialValues() {
_folderPathController.text = widget._settingsController.rootFolder;
_validateRootFolder();
_videoPlayerTypeOption = widget._settingsController.videoPlayerSettingType;
_videoPlayerPathController.text =
widget._settingsController.videoPlayerCommand;
_logLevel = widget._settingsController.logLevel;
_facebookNameController.text = widget._settingsController.facebookName;
}
void _updateSettingsValueDiffs() {
bool oldValue = _differentSettingValues;
bool newValue = false;
newValue |=
(_folderPathController.text != widget._settingsController.rootFolder &&
_validRootFolder);
newValue |= (_videoPlayerTypeOption !=
widget._settingsController.videoPlayerSettingType);
newValue |= (_videoPlayerPathController.text !=
widget._settingsController.videoPlayerCommand);
newValue |= (_logLevel != widget._settingsController.logLevel);
newValue |= (_facebookNameController.text !=
widget._settingsController.facebookName);
if (oldValue == newValue) return;
setState(() {
_differentSettingValues = newValue;
});
}
void _validateRootFolder() {
setState(() {
_validRootFolder = false;
if (!Directory(_folderPathController.text).existsSync()) {
_invalidFolderString = 'Choose an existing folder';
return;
}
if (!FacebookArchiveFolderReader.validateCanReadArchive(
_folderPathController.text)) {
_invalidFolderString =
'Choose a folder that is a Facebook Archive and accessible.\nOn Macs make sure root folder is in Downloads directory.';
return;
}
_invalidFolderString = null;
_validRootFolder = true;
});
}
void _setNewRootFolder() async {
final path = await FilePicker.platform.getDirectoryPath();
if (path == null) {
return;
}
setState(() {
_folderPathController.text = path;
});
}
void _setNewCustomPlayerPath() async {
final picked = await FilePicker.platform.pickFiles(
dialogTitle: 'Pick Video player',
type: FileType.any,
allowMultiple: false);
if (picked == null || picked.paths.isEmpty) {
return;
}
setState(() {
_videoPlayerPathController.text = picked.paths.first ?? '';
});
}
}

View file

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
enum VideoPlayerSettingType {
windows, //
macOS, //open
linuxVlc, //vlc
linuxTotem, //totem
linukMpv, //gnome-mpv
custom,
}
extension VideoPathMapping on VideoPlayerSettingType {
String toAppPath() {
switch (this) {
case VideoPlayerSettingType.custom:
return '';
case VideoPlayerSettingType.linuxVlc:
return 'vlc';
case VideoPlayerSettingType.linuxTotem:
return 'totem';
case VideoPlayerSettingType.linukMpv:
return 'gnome-mpv';
case VideoPlayerSettingType.macOS:
return 'open';
case VideoPlayerSettingType.windows:
return 'C:\\Program Files\\Windows Media Player\\wmplayer.exe';
}
}
DropdownMenuItem<VideoPlayerSettingType> toDropDownMenuItem() {
switch (this) {
case VideoPlayerSettingType.custom:
return const DropdownMenuItem(
value: VideoPlayerSettingType.custom,
child: Text('Custom'),
);
case VideoPlayerSettingType.linuxVlc:
return const DropdownMenuItem(
value: VideoPlayerSettingType.linuxVlc,
child: Text('VLC (Linux)'),
);
case VideoPlayerSettingType.linuxTotem:
return const DropdownMenuItem(
value: VideoPlayerSettingType.linuxTotem,
child: Text('Totem (Linux)'),
);
case VideoPlayerSettingType.linukMpv:
return const DropdownMenuItem(
value: VideoPlayerSettingType.linukMpv,
child: Text('MPV (Linux)'),
);
case VideoPlayerSettingType.macOS:
return const DropdownMenuItem(
value: VideoPlayerSettingType.macOS,
child: Text('macOS'),
);
case VideoPlayerSettingType.windows:
return const DropdownMenuItem(
value: VideoPlayerSettingType.windows,
child: Text('Windows'),
);
}
}
}

View file

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
class FriendicaArchiveBrowserTheme {
static ThemeData dark = ThemeData.dark().copyWith(
primaryColor: Colors.white,
);
static ThemeData light = ThemeData.light().copyWith(
primaryColor: Colors.black,
);
static ThemeData darkroom = dark.copyWith(
appBarTheme: const AppBarTheme(
backgroundColor: Colors.black,
),
scaffoldBackgroundColor: Colors.black,
floatingActionButtonTheme: const FloatingActionButtonThemeData(
foregroundColor: Colors.white,
backgroundColor: Colors.indigo,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
selectedItemColor: Colors.green,
),
);
}

View file

@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:friendica_archive_browser/src/utils/snackbar_status_builder.dart';
Future<void> copyToClipboard(
{required BuildContext context,
required String text,
required String snackbarMessage}) async {
await Clipboard.setData(ClipboardData(text: text));
SnackBarStatusBuilder.buildSnackbar(context, snackbarMessage);
}

View file

@ -0,0 +1,23 @@
import 'package:result_monad/result_monad.dart';
class ExecError {
final int errorCode;
final Object? exception;
final String errorMessage;
@override
String toString() {
return 'ExecError{\n errorCode: $errorCode,\n exception: $exception,\n errorMessage: $errorMessage\n}';
}
ExecError({this.errorCode = -1, this.errorMessage = '', this.exception});
ExecError.message(this.errorMessage)
: errorCode = 0,
exception = null;
}
extension ResultToExecError<T> on Result<T, dynamic> {
Result<T, ExecError> mapExceptionErrorToExecError() =>
mapError((error) => ExecError(exception: error));
}

View file

@ -0,0 +1,11 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class FacebookAppScrollingBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
};
}

View file

@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
class SnackBarStatusBuilder {
static Future<void> buildSnackbar(BuildContext context, String message,
{int durationSec = 10}) async {
final snackBar = SnackBar(
content: SelectableText(message),
duration: Duration(seconds: durationSec),
action: SnackBarAction(
label: 'Dismiss',
onPressed: () =>
ScaffoldMessenger.of(context).hideCurrentSnackBar()));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}

View file

@ -0,0 +1,36 @@
import 'dart:io';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
Future<String> getTempFile(String prefix, String extension) async {
final tempDirPath = await customGetTempDirectory();
final dateString = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
return '$tempDirPath$prefix$dateString$extension';
}
Future<String> customGetTempDirectory() async {
if (Platform.isMacOS) {
final tempDirPathFromEnv = Platform.environment['TMPDIR'];
if (tempDirPathFromEnv != null) {
return tempDirPathFromEnv;
}
}
final tempDirPath = await getTemporaryDirectory();
return tempDirPath.path + Platform.pathSeparator;
}
Future<Directory> getTileCachedDirectory() async {
final base = await getApplicationSupportDirectory();
final cachePath = p.join(base.path, 'geocache');
final cacheDir = Directory(cachePath);
await cacheDir.create(recursive: true);
return cacheDir;
}
File getTileCachedFile(Directory cacheDirectory, String filename) {
final path = p.join(cacheDirectory.path, filename);
return File(path);
}

View file

@ -0,0 +1,78 @@
import 'package:friendica_archive_browser/src/models/stat_bin.dart';
import 'package:friendica_archive_browser/src/models/time_element.dart';
class TimeStatGenerator {
final List<TimeElement> _elements;
TimeStatGenerator(Iterable<TimeElement> items) : _elements = items.toList() {
_elements.sort((e1, e2) => e1.timestamp.compareTo(e2.timestamp));
}
List<TimeElement> get sortedElements => List.unmodifiable(_elements);
List<StatBin> calculateDailyStats() {
final result = <StatBin>[];
final interimBins = <DateTime, int>{};
for (final element in _elements) {
final day = element.timestamp.toDayOnly();
final currentSum = interimBins[day] ?? 0;
interimBins[day] = currentSum + 1;
}
for (final bin in interimBins.entries) {
result.add(StatBin(index: 0, binEpoch: bin.key, initialCount: bin.value));
}
result.sort((a, b) => a.binEpoch.compareTo(b.binEpoch));
return result;
}
List<StatBin> calculateByDayOfWeekStats() => _calculateStats(
binCount: 7,
elementToTimeIndex: (e) => e.timestamp.weekday,
timeIndexToArrayIndex: (ti) => ti - 1,
arrayIndexToTimeIndex: (ai) => ai + 1);
List<StatBin> calculateByMonthStats() => _calculateStats(
binCount: 12,
elementToTimeIndex: (e) => e.timestamp.month,
timeIndexToArrayIndex: (ti) => ti - 1,
arrayIndexToTimeIndex: (ai) => ai + 1);
List<StatBin> calculateStatsByYear() {
if (_elements.isEmpty) {
return [];
}
final earliestYear = _elements.first.timestamp.year;
final latestYear = _elements.last.timestamp.year;
final binCount = latestYear - earliestYear + 1;
return _calculateStats(
binCount: binCount,
elementToTimeIndex: (e) => e.timestamp.year,
timeIndexToArrayIndex: (ti) => ti - earliestYear,
arrayIndexToTimeIndex: (ai) => ai + earliestYear);
}
List<StatBin> _calculateStats(
{required int binCount,
required int Function(TimeElement) elementToTimeIndex,
required int Function(int) timeIndexToArrayIndex,
required int Function(int) arrayIndexToTimeIndex}) {
final bins = List.generate(binCount, (index) {
final timeIndex = arrayIndexToTimeIndex(index);
return StatBin(index: timeIndex);
});
for (final e in _elements) {
final arrayIndex = timeIndexToArrayIndex(elementToTimeIndex(e));
bins[arrayIndex].increment();
}
return bins;
}
}
extension DateTimeToDateOnly on DateTime {
DateTime toDayOnly() => DateTime(year, month, day);
}

View file

@ -0,0 +1,139 @@
import 'dart:math';
class WordMapGenerator {
final _words = <String, int>{};
final int minimumWordSize;
final Set<String> _filterWords;
WordMapGenerator({Set<String>? filterWords, this.minimumWordSize = 1})
: _filterWords = filterWords ?? <String>{};
WordMapGenerator.withCommonWordsFilter({this.minimumWordSize = 1})
: _filterWords = commonWords;
void clear() {
_words.clear();
}
void processEntry(String text) {
final wordsFromText = text
.toLowerCase()
.replaceAll(RegExp(r'[^\w]+'), ' ')
.replaceAll(RegExp(r'[_]+'), ' ')
.split(RegExp(r'\s+'))
.where((word) =>
word.length >= minimumWordSize && !_filterWords.contains(word));
for (final word in wordsFromText) {
final oldCount = _words[word] ?? 0;
_words[word] = oldCount + 1;
}
}
List<WordMapItem> getTopList(int threshold) {
if (_words.isEmpty) {
return [];
}
final entries =
_words.entries.map((e) => WordMapItem(e.key, e.value)).toList();
entries.sort((e1, e2) => e2.count.compareTo(e1.count));
return entries.getRange(0, min(entries.length, threshold)).toList();
}
}
class WordMapItem {
final String word;
final int count;
WordMapItem(this.word, this.count);
@override
String toString() {
return 'WordMapItem{word: $word, count: $count}';
}
}
const commonWords = {
'does',
'aren',
'did',
'the',
'and',
'for',
'com',
'you',
'are',
'www',
'but',
'not',
'was',
'all',
'can',
'out',
'one',
'how',
'his',
'him',
'she',
'her',
'don',
'has',
'had',
'why',
'who',
'too',
'let',
'may',
'isn',
'far',
'utm',
'yet',
'that',
'this',
'http',
'https',
'html',
'htm',
'with',
'they',
'like',
'from',
'about',
'just',
'what',
'their',
'when',
'will',
'even',
'there'
'their',
'than',
'more',
'them',
'these',
'been',
'would',
'there',
'into',
'only',
'still',
'which',
'your',
'have',
'because',
'much',
'didn',
'back',
'were',
'then',
'very',
'many'
'maybe'
'here',
'ever',
'doesn',
'every',
'having',
'already',
'some',
};

View file

@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10)
project(runner LANGUAGES CXX)
set(BINARY_NAME "friendica_archive_browser")
set(APPLICATION_ID "com.example.friendica_archive_browser")
set(APPLICATION_ID "social.myportal.friendica_archive_browser")
cmake_policy(SET CMP0063 NEW)

View file

@ -6,6 +6,14 @@
#include "generated_plugin_registrant.h"
#include <desktop_window/desktop_window_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) desktop_window_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWindowPlugin");
desktop_window_plugin_register_with_registrar(desktop_window_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View file

@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
desktop_window
url_launcher_linux
)
set(PLUGIN_BUNDLED_LIBRARIES)

View file

@ -40,14 +40,14 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "friendica_archive_browser");
gtk_header_bar_set_title(header_bar, "Kyanite");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "friendica_archive_browser");
gtk_window_set_title(window, "Kyanite");
}
gtk_window_set_default_size(window, 1280, 720);
gtk_window_set_default_size(window, 900, 700);
gtk_widget_show(GTK_WIDGET(window));
g_autoptr(FlDartProject) project = fl_dart_project_new();

View file

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View file

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View file

@ -5,6 +5,14 @@
import FlutterMacOS
import Foundation
import desktop_window
import path_provider_macos
import shared_preferences_macos
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DesktopWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWindowPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View file

@ -0,0 +1,40 @@
platform :osx, '10.11'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end

View file

@ -0,0 +1,40 @@
PODS:
- desktop_window (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- path_provider_macos (0.0.1):
- FlutterMacOS
- shared_preferences_macos (0.0.1):
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- desktop_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_window/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`)
- shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
EXTERNAL SOURCES:
desktop_window:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_window/macos
FlutterMacOS:
:path: Flutter/ephemeral
path_provider_macos:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos
shared_preferences_macos:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
desktop_window: fb7c4f12c1129f947ac482296b6f14059d57a3c3
FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
path_provider_macos: 160cab0d5461f0c0e02995469a98f24bdb9a3f1f
shared_preferences_macos: 480ce071d0666e37cef23fe6c702293a3d21799e
url_launcher_macos: 45af3d61de06997666568a7149c1be98b41c95d4
PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c
COCOAPODS: 1.10.2

View file

@ -26,6 +26,7 @@
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
4BDE3286C73FDA8B999E5FF1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A62DF101BD155ACE6A97EE5 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -54,7 +55,7 @@
/* Begin PBXFileReference section */
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* friendica_archive_browser.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "friendica_archive_browser.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10ED2044A3C60003C045 /* Kyanite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Facebook Archive Viewer.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@ -66,8 +67,13 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
4BB1ABD4272E2E48001A21BE /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
5A62DF101BD155ACE6A97EE5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
65CE868E4C57844CD2D62123 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
8FA4A7CF34D8F959E50C03F8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
982DE8DD39E855D2451A342E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -75,12 +81,23 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4BDE3286C73FDA8B999E5FF1 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1AD654E9D11F7EC5F226D2B4 /* Pods */ = {
isa = PBXGroup;
children = (
982DE8DD39E855D2451A342E /* Pods-Runner.debug.xcconfig */,
65CE868E4C57844CD2D62123 /* Pods-Runner.release.xcconfig */,
8FA4A7CF34D8F959E50C03F8 /* Pods-Runner.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
33BA886A226E78AF003329D5 /* Configs */ = {
isa = PBXGroup;
children = (
@ -99,13 +116,14 @@
33CEB47122A05771004F2AC0 /* Flutter */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
1AD654E9D11F7EC5F226D2B4 /* Pods */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* friendica_archive_browser.app */,
33CC10ED2044A3C60003C045 /* Kyanite.app */,
);
name = Products;
sourceTree = "<group>";
@ -135,6 +153,7 @@
33FAB671232836740065AC1E /* Runner */ = {
isa = PBXGroup;
children = (
4BB1ABD4272E2E48001A21BE /* RunnerDebug.entitlements */,
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
@ -148,6 +167,7 @@
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
5A62DF101BD155ACE6A97EE5 /* Pods_Runner.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -159,11 +179,13 @@
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
433B9574F7E94075D058E585 /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
44C7D6FFFB0D86BDDC27CD29 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@ -172,7 +194,7 @@
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* friendica_archive_browser.app */;
productReference = 33CC10ED2044A3C60003C045 /* Kyanite.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@ -182,7 +204,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1300;
LastUpgradeCheck = 0930;
ORGANIZATIONNAME = "";
TargetAttributes = {
33CC10EC2044A3C60003C045 = {
@ -270,6 +292,45 @@
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
433B9574F7E94075D058E585 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
44C7D6FFFB0D86BDDC27CD29 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -484,7 +545,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1000"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "friendica_archive_browser.app"
BuildableName = "Kyanite.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@ -31,7 +31,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "friendica_archive_browser.app"
BuildableName = "Kyanite.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@ -54,7 +54,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "friendica_archive_browser.app"
BuildableName = "Kyanite.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@ -71,7 +71,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "friendica_archive_browser.app"
BuildableName = "Kyanite.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>

View file

@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View file

@ -3,61 +3,61 @@
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_16.png",
"filename" : "fba_app_icon_16.png",
"scale" : "1x"
},
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"filename" : "fba_app_icon_32.png",
"scale" : "2x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"filename" : "fba_app_icon_32.png",
"scale" : "1x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_64.png",
"filename" : "fba_app_icon_64.png",
"scale" : "2x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_128.png",
"filename" : "fba_app_icon_128.png",
"scale" : "1x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"filename" : "fba_app_icon_256.png",
"scale" : "2x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"filename" : "fba_app_icon_256.png",
"scale" : "1x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"filename" : "fba_app_icon_512.png",
"scale" : "2x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"filename" : "fba_app_icon_512.png",
"scale" : "1x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_1024.png",
"filename" : "fba_app_icon_1024.png",
"scale" : "2x"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="19455" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14490.70"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="19455"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@ -13,7 +13,7 @@
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="Runner" customModuleProvider="target">
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="friendica_archive_browser" customModuleProvider="target">
<connections>
<outlet property="applicationMenu" destination="uQy-DD-JDr" id="XBo-yE-nKs"/>
<outlet property="mainFlutterWindow" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
@ -326,14 +326,15 @@
</items>
<point key="canvasLocation" x="142" y="-258"/>
</menu>
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="friendica_archive_browser" customModuleProvider="target">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
<rect key="contentRect" x="0.0" y="175" width="915" height="700"/>
<rect key="screenRect" x="0.0" y="0.0" width="1440" height="875"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
<rect key="frame" x="0.0" y="0.0" width="915" height="700"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<point key="canvasLocation" x="139" y="401"/>
</window>
</objects>
</document>

View file

@ -8,7 +8,7 @@
PRODUCT_NAME = friendica_archive_browser
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.friendicaArchiveBrowser
PRODUCT_BUNDLE_IDENTIFIER = social.myportal.friendica_archive_browser
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2022 com.example. All rights reserved.
PRODUCT_COPYRIGHT = Copyright © 2021 Hank G. All rights reserved.

View file

@ -4,9 +4,17 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.assets.movies.read-write</key>
<true/>
<key>com.apple.security.assets.music.read-write</key>
<true/>
<key>com.apple.security.assets.pictures.read-write</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

View file

@ -4,5 +4,17 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.assets.movies.read-write</key>
<true/>
<key>com.apple.security.assets.music.read-write</key>
<true/>
<key>com.apple.security.assets.pictures.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.assets.movies.read-write</key>
<true/>
<key>com.apple.security.assets.music.read-write</key>
<true/>
<key>com.apple.security.assets.pictures.read-write</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

View file

@ -29,6 +29,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
charts_common:
dependency: transitive
description:
name: charts_common
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.0"
charts_flutter:
dependency: "direct main"
description:
name: charts_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.0"
clock:
dependency: transitive
description:
@ -43,6 +57,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.0"
crypto:
dependency: transitive
description:
name: crypto
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
csslib:
dependency: transitive
description:
name: csslib
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.1"
desktop_window:
dependency: "direct main"
description:
name: desktop_window
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.0"
fake_async:
dependency: transitive
description:
@ -50,6 +85,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
ffi:
dependency: transitive
description:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.2"
file:
dependency: transitive
description:
name: file
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.2"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.7"
flutter:
dependency: "direct main"
description: flutter
@ -67,18 +123,65 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl:
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
html:
dependency: transitive
description:
name: html
url: "https://pub.dartlang.org"
source: hosted
version: "0.15.0"
http:
dependency: transitive
description:
name: http
url: "https://pub.dartlang.org"
source: hosted
version: "0.13.4"
http_parser:
dependency: transitive
description:
name: http_parser
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
intl:
dependency: "direct main"
description:
name: intl
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.0"
js:
dependency: transitive
description:
name: js
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.3"
latlng:
dependency: "direct main"
description:
name: latlng
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
lints:
dependency: transitive
description:
@ -86,6 +189,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
logging:
dependency: "direct main"
description:
name: logging
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
map:
dependency: "direct main"
description:
name: map
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
matcher:
dependency: transitive
description:
@ -100,13 +217,188 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.0"
path:
metadata_fetch:
dependency: "direct main"
description:
name: metadata_fetch
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.1"
multi_split_view:
dependency: "direct main"
description:
name: multi_split_view
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0+1"
nested:
dependency: transitive
description:
name: nested
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
network_to_file_image:
dependency: "direct main"
description:
name: network_to_file_image
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.3"
path:
dependency: "direct main"
description:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
path_provider:
dependency: "direct main"
description:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.9"
path_provider_ios:
dependency: transitive
description:
name: path_provider_ios
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
path_provider_macos:
dependency: transitive
description:
name: path_provider_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
platform:
dependency: transitive
description:
name: platform
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
process:
dependency: transitive
description:
name: process
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.4"
provider:
dependency: "direct main"
description:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.1"
result_monad:
dependency: "direct main"
description:
name: result_monad
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
scrollable_positioned_list:
dependency: "direct main"
description:
name: scrollable_positioned_list
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.3"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.10"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.9"
shared_preferences_ios:
dependency: transitive
description:
name: shared_preferences_ios
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
shared_preferences_macos:
dependency: transitive
description:
name: shared_preferences_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
sky_engine:
dependency: transitive
description: flutter
@ -140,6 +432,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
string_validator:
dependency: transitive
description:
name: string_validator
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
term_glyph:
dependency: transitive
description:
@ -161,6 +460,69 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.17"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.13"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.13"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
uuid:
dependency: "direct main"
description:
name: uuid
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.5"
vector_math:
dependency: transitive
description:
@ -168,5 +530,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
sdks:
dart: ">=2.15.1 <3.0.0"
dart: ">=2.14.4 <3.0.0"
flutter: ">=2.5.0"

Some files were not shown because too many files have changed in this diff Show more