mirror of
https://gitlab.com/mysocialportal/fediverse-archiving-tools.git
synced 2024-10-18 08:53:31 +00:00
Initial port of Kyanite into Friendica Archive Browser naming
This commit is contained in:
parent
8066a3439d
commit
9bf45e42ba
139 changed files with 10023 additions and 334 deletions
373
LICENSE
Normal file
373
LICENSE
Normal 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.
|
|
@ -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
|
||||
|
|
33
friendica_archive_browser/CHANGELOG.md
Normal file
33
friendica_archive_browser/CHANGELOG.md
Normal 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.
|
|
@ -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).
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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(),
|
||||
))));
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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]!);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
))));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 = '';
|
||||
}
|
|
@ -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')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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()}';
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
enum FacebookTimelineType {
|
||||
active,
|
||||
archive,
|
||||
trash,
|
||||
}
|
|
@ -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');
|
|
@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)'),
|
||||
])),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)));
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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']);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
];
|
||||
}
|
119
friendica_archive_browser/lib/src/home.dart
Normal file
119
friendica_archive_browser/lib/src/home.dart
Normal 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));
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"appTitle": "friendica_archive_browser",
|
||||
"appTitle": "Kyanite",
|
||||
"@appTitle": {
|
||||
"description": "The title of the application"
|
||||
"description": "A viewer of Facebook Archive Folders"
|
||||
}
|
||||
}
|
||||
|
|
29
friendica_archive_browser/lib/src/models/stat_bin.dart
Normal file
29
friendica_archive_browser/lib/src/models/stat_bin.dart
Normal 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}';
|
||||
}
|
||||
}
|
18
friendica_archive_browser/lib/src/models/time_element.dart
Normal file
18
friendica_archive_browser/lib/src/models/time_element.dart
Normal 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);
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
/// A placeholder class that represents an entity or model.
|
||||
class SampleItem {
|
||||
const SampleItem(this.id);
|
||||
|
||||
final int id;
|
||||
}
|
|
@ -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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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, it’s 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 they’re 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,
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
38
friendica_archive_browser/lib/src/screens/error_screen.dart
Normal file
38
friendica_archive_browser/lib/src/screens/error_screen.dart
Normal 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),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
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,
|
||||
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.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 ?? '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
25
friendica_archive_browser/lib/src/themes.dart
Normal file
25
friendica_archive_browser/lib/src/themes.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
23
friendica_archive_browser/lib/src/utils/exec_error.dart
Normal file
23
friendica_archive_browser/lib/src/utils/exec_error.dart
Normal 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));
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
139
friendica_archive_browser/lib/src/utils/word_map_generator.dart
Normal file
139
friendica_archive_browser/lib/src/utils/word_map_generator.dart
Normal 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',
|
||||
};
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
desktop_window
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
40
friendica_archive_browser/macos/Podfile
Normal file
40
friendica_archive_browser/macos/Podfile
Normal 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
|
40
friendica_archive_browser/macos/Podfile.lock
Normal file
40
friendica_archive_browser/macos/Podfile.lock
Normal 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
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -4,4 +4,7 @@
|
|||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
|
@ -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 |
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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
Loading…
Reference in a new issue