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.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b
|
revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b
|
||||||
channel: stable
|
channel: stable
|
||||||
|
|
||||||
project_type: app
|
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
|
To install Kyanite you simply have to download the latest release from
|
||||||
[simple app state management
|
[the project release directory](https://gitlab.com/HankG/mysocialportal/-/releases)
|
||||||
tutorial](https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple).
|
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
|
## Building
|
||||||
[online documentation](https://flutter.dev/docs), which offers tutorials,
|
In order to build this application you will need to have installed [Flutter](https://flutter.dev).
|
||||||
samples, guidance on mobile development, and a full API reference.
|
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
|
On Mac:
|
||||||
include with your application.
|
```bash
|
||||||
|
flutter run -d macos
|
||||||
|
```
|
||||||
|
|
||||||
The `assets/images` directory contains [resolution-aware
|
On Windows:
|
||||||
images](https://flutter.dev/docs/development/ui/assets-and-images#resolution-aware).
|
```bash
|
||||||
|
flutter run -d windows
|
||||||
|
```
|
||||||
|
|
||||||
## Localization
|
Please report any bugs or feature requests [with our issue tracker](https://gitlab.com/HankG/mysocialportal/-/issues).
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
|
@ -1,20 +1,33 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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/app.dart';
|
||||||
import 'src/settings/settings_controller.dart';
|
import 'src/settings/settings_controller.dart';
|
||||||
import 'src/settings/settings_service.dart';
|
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
// Set up the SettingsController, which will glue user settings to multiple
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// Flutter Widgets.
|
final logPath = await setupLogging();
|
||||||
final settingsController = SettingsController(SettingsService());
|
Logger.root.info('Starting Facebook Archive Viewer');
|
||||||
|
final settingsController = SettingsController(logPath: logPath);
|
||||||
// Load the user's preferred theme while the splash screen is displayed.
|
|
||||||
// This prevents a sudden theme change when the app is first displayed.
|
|
||||||
await settingsController.loadSettings();
|
await settingsController.loadSettings();
|
||||||
|
runApp(FriendicaArchiveBrowser(settingsController: settingsController));
|
||||||
// Run the app and pass in the SettingsController. The app listens to the
|
}
|
||||||
// SettingsController for changes, then passes it further down to the
|
|
||||||
// SettingsView.
|
Future<String> setupLogging() async {
|
||||||
runApp(MyApp(settingsController: settingsController));
|
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/material.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
import 'package:flutter_localizations/flutter_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 'friendica/services/facebook_archive_service.dart';
|
||||||
import 'sample_feature/sample_item_list_view.dart';
|
import 'friendica/services/path_mapping_service.dart';
|
||||||
|
import 'home.dart';
|
||||||
import 'settings/settings_controller.dart';
|
import 'settings/settings_controller.dart';
|
||||||
import 'settings/settings_view.dart';
|
|
||||||
|
|
||||||
/// The Widget that configures your application.
|
/// The Widget that configures your application.
|
||||||
class MyApp extends StatelessWidget {
|
class FriendicaArchiveBrowser extends StatelessWidget {
|
||||||
const MyApp({
|
static const minAppSize = Size(915, 700);
|
||||||
|
|
||||||
|
const FriendicaArchiveBrowser({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.settingsController,
|
required this.settingsController,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
@ -18,23 +24,20 @@ class MyApp extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Glue the SettingsController to the MaterialApp.
|
DesktopWindow.setMinWindowSize(minAppSize);
|
||||||
//
|
final pathMappingService = PathMappingService(settingsController);
|
||||||
// The AnimatedBuilder Widget listens to the SettingsController for changes.
|
final archiveService = FacebookArchiveDataService(
|
||||||
// Whenever the user updates their settings, the MaterialApp is rebuilt.
|
pathMappingService: pathMappingService,
|
||||||
|
appDataDirectory: settingsController.appDataDirectory.path);
|
||||||
|
settingsController.addListener(() {
|
||||||
|
archiveService.clearCaches();
|
||||||
|
pathMappingService.refresh();
|
||||||
|
});
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: settingsController,
|
animation: settingsController,
|
||||||
builder: (BuildContext context, Widget? child) {
|
builder: (BuildContext context, Widget? child) {
|
||||||
return MaterialApp(
|
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',
|
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 [
|
localizationsDelegates: const [
|
||||||
AppLocalizations.delegate,
|
AppLocalizations.delegate,
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
@ -44,40 +47,20 @@ class MyApp extends StatelessWidget {
|
||||||
supportedLocales: const [
|
supportedLocales: const [
|
||||||
Locale('en', ''), // English, no country code
|
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) =>
|
onGenerateTitle: (BuildContext context) =>
|
||||||
AppLocalizations.of(context)!.appTitle,
|
AppLocalizations.of(context)!.appTitle,
|
||||||
|
theme: FriendicaArchiveBrowserTheme.light,
|
||||||
// Define a light and dark color theme. Then, read the user's
|
darkTheme: FriendicaArchiveBrowserTheme.dark,
|
||||||
// preferred ThemeMode (light, dark, or system default) from the
|
|
||||||
// SettingsController to display the correct theme.
|
|
||||||
theme: ThemeData(),
|
|
||||||
darkTheme: ThemeData.dark(),
|
|
||||||
themeMode: settingsController.themeMode,
|
themeMode: settingsController.themeMode,
|
||||||
|
scrollBehavior: FacebookAppScrollingBehavior(),
|
||||||
// Define a function to handle named routes in order to support
|
home: MultiProvider(
|
||||||
// Flutter web url navigation and deep linking.
|
providers: [
|
||||||
onGenerateRoute: (RouteSettings routeSettings) {
|
ChangeNotifierProvider(create: (context) => settingsController),
|
||||||
return MaterialPageRoute<void>(
|
ChangeNotifierProvider(create: (context) => archiveService),
|
||||||
settings: routeSettings,
|
Provider(create: (context) => pathMappingService),
|
||||||
builder: (BuildContext context) {
|
],
|
||||||
switch (routeSettings.name) {
|
child: Home(settingsController: settingsController),
|
||||||
case SettingsView.routeName:
|
),
|
||||||
return SettingsView(controller: settingsController);
|
|
||||||
case SampleItemDetailsView.routeName:
|
|
||||||
return const SampleItemDetailsView();
|
|
||||||
case SampleItemListView.routeName:
|
|
||||||
default:
|
|
||||||
return const SampleItemListView();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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": {
|
"@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: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';
|
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 {
|
class SettingsController with ChangeNotifier {
|
||||||
SettingsController(this._settingsService);
|
final String logPath;
|
||||||
|
|
||||||
// Make SettingsService a private variable so it is not used directly.
|
|
||||||
final SettingsService _settingsService;
|
final SettingsService _settingsService;
|
||||||
|
|
||||||
// Make ThemeMode a private variable so it is not updated directly without
|
SettingsController({required this.logPath})
|
||||||
// also persisting the changes with the SettingsService.
|
: _settingsService = SettingsService();
|
||||||
late ThemeMode _themeMode;
|
|
||||||
|
|
||||||
// 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 {
|
Future<void> loadSettings() async {
|
||||||
_themeMode = await _settingsService.themeMode();
|
_themeMode = await _settingsService.themeMode();
|
||||||
|
_rootFolder = await _settingsService.rootFolder();
|
||||||
// Important! Inform listeners a change has occurred.
|
_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();
|
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 {
|
Future<void> updateThemeMode(ThemeMode? newThemeMode) async {
|
||||||
if (newThemeMode == null) return;
|
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;
|
if (newThemeMode == _themeMode) return;
|
||||||
|
|
||||||
// Otherwise, store the new ThemeMode in memory
|
// Otherwise, store the new theme mode in memory
|
||||||
_themeMode = newThemeMode;
|
_themeMode = newThemeMode;
|
||||||
|
|
||||||
// Important! Inform listeners a change has occurred.
|
// Important! Inform listeners a change has occurred.
|
||||||
|
@ -47,4 +101,34 @@ class SettingsController with ChangeNotifier {
|
||||||
// SettingService.
|
// SettingService.
|
||||||
await _settingsService.updateThemeMode(newThemeMode);
|
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: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 {
|
class SettingsService {
|
||||||
/// Loads the User's preferred ThemeMode from local or remote storage.
|
static const themeDarknessKey = 'themeDarkness';
|
||||||
Future<ThemeMode> themeMode() async => ThemeMode.system;
|
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 {
|
Future<void> updateThemeMode(ThemeMode theme) async {
|
||||||
// Use the shared_preferences package to persist settings locally or the
|
final prefs = await SharedPreferences.getInstance();
|
||||||
// http package to persist settings over the network.
|
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: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';
|
import 'settings_controller.dart';
|
||||||
|
|
||||||
/// Displays the various settings that can be customized by the user.
|
class SettingsView extends StatefulWidget {
|
||||||
///
|
const SettingsView({Key? key, required SettingsController controller})
|
||||||
/// When a user changes a setting, the SettingsController is updated and
|
: _settingsController = controller,
|
||||||
/// Widgets that listen to the SettingsController are rebuilt.
|
super(key: key);
|
||||||
class SettingsView extends StatelessWidget {
|
|
||||||
const SettingsView({Key? key, required this.controller}) : super(key: key);
|
|
||||||
|
|
||||||
static const routeName = '/settings';
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
_updateSettingsValueDiffs();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Settings'),
|
title: const Text('Settings'),
|
||||||
),
|
backgroundColor: Theme.of(context).canvasColor,
|
||||||
body: Padding(
|
foregroundColor: Theme.of(context).primaryColor,
|
||||||
padding: const EdgeInsets.all(16),
|
elevation: 0.0,
|
||||||
// Glue the SettingsController to the theme selection DropdownButton.
|
),
|
||||||
//
|
body: Padding(
|
||||||
// When a user selects a theme from the dropdown list, the
|
padding: const EdgeInsets.all(16.0),
|
||||||
// SettingsController is updated, which rebuilds the MaterialApp.
|
child: Column(children: [
|
||||||
child: DropdownButton<ThemeMode>(
|
_buildThemeOptions(context),
|
||||||
// Read the selected themeMode from the controller
|
const SizedBox(height: 10),
|
||||||
value: controller.themeMode,
|
const Divider(),
|
||||||
// Call the updateThemeMode method any time the user selects a theme.
|
const SizedBox(height: 10),
|
||||||
onChanged: controller.updateThemeMode,
|
_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 [
|
items: const [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: ThemeMode.system,
|
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)
|
project(runner LANGUAGES CXX)
|
||||||
|
|
||||||
set(BINARY_NAME "friendica_archive_browser")
|
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)
|
cmake_policy(SET CMP0063 NEW)
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,14 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#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) {
|
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
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
desktop_window
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|
|
@ -40,14 +40,14 @@ static void my_application_activate(GApplication* application) {
|
||||||
if (use_header_bar) {
|
if (use_header_bar) {
|
||||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
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_header_bar_set_show_close_button(header_bar, TRUE);
|
||||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||||
} else {
|
} 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));
|
gtk_widget_show(GTK_WIDGET(window));
|
||||||
|
|
||||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
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"
|
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||||
|
|
|
@ -5,6 +5,14 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import desktop_window
|
||||||
|
import path_provider_macos
|
||||||
|
import shared_preferences_macos
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
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 */; };
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
@ -54,7 +55,7 @@
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
@ -75,12 +81,23 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
4BDE3286C73FDA8B999E5FF1 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup 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 */ = {
|
33BA886A226E78AF003329D5 /* Configs */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -99,13 +116,14 @@
|
||||||
33CEB47122A05771004F2AC0 /* Flutter */,
|
33CEB47122A05771004F2AC0 /* Flutter */,
|
||||||
33CC10EE2044A3C60003C045 /* Products */,
|
33CC10EE2044A3C60003C045 /* Products */,
|
||||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||||
|
1AD654E9D11F7EC5F226D2B4 /* Pods */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
33CC10EE2044A3C60003C045 /* Products */ = {
|
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
33CC10ED2044A3C60003C045 /* friendica_archive_browser.app */,
|
33CC10ED2044A3C60003C045 /* Kyanite.app */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -135,6 +153,7 @@
|
||||||
33FAB671232836740065AC1E /* Runner */ = {
|
33FAB671232836740065AC1E /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
4BB1ABD4272E2E48001A21BE /* RunnerDebug.entitlements */,
|
||||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||||
|
@ -148,6 +167,7 @@
|
||||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
5A62DF101BD155ACE6A97EE5 /* Pods_Runner.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -159,11 +179,13 @@
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
433B9574F7E94075D058E585 /* [CP] Check Pods Manifest.lock */,
|
||||||
33CC10E92044A3C60003C045 /* Sources */,
|
33CC10E92044A3C60003C045 /* Sources */,
|
||||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||||
33CC10EB2044A3C60003C045 /* Resources */,
|
33CC10EB2044A3C60003C045 /* Resources */,
|
||||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
|
44C7D6FFFB0D86BDDC27CD29 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
|
@ -172,7 +194,7 @@
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 33CC10ED2044A3C60003C045 /* friendica_archive_browser.app */;
|
productReference = 33CC10ED2044A3C60003C045 /* Kyanite.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
@ -182,7 +204,7 @@
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastSwiftUpdateCheck = 0920;
|
LastSwiftUpdateCheck = 0920;
|
||||||
LastUpgradeCheck = 1300;
|
LastUpgradeCheck = 0930;
|
||||||
ORGANIZATIONNAME = "";
|
ORGANIZATIONNAME = "";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
33CC10EC2044A3C60003C045 = {
|
33CC10EC2044A3C60003C045 = {
|
||||||
|
@ -270,6 +292,45 @@
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
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 */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
@ -484,7 +545,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1300"
|
LastUpgradeVersion = "1000"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
@ -15,7 +15,7 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
BuildableName = "friendica_archive_browser.app"
|
BuildableName = "Kyanite.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Runner"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
|
@ -31,7 +31,7 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
BuildableName = "friendica_archive_browser.app"
|
BuildableName = "Kyanite.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Runner"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
|
@ -54,7 +54,7 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
BuildableName = "friendica_archive_browser.app"
|
BuildableName = "Kyanite.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Runner"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
|
@ -71,7 +71,7 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
BuildableName = "friendica_archive_browser.app"
|
BuildableName = "Kyanite.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Runner"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
|
|
|
@ -4,4 +4,7 @@
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|
|
@ -3,61 +3,61 @@
|
||||||
{
|
{
|
||||||
"size" : "16x16",
|
"size" : "16x16",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_16.png",
|
"filename" : "fba_app_icon_16.png",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "16x16",
|
"size" : "16x16",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_32.png",
|
"filename" : "fba_app_icon_32.png",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "32x32",
|
"size" : "32x32",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_32.png",
|
"filename" : "fba_app_icon_32.png",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "32x32",
|
"size" : "32x32",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_64.png",
|
"filename" : "fba_app_icon_64.png",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "128x128",
|
"size" : "128x128",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_128.png",
|
"filename" : "fba_app_icon_128.png",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "128x128",
|
"size" : "128x128",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_256.png",
|
"filename" : "fba_app_icon_256.png",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "256x256",
|
"size" : "256x256",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_256.png",
|
"filename" : "fba_app_icon_256.png",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "256x256",
|
"size" : "256x256",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_512.png",
|
"filename" : "fba_app_icon_512.png",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "512x512",
|
"size" : "512x512",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_512.png",
|
"filename" : "fba_app_icon_512.png",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "512x512",
|
"size" : "512x512",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"filename" : "app_icon_1024.png",
|
"filename" : "fba_app_icon_1024.png",
|
||||||
"scale" : "2x"
|
"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"?>
|
<?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>
|
<dependencies>
|
||||||
<deployment identifier="macosx"/>
|
<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"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<objects>
|
<objects>
|
||||||
|
@ -13,7 +13,7 @@
|
||||||
</customObject>
|
</customObject>
|
||||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
<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>
|
<connections>
|
||||||
<outlet property="applicationMenu" destination="uQy-DD-JDr" id="XBo-yE-nKs"/>
|
<outlet property="applicationMenu" destination="uQy-DD-JDr" id="XBo-yE-nKs"/>
|
||||||
<outlet property="mainFlutterWindow" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
|
<outlet property="mainFlutterWindow" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
|
||||||
|
@ -326,14 +326,15 @@
|
||||||
</items>
|
</items>
|
||||||
<point key="canvasLocation" x="142" y="-258"/>
|
<point key="canvasLocation" x="142" y="-258"/>
|
||||||
</menu>
|
</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"/>
|
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||||
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
|
<rect key="contentRect" x="0.0" y="175" width="915" height="700"/>
|
||||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
|
<rect key="screenRect" x="0.0" y="0.0" width="1440" height="875"/>
|
||||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
<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"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
</view>
|
</view>
|
||||||
|
<point key="canvasLocation" x="139" y="401"/>
|
||||||
</window>
|
</window>
|
||||||
</objects>
|
</objects>
|
||||||
</document>
|
</document>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
PRODUCT_NAME = friendica_archive_browser
|
PRODUCT_NAME = friendica_archive_browser
|
||||||
|
|
||||||
// The application's bundle identifier
|
// 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
|
// 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>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<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>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<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/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
@ -4,5 +4,17 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<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>
|
</dict>
|
||||||
</plist>
|
</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"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.1"
|
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:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -43,6 +57,27 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.15.0"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -50,6 +85,27 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
@ -67,18 +123,65 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
intl:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
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:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.0"
|
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:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -86,6 +189,20 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
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:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -100,13 +217,188 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.7.0"
|
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
|
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:
|
description:
|
||||||
name: path
|
name: path
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.8.0"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
@ -140,6 +432,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -161,6 +460,69 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -168,5 +530,20 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
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:
|
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