Merge branch 'browser' into 'main'
Friendica Archive Browser 1.0 and Friendica Archiver 1.1.0 See merge request mysocialportal/friendica-archiving-tools!1
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.
|
46
friendica_archive_browser/.gitignore
vendored
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# Web related
|
||||||
|
lib/generated_plugin_registrant.dart
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
10
friendica_archive_browser/.metadata
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b
|
||||||
|
channel: stable
|
||||||
|
|
||||||
|
project_type: app
|
3
friendica_archive_browser/CHANGELOG.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Friendica Archive Browser Changelog
|
||||||
|
|
||||||
|
## Version 1.0.0
|
37
friendica_archive_browser/README.md
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# A Friendica Archive Viewer
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To install the Friendica Archive Browser you simply have to download the latest release from
|
||||||
|
[the project release directory](https://gitlab.com/HankG/mysocialportal/-/releases)
|
||||||
|
for your given platform. Then unzip the folder and you are ready to run. On Mac
|
||||||
|
and Windows you will get a warning about an "unknown publisher" since this is beta
|
||||||
|
software that is not installed or signed through the respective app stores. App store
|
||||||
|
versions will come in the near future.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
In order to build this application you will need to have installed [Flutter](https://flutter.dev).
|
||||||
|
Installation instructions for various platforms are [here](https://flutter.dev/docs/get-started/install).
|
||||||
|
Once you have that installed it is as easy as navigating to the respective directory on the command
|
||||||
|
line and executing:
|
||||||
|
|
||||||
|
On Linux:
|
||||||
|
```bash
|
||||||
|
flutter run -d linux
|
||||||
|
```
|
||||||
|
|
||||||
|
On Mac:
|
||||||
|
```bash
|
||||||
|
flutter run -d macos
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows:
|
||||||
|
```bash
|
||||||
|
flutter run -d windows
|
||||||
|
```
|
||||||
|
|
||||||
|
Please report any bugs or feature requests [with our issue tracker](https://gitlab.com/HankG/mysocialportal/-/issues).
|
29
friendica_archive_browser/analysis_options.yaml
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at
|
||||||
|
# https://dart-lang.github.io/linter/lints/index.html.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
BIN
friendica_archive_browser/assets/images/2.0x/flutter_logo.png
Normal file
After Width: | Height: | Size: 619 B |
BIN
friendica_archive_browser/assets/images/3.0x/flutter_logo.png
Normal file
After Width: | Height: | Size: 810 B |
BIN
friendica_archive_browser/assets/images/flutter_logo.png
Normal file
After Width: | Height: | Size: 419 B |
3
friendica_archive_browser/l10n.yaml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
arb-dir: lib/src/localization
|
||||||
|
template-arb-file: app_en.arb
|
||||||
|
output-localization-file: app_localizations.dart
|
33
friendica_archive_browser/lib/main.dart
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/utils/temp_file_builder.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
import 'src/app.dart';
|
||||||
|
import 'src/settings/settings_controller.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final logPath = await setupLogging();
|
||||||
|
Logger.root.info('Starting Friendica Archive Browser');
|
||||||
|
final settingsController = SettingsController(logPath: logPath);
|
||||||
|
await settingsController.loadSettings();
|
||||||
|
runApp(FriendicaArchiveBrowser(settingsController: settingsController));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> setupLogging() async {
|
||||||
|
final logFilePath = await getTempFile('friendica_archive_browser_', '.log');
|
||||||
|
final logFile = File(logFilePath);
|
||||||
|
Logger.root.level = Level.ALL;
|
||||||
|
Logger.root.onRecord.listen((event) {
|
||||||
|
final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName;
|
||||||
|
final msg =
|
||||||
|
'${event.level.name} - $logName @ ${event.time}: ${event.message}\n';
|
||||||
|
final handle = logFile.openSync(mode: FileMode.append);
|
||||||
|
handle.writeStringSync(msg);
|
||||||
|
handle.closeSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
return logFilePath;
|
||||||
|
}
|
70
friendica_archive_browser/lib/src/app.dart
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import 'package:desktop_window/desktop_window.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/services/friendica_archive_service.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 'friendica/services/path_mapping_service.dart';
|
||||||
|
import 'home.dart';
|
||||||
|
import 'settings/settings_controller.dart';
|
||||||
|
|
||||||
|
/// The Widget that configures your application.
|
||||||
|
class FriendicaArchiveBrowser extends StatelessWidget {
|
||||||
|
static const minAppSize = Size(915, 700);
|
||||||
|
|
||||||
|
const FriendicaArchiveBrowser({
|
||||||
|
Key? key,
|
||||||
|
required this.settingsController,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final SettingsController settingsController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
DesktopWindow.setMinWindowSize(minAppSize);
|
||||||
|
final pathMappingService = PathMappingService(settingsController);
|
||||||
|
final friendicaArchiveService =
|
||||||
|
FriendicaArchiveService(pathMappingService: pathMappingService);
|
||||||
|
settingsController.addListener(() {
|
||||||
|
friendicaArchiveService.clearCaches();
|
||||||
|
pathMappingService.refresh();
|
||||||
|
});
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: settingsController,
|
||||||
|
builder: (BuildContext context, Widget? child) {
|
||||||
|
return MaterialApp(
|
||||||
|
restorationScopeId: 'app',
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [
|
||||||
|
Locale('en', ''), // English, no country code
|
||||||
|
],
|
||||||
|
onGenerateTitle: (BuildContext context) =>
|
||||||
|
AppLocalizations.of(context)!.appTitle,
|
||||||
|
theme: FriendicaArchiveBrowserTheme.light,
|
||||||
|
darkTheme: FriendicaArchiveBrowserTheme.dark,
|
||||||
|
themeMode: settingsController.themeMode,
|
||||||
|
scrollBehavior: AppScrollingBehavior(),
|
||||||
|
home: MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider(create: (context) => settingsController),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => friendicaArchiveService),
|
||||||
|
Provider(create: (context) => pathMappingService),
|
||||||
|
],
|
||||||
|
child: Home(
|
||||||
|
settingsController: settingsController,
|
||||||
|
archiveService: friendicaArchiveService),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,151 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/models/time_element.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/services/friendica_connections.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/utils/snackbar_status_builder.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/utils/top_interactors_generator.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class TopInteractorsWidget extends StatefulWidget {
|
||||||
|
final List<TimeElement> entries;
|
||||||
|
final FriendicaConnections connections;
|
||||||
|
|
||||||
|
const TopInteractorsWidget(this.entries, this.connections, {Key? key})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TopInteractorsWidget> createState() => _TopInteractionsWidget();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TopInteractionsWidget extends State<TopInteractorsWidget> {
|
||||||
|
static final _logger = Logger('$TopInteractorsWidget');
|
||||||
|
int _currentThreshold = 10;
|
||||||
|
int _sortIndex = 1;
|
||||||
|
final _thresholds = [10, 20, 50, 100];
|
||||||
|
final generator = TopInteractorsGenerator();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _generateStats() {
|
||||||
|
_logger.finer('Filling list');
|
||||||
|
generator.clear();
|
||||||
|
for (final entry in widget.entries) {
|
||||||
|
generator.processEntry(entry.entry, widget.connections);
|
||||||
|
}
|
||||||
|
_logger.finer('List filled');
|
||||||
|
_calcTopList(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _calcTopList(bool updateState) async {
|
||||||
|
if (updateState) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.fine('Rebuilding Top Interactors');
|
||||||
|
_generateStats();
|
||||||
|
|
||||||
|
final interactors = <InteractorItem>[];
|
||||||
|
|
||||||
|
if (_sortIndex == 1) {
|
||||||
|
interactors.addAll(generator.getTopLikes(_currentThreshold));
|
||||||
|
} else if (_sortIndex == 2) {
|
||||||
|
interactors.addAll(generator.getTopDislikes(_currentThreshold));
|
||||||
|
} else {
|
||||||
|
interactors.addAll(generator.getTopCommentReshare(_currentThreshold));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
'Interactors',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: Theme.of(context).textTheme.headline6,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10.0),
|
||||||
|
_buildDataTable(context, interactors),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDataTable(
|
||||||
|
BuildContext context, List<InteractorItem> interactors) {
|
||||||
|
return DataTable(
|
||||||
|
sortColumnIndex: _sortIndex,
|
||||||
|
sortAscending: false,
|
||||||
|
columns: [
|
||||||
|
const DataColumn(label: Text('Name')),
|
||||||
|
DataColumn(
|
||||||
|
label: const Text('Likes'),
|
||||||
|
numeric: true,
|
||||||
|
onSort: (column, ascending) => setState(() {
|
||||||
|
_sortIndex = column;
|
||||||
|
})),
|
||||||
|
DataColumn(
|
||||||
|
label: const Text('Dislikes'),
|
||||||
|
numeric: true,
|
||||||
|
onSort: (column, ascending) => setState(() {
|
||||||
|
_sortIndex = column;
|
||||||
|
})),
|
||||||
|
DataColumn(
|
||||||
|
label: const Text('Reshares'),
|
||||||
|
numeric: true,
|
||||||
|
onSort: (column, ascending) => setState(() {
|
||||||
|
_sortIndex = column;
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
rows: List.generate(
|
||||||
|
interactors.length,
|
||||||
|
(index) => DataRow(
|
||||||
|
color: index.isEven
|
||||||
|
? MaterialStateProperty.resolveWith(
|
||||||
|
(states) => Theme.of(context).dividerColor)
|
||||||
|
: null,
|
||||||
|
cells: [
|
||||||
|
DataCell(TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final url =
|
||||||
|
interactors[index].contact.profileUrl.toString();
|
||||||
|
await canLaunch(url)
|
||||||
|
? await launch(url)
|
||||||
|
: SnackBarStatusBuilder.buildSnackbar(
|
||||||
|
context, 'Failed to open $url');
|
||||||
|
},
|
||||||
|
child: Text(interactors[index].contact.name))),
|
||||||
|
DataCell(Text('${interactors[index].likeCount}')),
|
||||||
|
DataCell(Text('${interactors[index].dislikeCount}')),
|
||||||
|
DataCell(
|
||||||
|
Text('${interactors[index].resharedOrCommentedOn}')),
|
||||||
|
])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
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}');
|
||||||
|
|
||||||
|
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),
|
||||||
|
_buildDataTable(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDataTable(BuildContext context) {
|
||||||
|
return DataTable(
|
||||||
|
sortAscending: false,
|
||||||
|
columns: const [
|
||||||
|
DataColumn(label: Text('Word')),
|
||||||
|
DataColumn(label: Text('Count'), numeric: true),
|
||||||
|
],
|
||||||
|
rows: List.generate(
|
||||||
|
topElements.length,
|
||||||
|
(index) => DataRow(
|
||||||
|
color: index.isEven
|
||||||
|
? MaterialStateProperty.resolveWith(
|
||||||
|
(states) => Theme.of(context).dividerColor)
|
||||||
|
: null,
|
||||||
|
cells: [
|
||||||
|
DataCell(Text(topElements[index].word)),
|
||||||
|
DataCell(Text('${topElements[index].count}')),
|
||||||
|
])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,414 @@
|
||||||
|
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)? commentsOnlyFilterFunction;
|
||||||
|
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.commentsOnlyFilterFunction,
|
||||||
|
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 _withCommentsOnly = false;
|
||||||
|
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 &&
|
||||||
|
_withCommentsOnly &&
|
||||||
|
widget.commentsOnlyFilterFunction != null) {
|
||||||
|
passes &= widget.commentsOnlyFilterFunction!(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),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (widget.commentsOnlyFilterFunction != null)
|
||||||
|
_buildCommentsOnly(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 _buildCommentsOnly(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Row(children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _withCommentsOnly,
|
||||||
|
onChanged: (value) => setState(() {
|
||||||
|
_withCommentsOnly = value ?? false;
|
||||||
|
_updateFilter();
|
||||||
|
})),
|
||||||
|
const SizedBox(width: 1),
|
||||||
|
const Text('Only with comments'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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/friendica_timeline_entry.dart';
|
||||||
|
import 'package:latlng/latlng.dart';
|
||||||
|
import 'package:map/map.dart';
|
||||||
|
|
||||||
|
import 'marker_data.dart';
|
||||||
|
|
||||||
|
extension GeoSpatialPostExtensions on FriendicaTimelineEntry {
|
||||||
|
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/friendica_timeline_entry.dart';
|
||||||
|
|
||||||
|
class MarkerData {
|
||||||
|
final List<FriendicaTimelineEntry> 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,136 @@
|
||||||
|
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 LinkElementsComponent extends StatefulWidget {
|
||||||
|
static final _logger = Logger('$LinkElementsComponent');
|
||||||
|
final List<Uri> links;
|
||||||
|
|
||||||
|
const LinkElementsComponent({Key? key, required this.links})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LinkElementsComponent> createState() => _LinkElementsComponentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LinkElementsComponentState extends State<LinkElementsComponent> {
|
||||||
|
final previewWidth = 500.0;
|
||||||
|
final previewHeight = 165.0;
|
||||||
|
static final _logger = Logger('$_LinkElementsComponentState');
|
||||||
|
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!)
|
||||||
|
: LinkElementsComponent._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,70 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_media_attachment.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/screens/media_slideshow_screen.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/services/friendica_archive_service.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'media_wrapper_component.dart';
|
||||||
|
|
||||||
|
class MediaTimelineComponent extends StatelessWidget {
|
||||||
|
static const double _maxHeightWidth = 400.0;
|
||||||
|
|
||||||
|
final List<FriendicaMediaAttachment> mediaAttachments;
|
||||||
|
|
||||||
|
const MediaTimelineComponent({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 archiveService = Provider.of<FriendicaArchiveService>(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),
|
||||||
|
ChangeNotifierProvider.value(value: archiveService),
|
||||||
|
],
|
||||||
|
child: MediaSlideShowScreen(
|
||||||
|
mediaAttachments: mediaAttachments,
|
||||||
|
initialIndex: index));
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
child: MediaWrapperComponent(
|
||||||
|
mediaAttachment: mediaAttachments[index],
|
||||||
|
preferredWidth: isSingle ? singleWidth : preferredMultiWidth,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) {
|
||||||
|
return const SizedBox(width: 10);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,203 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_media_attachment.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/services/friendica_archive_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 MediaWrapperComponent extends StatelessWidget {
|
||||||
|
static final _logger = Logger('$MediaWrapperComponent');
|
||||||
|
|
||||||
|
static const double _noPreferredValue = -1.0;
|
||||||
|
final FriendicaMediaAttachment mediaAttachment;
|
||||||
|
final double preferredWidth;
|
||||||
|
final double preferredHeight;
|
||||||
|
|
||||||
|
const MediaWrapperComponent(
|
||||||
|
{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 archiveService = Provider.of<FriendicaArchiveService>(context);
|
||||||
|
final videoPlayerCommand = settingsController.videoPlayerCommand;
|
||||||
|
final path = _calculatePath(pathMapper, archiveService);
|
||||||
|
final width =
|
||||||
|
preferredWidth > 0 ? preferredWidth : MediaQuery.of(context).size.width;
|
||||||
|
final height = preferredHeight > 0
|
||||||
|
? preferredHeight
|
||||||
|
: MediaQuery.of(context).size.height;
|
||||||
|
|
||||||
|
if (mediaAttachment.explicitType == FriendicaAttachmentMediaType.unknown) {
|
||||||
|
return Text('Unable to resolve type for ${mediaAttachment.uri.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaAttachment.explicitType == FriendicaAttachmentMediaType.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.explicitType == FriendicaAttachmentMediaType.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _calculatePath(
|
||||||
|
PathMappingService pathMapper, FriendicaArchiveService archiveService) {
|
||||||
|
final url = mediaAttachment.uri.toString();
|
||||||
|
String basePath = '';
|
||||||
|
if (url.startsWith('http')) {
|
||||||
|
final localCacheFile = archiveService.getImageByUrl(url);
|
||||||
|
if (localCacheFile.isFailure) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
basePath = localCacheFile.value.localFilename;
|
||||||
|
} else {
|
||||||
|
basePath = mediaAttachment.uri.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathMapper.toFullPath(basePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageAndPathResult {
|
||||||
|
final Image? image;
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
_ImageAndPathResult(this.image, this.path);
|
||||||
|
|
||||||
|
_ImageAndPathResult.none()
|
||||||
|
: image = null,
|
||||||
|
path = '';
|
||||||
|
}
|
|
@ -0,0 +1,155 @@
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_entry_tree_item.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/location_data.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:friendica_archive_browser/src/utils/snackbar_status_builder.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import 'link_elements_component.dart';
|
||||||
|
import 'media_timeline_component.dart';
|
||||||
|
|
||||||
|
class TreeEntryCard extends StatelessWidget {
|
||||||
|
static final _logger = Logger("$TreeEntryCard");
|
||||||
|
final FriendicaEntryTreeItem treeEntry;
|
||||||
|
final bool isTopLevel;
|
||||||
|
|
||||||
|
const TreeEntryCard(
|
||||||
|
{Key? key, required this.treeEntry, this.isTopLevel = true})
|
||||||
|
: 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 entry = treeEntry.entry;
|
||||||
|
|
||||||
|
final title = entry.title.isNotEmpty
|
||||||
|
? entry.title
|
||||||
|
: entry.parentId.isEmpty
|
||||||
|
? (entry.isReshare ? 'Reshare' : 'Post')
|
||||||
|
: 'Comment on post by ${entry.parentAuthor}';
|
||||||
|
final dateStamp = ' At ' +
|
||||||
|
formatter.format(
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(entry.creationTimestamp * 1000)
|
||||||
|
.toLocal());
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Container(
|
||||||
|
color: !isTopLevel ? Theme.of(context).dividerColor : null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(5.0),
|
||||||
|
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 post to clipboard',
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () async => await copyToClipboard(
|
||||||
|
context: context,
|
||||||
|
text: entry.toHumanString(mapper, formatter),
|
||||||
|
snackbarMessage: 'Copied Post to clipboard'),
|
||||||
|
icon: const Icon(Icons.copy)),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: 'Open link to original item',
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await canLaunch(entry.externalLink)
|
||||||
|
? await launch(entry.externalLink)
|
||||||
|
: _logger.info(
|
||||||
|
'Failed to launch ${entry.externalLink}');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.link)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
if (entry.body.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: spacingHeight),
|
||||||
|
HtmlWidget(
|
||||||
|
entry.body,
|
||||||
|
onTapUrl: (url) async {
|
||||||
|
bool canLaunchResult = await canLaunch(url);
|
||||||
|
if (!canLaunchResult) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool launched = await launch(url);
|
||||||
|
if (!launched) {
|
||||||
|
final message = 'Failed to launch: $url';
|
||||||
|
_logger.info(message);
|
||||||
|
SnackBarStatusBuilder.buildSnackbar(context, message);
|
||||||
|
}
|
||||||
|
return launched;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
const SizedBox(height: spacingHeight * 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Tooltip(
|
||||||
|
message: entry.likes.map((e) => e.name).join(', '),
|
||||||
|
child: const Icon(Icons.thumb_up_alt_outlined)),
|
||||||
|
Text('${entry.likes.length}'),
|
||||||
|
SizedBox(
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: entry.dislikes.map((e) => e.name).join(', '),
|
||||||
|
child: const Icon(Icons.thumb_down_alt_outlined)),
|
||||||
|
Text('${entry.dislikes.length}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (entry.locationData.hasData())
|
||||||
|
entry.locationData.toWidget(spacingHeight),
|
||||||
|
if (entry.links.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: spacingHeight),
|
||||||
|
LinkElementsComponent(links: entry.links)
|
||||||
|
],
|
||||||
|
if (entry.mediaAttachments.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: spacingHeight),
|
||||||
|
MediaTimelineComponent(mediaAttachments: entry.mediaAttachments)
|
||||||
|
],
|
||||||
|
if (treeEntry.children.isNotEmpty)
|
||||||
|
Column(
|
||||||
|
children: treeEntry.children
|
||||||
|
.map((e) => TreeEntryCard(
|
||||||
|
treeEntry: e,
|
||||||
|
isTopLevel: false,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
class FriendicaContact {
|
||||||
|
final ConnectionStatus status;
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
final Uri profileUrl;
|
||||||
|
|
||||||
|
final String network;
|
||||||
|
|
||||||
|
FriendicaContact(
|
||||||
|
{required this.status,
|
||||||
|
required this.name,
|
||||||
|
required this.id,
|
||||||
|
required this.profileUrl,
|
||||||
|
required this.network});
|
||||||
|
|
||||||
|
static FriendicaContact fromJson(Map<String, dynamic> json) {
|
||||||
|
final status = (json['following'] ?? '') == 'true'
|
||||||
|
? ConnectionStatus.youFollowThem
|
||||||
|
: ConnectionStatus.none;
|
||||||
|
final name = json['name'] ?? '';
|
||||||
|
final id = json['id_str'] ?? '';
|
||||||
|
final profileUrl = Uri.parse(json['url'] ?? '');
|
||||||
|
final network = json['network'] ?? 'unkn';
|
||||||
|
|
||||||
|
return FriendicaContact(
|
||||||
|
status: status,
|
||||||
|
name: name,
|
||||||
|
id: id,
|
||||||
|
profileUrl: profileUrl,
|
||||||
|
network: network);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionStatus {
|
||||||
|
youFollowThem,
|
||||||
|
theyFollowYou,
|
||||||
|
mutual,
|
||||||
|
none,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FriendStatusWriter on ConnectionStatus {
|
||||||
|
String name() {
|
||||||
|
switch (this) {
|
||||||
|
case ConnectionStatus.youFollowThem:
|
||||||
|
return "You Follow Them";
|
||||||
|
case ConnectionStatus.theyFollowYou:
|
||||||
|
return "They Follow You";
|
||||||
|
case ConnectionStatus.mutual:
|
||||||
|
return "Follow each other";
|
||||||
|
case ConnectionStatus.none:
|
||||||
|
return "Not connected";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
|
||||||
|
|
||||||
|
class FriendicaEntryTreeItem {
|
||||||
|
final FriendicaTimelineEntry entry;
|
||||||
|
final bool isOrphaned;
|
||||||
|
|
||||||
|
final _children = <String, FriendicaEntryTreeItem>{};
|
||||||
|
|
||||||
|
FriendicaEntryTreeItem(this.entry, this.isOrphaned);
|
||||||
|
|
||||||
|
String get id => entry.id;
|
||||||
|
|
||||||
|
void addChild(FriendicaEntryTreeItem child) {
|
||||||
|
_children[child.id] = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FriendicaEntryTreeItem> get children =>
|
||||||
|
List.unmodifiable(_children.values);
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
|
||||||
|
|
||||||
|
import 'model_utils.dart';
|
||||||
|
|
||||||
|
enum FriendicaAttachmentMediaType { unknown, image, video }
|
||||||
|
|
||||||
|
class FriendicaMediaAttachment {
|
||||||
|
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 FriendicaAttachmentMediaType explicitType;
|
||||||
|
|
||||||
|
final Uri thumbnailUri;
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
FriendicaMediaAttachment(
|
||||||
|
{required this.uri,
|
||||||
|
required this.creationTimestamp,
|
||||||
|
required this.metadata,
|
||||||
|
required this.thumbnailUri,
|
||||||
|
required this.title,
|
||||||
|
required this.explicitType,
|
||||||
|
required this.description});
|
||||||
|
|
||||||
|
FriendicaMediaAttachment.randomBuilt()
|
||||||
|
: uri = Uri.parse('http://localhost/${randomId()}'),
|
||||||
|
creationTimestamp = DateTime.now().millisecondsSinceEpoch,
|
||||||
|
title = 'Random title ${randomId()}',
|
||||||
|
thumbnailUri = Uri.parse('${randomId()}.jpg'),
|
||||||
|
description = 'Random description ${randomId()}',
|
||||||
|
explicitType = FriendicaAttachmentMediaType.image,
|
||||||
|
metadata = {'value1': randomId(), 'value2': randomId()};
|
||||||
|
|
||||||
|
FriendicaMediaAttachment.fromUriOnly(this.uri)
|
||||||
|
: creationTimestamp = 0,
|
||||||
|
thumbnailUri = Uri.file(''),
|
||||||
|
title = '',
|
||||||
|
explicitType = mediaTypeFromString(uri.path),
|
||||||
|
description = '',
|
||||||
|
metadata = {};
|
||||||
|
|
||||||
|
FriendicaMediaAttachment.fromUriAndTime(this.uri, this.creationTimestamp)
|
||||||
|
: thumbnailUri = Uri.file(''),
|
||||||
|
title = '',
|
||||||
|
explicitType = mediaTypeFromString(uri.path),
|
||||||
|
description = '',
|
||||||
|
metadata = {};
|
||||||
|
|
||||||
|
FriendicaMediaAttachment.blank()
|
||||||
|
: uri = Uri(),
|
||||||
|
creationTimestamp = 0,
|
||||||
|
thumbnailUri = Uri.file(''),
|
||||||
|
explicitType = FriendicaAttachmentMediaType.unknown,
|
||||||
|
title = '',
|
||||||
|
description = '',
|
||||||
|
metadata = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'FriendicaMediaAttachment{uri: $uri, creationTimestamp: $creationTimestamp, type: $explicitType, metadata: $metadata, title: $title, description: $description}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String toHumanString(PathMappingService mapper) {
|
||||||
|
if (uri.scheme.startsWith('http')) {
|
||||||
|
return uri.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapper.toFullPath(uri.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
FriendicaMediaAttachment.fromJson(Map<String, dynamic> json)
|
||||||
|
: uri = Uri.parse(json['url']),
|
||||||
|
creationTimestamp = 0,
|
||||||
|
metadata = (json['metadata'] as Map<String, dynamic>? ?? {})
|
||||||
|
.map((key, value) => MapEntry(key, value.toString())),
|
||||||
|
explicitType = (json['mimetype'] ?? '').startsWith('image')
|
||||||
|
? FriendicaAttachmentMediaType.image
|
||||||
|
: (json['mimetype'] ?? '').startsWith('video')
|
||||||
|
? FriendicaAttachmentMediaType.video
|
||||||
|
: FriendicaAttachmentMediaType.unknown,
|
||||||
|
thumbnailUri = Uri(),
|
||||||
|
title = '',
|
||||||
|
description = '';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'uri': uri.toString(),
|
||||||
|
'creationTimestamp': creationTimestamp,
|
||||||
|
'metadata': metadata,
|
||||||
|
'type': explicitType,
|
||||||
|
'thumbnailUri': thumbnailUri.toString(),
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
};
|
||||||
|
|
||||||
|
static FriendicaAttachmentMediaType 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 FriendicaAttachmentMediaType.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
final extension = filename.substring(lastPeriod).toLowerCase();
|
||||||
|
|
||||||
|
if (_graphicsExtensions.contains(extension)) {
|
||||||
|
return FriendicaAttachmentMediaType.image;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_movieExtensions.contains(extension)) {
|
||||||
|
return FriendicaAttachmentMediaType.video;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FriendicaAttachmentMediaType.unknown;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,246 @@
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_contact.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/services/friendica_connections.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/utils/offsetdatetime_utils.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
import 'friendica_media_attachment.dart';
|
||||||
|
import 'location_data.dart';
|
||||||
|
import 'model_utils.dart';
|
||||||
|
|
||||||
|
class FriendicaTimelineEntry {
|
||||||
|
static final _logger = Logger('$FriendicaTimelineEntry');
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
final String parentId;
|
||||||
|
|
||||||
|
final String parentAuthor;
|
||||||
|
|
||||||
|
final String parentAuthorId;
|
||||||
|
|
||||||
|
final int creationTimestamp;
|
||||||
|
|
||||||
|
final int backdatedTimestamp;
|
||||||
|
|
||||||
|
final int modificationTimestamp;
|
||||||
|
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
final bool isReshare;
|
||||||
|
|
||||||
|
final String author;
|
||||||
|
|
||||||
|
final String authorId;
|
||||||
|
|
||||||
|
final String externalLink;
|
||||||
|
|
||||||
|
final List<FriendicaMediaAttachment> mediaAttachments;
|
||||||
|
|
||||||
|
final LocationData locationData;
|
||||||
|
|
||||||
|
final List<Uri> links;
|
||||||
|
|
||||||
|
final List<FriendicaContact> likes;
|
||||||
|
|
||||||
|
final List<FriendicaContact> dislikes;
|
||||||
|
|
||||||
|
FriendicaTimelineEntry(
|
||||||
|
{this.id = '',
|
||||||
|
this.parentId = '',
|
||||||
|
this.creationTimestamp = 0,
|
||||||
|
this.backdatedTimestamp = 0,
|
||||||
|
this.modificationTimestamp = 0,
|
||||||
|
this.isReshare = false,
|
||||||
|
this.body = '',
|
||||||
|
this.title = '',
|
||||||
|
this.author = '',
|
||||||
|
this.authorId = '',
|
||||||
|
this.parentAuthor = '',
|
||||||
|
this.parentAuthorId = '',
|
||||||
|
this.externalLink = '',
|
||||||
|
this.locationData = const LocationData(),
|
||||||
|
this.likes = const <FriendicaContact>[],
|
||||||
|
this.dislikes = const <FriendicaContact>[],
|
||||||
|
List<FriendicaMediaAttachment>? mediaAttachments,
|
||||||
|
List<Uri>? links})
|
||||||
|
: mediaAttachments = mediaAttachments ?? <FriendicaMediaAttachment>[],
|
||||||
|
links = links ?? <Uri>[];
|
||||||
|
|
||||||
|
FriendicaTimelineEntry.randomBuilt()
|
||||||
|
: creationTimestamp = DateTime.now().millisecondsSinceEpoch,
|
||||||
|
backdatedTimestamp = DateTime.now().millisecondsSinceEpoch,
|
||||||
|
modificationTimestamp = DateTime.now().millisecondsSinceEpoch,
|
||||||
|
id = randomId(),
|
||||||
|
isReshare = false,
|
||||||
|
parentId = randomId(),
|
||||||
|
externalLink = 'Random external link ${randomId()}',
|
||||||
|
body = 'Random post text ${randomId()}',
|
||||||
|
title = 'Random title ${randomId()}',
|
||||||
|
author = 'Random author ${randomId()}',
|
||||||
|
authorId = 'Random authorId ${randomId()}',
|
||||||
|
parentAuthor = 'Random parent author ${randomId()}',
|
||||||
|
parentAuthorId = 'Random parent author id ${randomId()}',
|
||||||
|
locationData = LocationData.randomBuilt(),
|
||||||
|
likes = const <FriendicaContact>[],
|
||||||
|
dislikes = const <FriendicaContact>[],
|
||||||
|
links = [
|
||||||
|
Uri.parse('http://localhost/${randomId()}'),
|
||||||
|
Uri.parse('http://localhost/${randomId()}')
|
||||||
|
],
|
||||||
|
mediaAttachments = [
|
||||||
|
FriendicaMediaAttachment.randomBuilt(),
|
||||||
|
FriendicaMediaAttachment.randomBuilt()
|
||||||
|
];
|
||||||
|
|
||||||
|
FriendicaTimelineEntry copy(
|
||||||
|
{int? creationTimestamp,
|
||||||
|
int? backdatedTimestamp,
|
||||||
|
int? modificationTimestamp,
|
||||||
|
bool? isReshare,
|
||||||
|
String? id,
|
||||||
|
String? parentId,
|
||||||
|
String? externalLink,
|
||||||
|
String? body,
|
||||||
|
String? title,
|
||||||
|
String? author,
|
||||||
|
String? authorId,
|
||||||
|
String? parentAuthor,
|
||||||
|
String? parentAuthorId,
|
||||||
|
LocationData? locationData,
|
||||||
|
List<FriendicaMediaAttachment>? mediaAttachments,
|
||||||
|
List<FriendicaContact>? likes,
|
||||||
|
List<FriendicaContact>? dislikes,
|
||||||
|
List<Uri>? links}) {
|
||||||
|
return FriendicaTimelineEntry(
|
||||||
|
creationTimestamp: creationTimestamp ?? this.creationTimestamp,
|
||||||
|
backdatedTimestamp: backdatedTimestamp ?? this.backdatedTimestamp,
|
||||||
|
modificationTimestamp:
|
||||||
|
modificationTimestamp ?? this.modificationTimestamp,
|
||||||
|
id: id ?? this.id,
|
||||||
|
isReshare: isReshare ?? this.isReshare,
|
||||||
|
parentId: parentId ?? this.parentId,
|
||||||
|
externalLink: externalLink ?? this.externalLink,
|
||||||
|
body: body ?? this.body,
|
||||||
|
title: title ?? this.title,
|
||||||
|
author: author ?? this.author,
|
||||||
|
authorId: authorId ?? this.authorId,
|
||||||
|
parentAuthor: parentAuthor ?? this.parentAuthor,
|
||||||
|
parentAuthorId: parentAuthorId ?? this.parentAuthorId,
|
||||||
|
locationData: locationData ?? this.locationData,
|
||||||
|
mediaAttachments: mediaAttachments ?? this.mediaAttachments,
|
||||||
|
likes: likes ?? this.likes,
|
||||||
|
dislikes: dislikes ?? this.dislikes,
|
||||||
|
links: links ?? this.links);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'FriendicaTimelineEntry{id: $id, isReshare: $isReshare, parentId: $parentId, creationTimestamp: $creationTimestamp, modificationTimestamp: $modificationTimestamp, backdatedTimeStamp: $backdatedTimestamp, post: $body, title: $title, author: $author, parentAuthor: $parentAuthor 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:',
|
||||||
|
'Author: $author',
|
||||||
|
'Reshare: $isReshare',
|
||||||
|
if (externalLink.isNotEmpty) 'External Link: $externalLink',
|
||||||
|
body,
|
||||||
|
'',
|
||||||
|
if (parentId.isNotEmpty)
|
||||||
|
"Comment on post/comment by ${parentAuthor.isNotEmpty ? parentAuthor : 'unknown author'}",
|
||||||
|
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.explicitType == FriendicaAttachmentMediaType.image)
|
||||||
|
.isNotEmpty;
|
||||||
|
|
||||||
|
bool hasVideos() => mediaAttachments
|
||||||
|
.where((element) =>
|
||||||
|
element.explicitType == FriendicaAttachmentMediaType.video)
|
||||||
|
.isNotEmpty;
|
||||||
|
|
||||||
|
static FriendicaTimelineEntry fromJson(
|
||||||
|
Map<String, dynamic> json, FriendicaConnections connections) {
|
||||||
|
final int timestamp = json.containsKey('created_at')
|
||||||
|
? OffsetDateTimeUtils.epochSecTimeFromFriendicaString(
|
||||||
|
json['created_at'])
|
||||||
|
.fold(
|
||||||
|
onSuccess: (value) => value,
|
||||||
|
onError: (error) {
|
||||||
|
_logger.severe("Couldn't read date time string: $error");
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
|
final id = json['id_str'] ?? '';
|
||||||
|
final isReshare = json.containsKey('retweeted_status');
|
||||||
|
final parentId = json['in_reply_to_status_id_str'] ?? '';
|
||||||
|
final parentAuthor = json['in_reply_to_screen_name'] ?? '';
|
||||||
|
final parentAuthorId = json['in_reply_to_user_id_str'] ?? '';
|
||||||
|
final body = json['friendica_html'] ?? '';
|
||||||
|
final author = json['user']['name'];
|
||||||
|
final authorId = json['user']['id_str'];
|
||||||
|
final title = json['friendica_title'] ?? '';
|
||||||
|
final externalLink = json['external_url'] ?? '';
|
||||||
|
final actualLocationData = LocationData();
|
||||||
|
final modificationTimestamp = timestamp;
|
||||||
|
final backdatedTimestamp = timestamp;
|
||||||
|
final links = <Uri>[];
|
||||||
|
final mediaAttachments = (json['attachments'] as List<dynamic>? ?? [])
|
||||||
|
.map((j) => FriendicaMediaAttachment.fromJson(j))
|
||||||
|
.toList();
|
||||||
|
final likes =
|
||||||
|
(json['friendica_activities']?['like'] as List<dynamic>? ?? [])
|
||||||
|
.map((json) => FriendicaContact.fromJson(json))
|
||||||
|
.toList();
|
||||||
|
final dislikes =
|
||||||
|
(json['friendica_activities']?['dislike'] as List<dynamic>? ?? [])
|
||||||
|
.map((json) => FriendicaContact.fromJson(json))
|
||||||
|
.toList();
|
||||||
|
final announce =
|
||||||
|
(json['friendica_activities']?['announce'] as List<dynamic>? ?? [])
|
||||||
|
.map((json) => FriendicaContact.fromJson(json))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (final contact in [...likes, ...dislikes, ...announce]) {
|
||||||
|
connections.addConnection(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FriendicaTimelineEntry(
|
||||||
|
creationTimestamp: timestamp,
|
||||||
|
modificationTimestamp: modificationTimestamp,
|
||||||
|
backdatedTimestamp: backdatedTimestamp,
|
||||||
|
locationData: actualLocationData,
|
||||||
|
externalLink: externalLink,
|
||||||
|
body: body,
|
||||||
|
isReshare: isReshare,
|
||||||
|
id: id,
|
||||||
|
parentId: parentId,
|
||||||
|
parentAuthorId: parentAuthorId,
|
||||||
|
author: author,
|
||||||
|
authorId: authorId,
|
||||||
|
parentAuthor: parentAuthor,
|
||||||
|
title: title,
|
||||||
|
links: links,
|
||||||
|
likes: likes,
|
||||||
|
dislikes: dislikes,
|
||||||
|
mediaAttachments: mediaAttachments,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,121 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/components/link_elements_component.dart';
|
||||||
|
|
||||||
|
import 'model_utils.dart';
|
||||||
|
|
||||||
|
class LocationData {
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
final double latitude;
|
||||||
|
|
||||||
|
final double longitude;
|
||||||
|
|
||||||
|
final double altitude;
|
||||||
|
|
||||||
|
final bool hasPosition;
|
||||||
|
|
||||||
|
final String address;
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
const LocationData(
|
||||||
|
{this.name = '',
|
||||||
|
this.latitude = 0.0,
|
||||||
|
this.longitude = 0.0,
|
||||||
|
this.altitude = 0.0,
|
||||||
|
this.hasPosition = false,
|
||||||
|
this.address = '',
|
||||||
|
this.url = ''});
|
||||||
|
|
||||||
|
LocationData.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 'LocationData{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 LocationData 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 LocationData(
|
||||||
|
name: name,
|
||||||
|
address: address,
|
||||||
|
url: url,
|
||||||
|
hasPosition: hasPosition,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
altitude: altitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WidgetExtensions on LocationData {
|
||||||
|
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),
|
||||||
|
LinkElementsComponent(
|
||||||
|
links: [Uri.parse(url)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,110 @@
|
||||||
|
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/tree_entry_card.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_entry_tree_item.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/model_utils.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 EntriesScreen extends StatelessWidget {
|
||||||
|
static final _logger = Logger('$EntriesScreen');
|
||||||
|
final FutureResult<List<FriendicaEntryTreeItem>, ExecError> Function()
|
||||||
|
populator;
|
||||||
|
|
||||||
|
const EntriesScreen({Key? key, required this.populator}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.info('Build FriendicaEntriesScreen');
|
||||||
|
Provider.of<SettingsController>(context);
|
||||||
|
|
||||||
|
return FutureBuilder<Result<List<FriendicaEntryTreeItem>, ExecError>>(
|
||||||
|
future: populator(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
_logger.info('FriendicaEntriesScreen Future builder called');
|
||||||
|
|
||||||
|
if (!snapshot.hasData ||
|
||||||
|
snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const LoadingStatusScreen(title: 'Loading entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
final postsResult = snapshot.requireData;
|
||||||
|
|
||||||
|
if (postsResult.isFailure) {
|
||||||
|
return ErrorScreen(
|
||||||
|
title: 'Error getting entries', error: postsResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allPosts = postsResult.value;
|
||||||
|
final posts = allPosts;
|
||||||
|
|
||||||
|
if (posts.isEmpty) {
|
||||||
|
return const StandInStatusScreen(title: 'No entries were found');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.fine('Build Entries ListView');
|
||||||
|
return _FriendicaEntriesScreenWidget(posts: posts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FriendicaEntriesScreenWidget extends StatelessWidget {
|
||||||
|
static final _logger = Logger('$_FriendicaEntriesScreenWidget');
|
||||||
|
|
||||||
|
final List<FriendicaEntryTreeItem> posts;
|
||||||
|
|
||||||
|
const _FriendicaEntriesScreenWidget({Key? key, required this.posts})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.fine('Redrawing');
|
||||||
|
return FilterControl<FriendicaEntryTreeItem, dynamic>(
|
||||||
|
allItems: posts,
|
||||||
|
commentsOnlyFilterFunction: (post) => post.children.isNotEmpty,
|
||||||
|
imagesOnlyFilterFunction: (post) => post.entry.hasImages(),
|
||||||
|
videosOnlyFilterFunction: (post) => post.entry.hasVideos(),
|
||||||
|
textSearchFilterFunction: (post, text) =>
|
||||||
|
post.entry.title.contains(text) || post.entry.body.contains(text),
|
||||||
|
itemToDateTimeFunction: (post) => DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
post.entry.creationTimestamp * 1000),
|
||||||
|
dateRangeFilterFunction: (post, start, stop) =>
|
||||||
|
timestampInRange(post.entry.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: 'friendicaEntriesListView',
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
_logger.finer('Rendering Friendica List Item');
|
||||||
|
return TreeEntryCard(
|
||||||
|
treeEntry: items[index],
|
||||||
|
isTopLevel: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) {
|
||||||
|
return const Divider(
|
||||||
|
color: Colors.black,
|
||||||
|
thickness: 0.2,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,367 @@
|
||||||
|
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/tree_entry_card.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_entry_tree_item.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.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/screens/loading_status_screen.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/screens/standin_status_screen.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/services/friendica_archive_service.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:intl/intl.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 GeospatialViewScreen extends StatelessWidget {
|
||||||
|
static final _logger = Logger('$GeospatialViewScreen');
|
||||||
|
|
||||||
|
const GeospatialViewScreen({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.info('Build GeospatialViewScreen');
|
||||||
|
final service = Provider.of<FriendicaArchiveService>(context);
|
||||||
|
|
||||||
|
return FutureBuilder<Result<List<FriendicaEntryTreeItem>, ExecError>>(
|
||||||
|
future: service.getPosts(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
_logger.info('GeospatialViewScreen 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.entry.locationData.hasPosition)
|
||||||
|
.map((e) => e.entry);
|
||||||
|
|
||||||
|
final posts = filteredPosts.toList();
|
||||||
|
|
||||||
|
_logger.fine('Build Posts ListView');
|
||||||
|
return GeospatialView(posts: posts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeospatialView extends StatefulWidget {
|
||||||
|
final List<FriendicaTimelineEntry> 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 = <FriendicaTimelineEntry>[];
|
||||||
|
final postsInView = <FriendicaTimelineEntry>[];
|
||||||
|
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) => TreeEntryCard(
|
||||||
|
treeEntry: FriendicaEntryTreeItem(postsInList[index], false)),
|
||||||
|
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,181 @@
|
||||||
|
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/media_wrapper_component.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_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 MediaSlideShowScreen extends StatefulWidget {
|
||||||
|
static const _spacing = 5.0;
|
||||||
|
|
||||||
|
final List<FriendicaMediaAttachment> mediaAttachments;
|
||||||
|
final int initialIndex;
|
||||||
|
|
||||||
|
const MediaSlideShowScreen(
|
||||||
|
{Key? key, required this.mediaAttachments, required this.initialIndex})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MediaSlideShowScreen> createState() => _MediaSlideShowScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaSlideShowScreenState extends State<MediaSlideShowScreen> {
|
||||||
|
static const fastestChangeMS = 250;
|
||||||
|
FriendicaMediaAttachment media = FriendicaMediaAttachment.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: MediaWrapperComponent(
|
||||||
|
mediaAttachment: media,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: MediaSlideShowScreen._spacing),
|
||||||
|
SelectableText(media.description),
|
||||||
|
const SizedBox(height: MediaSlideShowScreen._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,200 @@
|
||||||
|
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/top_interactactors_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/model_utils.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/services/friendica_archive_service.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/utils/snackbar_status_builder.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class StatsScreen extends StatefulWidget {
|
||||||
|
const StatsScreen({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatsScreen> createState() => _StatsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatsScreenState extends State<StatsScreen> {
|
||||||
|
static final _logger = Logger("$_StatsScreenState");
|
||||||
|
FriendicaArchiveService? 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.entry.creationTimestamp * 1000, entry: e.entry)),
|
||||||
|
onError: (error) {
|
||||||
|
_logger.severe('Error getting posts: $error');
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case StatType.comment:
|
||||||
|
newItems = (await archiveDataService!.getAllComments()).fold(
|
||||||
|
onSuccess: (comments) => comments.map((e) => TimeElement(
|
||||||
|
timeInMS: e.entry.creationTimestamp * 1000, entry: e.entry)),
|
||||||
|
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<FriendicaArchiveService>(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(),
|
||||||
|
TopInteractorsWidget(items, archiveDataService!.connections),
|
||||||
|
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,
|
||||||
|
selectType,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatTypeString on StatType {
|
||||||
|
String toLabel() {
|
||||||
|
switch (this) {
|
||||||
|
case StatType.post:
|
||||||
|
return "Posts";
|
||||||
|
case StatType.comment:
|
||||||
|
return "Comments";
|
||||||
|
case StatType.selectType:
|
||||||
|
return "Select Type";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatType fromLabel(String text) {
|
||||||
|
if (text == 'Posts') {
|
||||||
|
return StatType.post;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text == 'Comments') {
|
||||||
|
return StatType.comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text == 'Select Type') {
|
||||||
|
return StatType.selectType;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ArgumentError(['Unknown enum type: $text', 'text']);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
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 = [
|
||||||
|
'images',
|
||||||
|
'images.json',
|
||||||
|
'postsAndComments.json'
|
||||||
|
];
|
||||||
|
}
|
137
friendica_archive_browser/lib/src/home.dart
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/services/friendica_archive_service.dart';
|
||||||
|
|
||||||
|
import 'friendica/screens/entries_screen.dart';
|
||||||
|
import 'friendica/screens/stats_screen.dart';
|
||||||
|
import 'settings/settings_controller.dart';
|
||||||
|
import 'settings/settings_view.dart';
|
||||||
|
|
||||||
|
class Home extends StatefulWidget {
|
||||||
|
final SettingsController settingsController;
|
||||||
|
final FriendicaArchiveService archiveService;
|
||||||
|
|
||||||
|
const Home(
|
||||||
|
{Key? key,
|
||||||
|
required this.settingsController,
|
||||||
|
required this.archiveService})
|
||||||
|
: 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,
|
||||||
|
() => EntriesScreen(
|
||||||
|
populator: widget.archiveService.getPosts,
|
||||||
|
)),
|
||||||
|
AppPageData(
|
||||||
|
'Orphan\nComments',
|
||||||
|
Icons.comment,
|
||||||
|
() => EntriesScreen(
|
||||||
|
populator: widget.archiveService.getOrphanedComments,
|
||||||
|
)),
|
||||||
|
AppPageData('Stats', Icons.bar_chart, () => const StatsScreen()),
|
||||||
|
AppPageData('Settings', Icons.settings, () => _buildSettingsView()),
|
||||||
|
]);
|
||||||
|
for (var i = 0; i < _pageData.length; i++) {
|
||||||
|
_pages.add(notInitialiedWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory(widget.settingsController.rootFolder).existsSync()) {
|
||||||
|
_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,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
));
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"appTitle": "Friendica Archive Browser",
|
||||||
|
"@appTitle": {
|
||||||
|
"description": "A browser of Friendica Archive Folders"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
class ImageEntry {
|
||||||
|
final String postId;
|
||||||
|
final String localFilename;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
ImageEntry(
|
||||||
|
{required this.postId, required this.localFilename, required this.url});
|
||||||
|
|
||||||
|
ImageEntry.fromJson(Map<String, dynamic> json)
|
||||||
|
: postId = json['postId'] ?? '',
|
||||||
|
localFilename = json['localFilename'] ?? '',
|
||||||
|
url = json['url'] ?? '';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'postId': postId,
|
||||||
|
'localFilename': localFilename,
|
||||||
|
'url': url,
|
||||||
|
};
|
||||||
|
}
|
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}';
|
||||||
|
}
|
||||||
|
}
|
20
friendica_archive_browser/lib/src/models/time_element.dart
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
|
||||||
|
|
||||||
|
class TimeElement {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final FriendicaTimelineEntry entry;
|
||||||
|
|
||||||
|
TimeElement({int timeInMS = 0, required this.entry})
|
||||||
|
: timestamp = DateTime.fromMillisecondsSinceEpoch(timeInMS);
|
||||||
|
|
||||||
|
bool get hasImages => entry.hasImages();
|
||||||
|
|
||||||
|
bool get hasVideos => entry.hasVideos();
|
||||||
|
|
||||||
|
String get text => entry.body;
|
||||||
|
|
||||||
|
String get title => entry.title;
|
||||||
|
|
||||||
|
bool hasText(String phrase) =>
|
||||||
|
text.contains(phrase) || title.contains(phrase);
|
||||||
|
}
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,136 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_entry_tree_item.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/models/local_image_archive_entry.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/services/friendica_connections.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:result_monad/result_monad.dart';
|
||||||
|
|
||||||
|
class FriendicaArchiveService extends ChangeNotifier {
|
||||||
|
final PathMappingService pathMappingService;
|
||||||
|
final Map<String, ImageEntry> _imagesByRequestUrl = {};
|
||||||
|
final List<FriendicaEntryTreeItem> _postEntries = [];
|
||||||
|
final List<FriendicaEntryTreeItem> _orphanedCommentEntries = [];
|
||||||
|
final List<FriendicaEntryTreeItem> _allComments = [];
|
||||||
|
final FriendicaConnections connections = FriendicaConnections();
|
||||||
|
String _ownersName = '';
|
||||||
|
|
||||||
|
FriendicaArchiveService({required this.pathMappingService});
|
||||||
|
|
||||||
|
String get ownersName {
|
||||||
|
if (_ownersName.isNotEmpty) {
|
||||||
|
return _ownersName;
|
||||||
|
}
|
||||||
|
|
||||||
|
final uniqueNames = _postEntries.map((e) => e.entry.author).toSet();
|
||||||
|
_ownersName = uniqueNames.isNotEmpty ? uniqueNames.first : '';
|
||||||
|
|
||||||
|
return _ownersName;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearCaches() {
|
||||||
|
connections.clearCaches();
|
||||||
|
_imagesByRequestUrl.clear();
|
||||||
|
_orphanedCommentEntries.clear();
|
||||||
|
_allComments.clear();
|
||||||
|
_postEntries.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureResult<List<FriendicaEntryTreeItem>, ExecError> getPosts() async {
|
||||||
|
if (_postEntries.isEmpty && _allComments.isEmpty) {
|
||||||
|
_loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(_postEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureResult<List<FriendicaEntryTreeItem>, ExecError> getAllComments() async {
|
||||||
|
if (_postEntries.isEmpty && _allComments.isEmpty) {
|
||||||
|
_loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(_allComments);
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureResult<List<FriendicaEntryTreeItem>, ExecError>
|
||||||
|
getOrphanedComments() async {
|
||||||
|
if (_postEntries.isEmpty && _allComments.isEmpty) {
|
||||||
|
_loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(_orphanedCommentEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<ImageEntry, ExecError> getImageByUrl(String url) {
|
||||||
|
if (_imagesByRequestUrl.isEmpty) {
|
||||||
|
_loadImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = _imagesByRequestUrl[url];
|
||||||
|
return result == null
|
||||||
|
? Result.error(ExecError(errorMessage: '$url not found'))
|
||||||
|
: Result.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _baseArchiveFolder => pathMappingService.rootFolder;
|
||||||
|
|
||||||
|
void _loadEntries() {
|
||||||
|
final entriesJsonPath = p.join(_baseArchiveFolder, 'postsAndComments.json');
|
||||||
|
final jsonFile = File(entriesJsonPath);
|
||||||
|
if (jsonFile.existsSync()) {
|
||||||
|
final json = jsonDecode(jsonFile.readAsStringSync()) as List<dynamic>;
|
||||||
|
final entries =
|
||||||
|
json.map((j) => FriendicaTimelineEntry.fromJson(j, connections));
|
||||||
|
final topLevelEntries =
|
||||||
|
entries.where((element) => element.parentId.isEmpty);
|
||||||
|
final commentEntries =
|
||||||
|
entries.where((element) => element.parentId.isNotEmpty).toList();
|
||||||
|
final entryTrees = <String, FriendicaEntryTreeItem>{};
|
||||||
|
|
||||||
|
final postTreeEntries = <FriendicaEntryTreeItem>[];
|
||||||
|
for (final entry in topLevelEntries) {
|
||||||
|
final treeEntry = FriendicaEntryTreeItem(entry, false);
|
||||||
|
entryTrees[entry.id] = treeEntry;
|
||||||
|
postTreeEntries.add(treeEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
final commentTreeEntries = <FriendicaEntryTreeItem>[];
|
||||||
|
commentEntries.sort(
|
||||||
|
(c1, c2) => c1.creationTimestamp.compareTo(c2.creationTimestamp));
|
||||||
|
for (final entry in commentEntries) {
|
||||||
|
final parent = entryTrees[entry.parentId];
|
||||||
|
final treeEntry = FriendicaEntryTreeItem(entry, parent == null);
|
||||||
|
parent?.addChild(treeEntry);
|
||||||
|
entryTrees[entry.id] = treeEntry;
|
||||||
|
commentTreeEntries.add(treeEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
_postEntries.clear();
|
||||||
|
_postEntries.addAll(postTreeEntries);
|
||||||
|
|
||||||
|
_allComments.clear();
|
||||||
|
_allComments.addAll(commentTreeEntries);
|
||||||
|
|
||||||
|
_orphanedCommentEntries.clear();
|
||||||
|
_orphanedCommentEntries
|
||||||
|
.addAll(entryTrees.values.where((element) => element.isOrphaned));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadImages() {
|
||||||
|
final imageJsonPath = p.join(_baseArchiveFolder, 'images.json');
|
||||||
|
final jsonFile = File(imageJsonPath);
|
||||||
|
if (jsonFile.existsSync()) {
|
||||||
|
final json = jsonDecode(jsonFile.readAsStringSync()) as List<dynamic>;
|
||||||
|
final imageEntries = json.map((j) => ImageEntry.fromJson(j));
|
||||||
|
for (final entry in imageEntries) {
|
||||||
|
_imagesByRequestUrl[entry.url] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_contact.dart';
|
||||||
|
import 'package:result_monad/result_monad.dart';
|
||||||
|
|
||||||
|
class FriendicaConnections {
|
||||||
|
final _connectionsById = <String, FriendicaContact>{};
|
||||||
|
final _connectionsByName = <String, FriendicaContact>{};
|
||||||
|
|
||||||
|
void clearCaches() {
|
||||||
|
_connectionsById.clear();
|
||||||
|
_connectionsByName.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addConnection(FriendicaContact contact) {
|
||||||
|
if (_connectionsById.containsKey(contact.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_connectionsById[contact.id] = contact;
|
||||||
|
_connectionsByName[contact.name] = contact;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<FriendicaContact, String> getById(String id) {
|
||||||
|
final result = _connectionsById[id];
|
||||||
|
|
||||||
|
return result != null ? Result.ok(result) : Result.error('$id not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<FriendicaContact, String> getByName(String name) {
|
||||||
|
final result = _connectionsByName[name];
|
||||||
|
|
||||||
|
return result != null ? Result.ok(result) : Result.error('$name not found');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/settings/video_player_settings.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/utils/temp_file_builder.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'settings_service.dart';
|
||||||
|
|
||||||
|
class SettingsController with ChangeNotifier {
|
||||||
|
final String logPath;
|
||||||
|
final SettingsService _settingsService;
|
||||||
|
|
||||||
|
SettingsController({required this.logPath})
|
||||||
|
: _settingsService = SettingsService();
|
||||||
|
|
||||||
|
Future<void> loadSettings() async {
|
||||||
|
_themeMode = await _settingsService.themeMode();
|
||||||
|
_rootFolder = await _settingsService.rootFolder();
|
||||||
|
_videoPlayerSettingType = await _settingsService.videoPlayerSettingType();
|
||||||
|
_videoPlayerCommand = await _settingsService.videoPlayerCommand();
|
||||||
|
_dateTimeFormatter = DateFormat('MMMM dd yyyy h:mm a');
|
||||||
|
_dateFormatter = DateFormat('MMMM dd yyyy');
|
||||||
|
_logLevel = await _settingsService.logLevel();
|
||||||
|
_appDataDirectory = await getApplicationSupportDirectory();
|
||||||
|
_geoCacheDirectory = await getTileCachedDirectory();
|
||||||
|
Logger.root.level = _logLevel;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ThemeMode _themeMode;
|
||||||
|
|
||||||
|
ThemeMode get themeMode => _themeMode;
|
||||||
|
|
||||||
|
Future<void> updateThemeMode(ThemeMode? newThemeMode) async {
|
||||||
|
if (newThemeMode == null) return;
|
||||||
|
|
||||||
|
// Dot not perform any work if new and old ThemeMode are identical
|
||||||
|
if (newThemeMode == _themeMode) return;
|
||||||
|
|
||||||
|
// Otherwise, store the new theme mode in memory
|
||||||
|
_themeMode = newThemeMode;
|
||||||
|
|
||||||
|
// Important! Inform listeners a change has occurred.
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Persist the changes to a local database or the internet using the
|
||||||
|
// SettingService.
|
||||||
|
await _settingsService.updateThemeMode(newThemeMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
late VideoPlayerSettingType _videoPlayerSettingType;
|
||||||
|
|
||||||
|
VideoPlayerSettingType get videoPlayerSettingType => _videoPlayerSettingType;
|
||||||
|
|
||||||
|
Future<void> updateVideoPlayerSettingType(VideoPlayerSettingType type) async {
|
||||||
|
if (type == _videoPlayerSettingType) return;
|
||||||
|
_videoPlayerSettingType = type;
|
||||||
|
if (_videoPlayerSettingType != VideoPlayerSettingType.custom) {
|
||||||
|
await _resetVideoPlayerCommand();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
await _settingsService.updateVideoPlayerSettingType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
late String _videoPlayerCommand;
|
||||||
|
|
||||||
|
String get videoPlayerCommand => _videoPlayerCommand;
|
||||||
|
|
||||||
|
Future<void> updateVideoPlayerCommand(String newCommand) async {
|
||||||
|
if (newCommand == _videoPlayerCommand) return;
|
||||||
|
_videoPlayerCommand = newCommand;
|
||||||
|
notifyListeners();
|
||||||
|
await _settingsService.updateVideoPlayerCommand(newCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resetVideoPlayerCommand() async {
|
||||||
|
_videoPlayerCommand = _videoPlayerSettingType.toAppPath();
|
||||||
|
await _settingsService.updateVideoPlayerCommand(_videoPlayerCommand);
|
||||||
|
}
|
||||||
|
}
|
110
friendica_archive_browser/lib/src/settings/settings_service.dart
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'video_player_settings.dart';
|
||||||
|
|
||||||
|
class SettingsService {
|
||||||
|
static const themeDarknessKey = 'themeDarkness';
|
||||||
|
static const rootFolderKey = 'rootFolder';
|
||||||
|
static const videoPlayerSettingTypeKey = 'videoPlayerSettingType';
|
||||||
|
static const videoPlayerCommandKey = 'videoPlayerCustomPath';
|
||||||
|
static const logLevelKey = "logLevel";
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateThemeMode(ThemeMode theme) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
prefs.setInt(themeDarknessKey, theme.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> rootFolder() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final result = prefs.getString(rootFolderKey) ?? '';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateRootFolder(String folder) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(rootFolderKey, folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<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;
|
||||||
|
}
|
||||||
|
}
|
337
friendica_archive_browser/lib/src/settings/settings_view.dart
Normal file
|
@ -0,0 +1,337 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.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';
|
||||||
|
|
||||||
|
class SettingsView extends StatefulWidget {
|
||||||
|
const SettingsView({Key? key, required SettingsController controller})
|
||||||
|
: _settingsController = controller,
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
static const routeName = '/settings';
|
||||||
|
|
||||||
|
final SettingsController _settingsController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingsView> createState() => _SettingsViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsViewState extends State<SettingsView> {
|
||||||
|
static final _logger = Logger('$_SettingsViewState');
|
||||||
|
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);
|
||||||
|
_videoPlayerPathController.addListener(() {
|
||||||
|
_updateSettingsValueDiffs();
|
||||||
|
});
|
||||||
|
_setInitialValues();
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_updateSettingsValueDiffs();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Settings'),
|
||||||
|
backgroundColor: Theme.of(context).canvasColor,
|
||||||
|
foregroundColor: Theme.of(context).primaryColor,
|
||||||
|
elevation: 0.0,
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(children: [
|
||||||
|
_buildThemeOptions(context),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildLoggingOptions(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 Friendica archive file',
|
||||||
|
errorText: _invalidFolderString,
|
||||||
|
))),
|
||||||
|
const SizedBox(width: 15),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _setNewRootFolder,
|
||||||
|
icon: const Icon(Icons.folder_outlined)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThemeOptions(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('Application Theme: ',
|
||||||
|
style: Theme.of(context).textTheme.bodyText1),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
DropdownButton<ThemeMode>(
|
||||||
|
value: widget._settingsController.themeMode,
|
||||||
|
onChanged: (newMode) async {
|
||||||
|
await widget._settingsController.updateThemeMode(newMode);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ThemeMode.system,
|
||||||
|
child: Text('System Theme'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ThemeMode.light,
|
||||||
|
child: Text('Light Theme'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ThemeMode.dark,
|
||||||
|
child: Text('Dark Theme'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setInitialValues() {
|
||||||
|
_folderPathController.text = widget._settingsController.rootFolder;
|
||||||
|
_validateRootFolder();
|
||||||
|
_videoPlayerTypeOption = widget._settingsController.videoPlayerSettingType;
|
||||||
|
_videoPlayerPathController.text =
|
||||||
|
widget._settingsController.videoPlayerCommand;
|
||||||
|
_logLevel = widget._settingsController.logLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (oldValue == newValue) return;
|
||||||
|
setState(() {
|
||||||
|
_differentSettingValues = newValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateRootFolder() {
|
||||||
|
setState(() {
|
||||||
|
_validRootFolder = false;
|
||||||
|
if (!Directory(_folderPathController.text).existsSync()) {
|
||||||
|
_invalidFolderString = 'Choose an existing folder';
|
||||||
|
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
|
@ -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
|
@ -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,21 @@
|
||||||
|
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
|
||||||
|
import 'package:result_monad/result_monad.dart';
|
||||||
|
import 'package:time_machine/time_machine_text_patterns.dart';
|
||||||
|
|
||||||
|
class OffsetDateTimeUtils {
|
||||||
|
static final _parser = OffsetDateTimePattern.createWithInvariantCulture(
|
||||||
|
'ddd MMM dd HH:mm:ss o<+HHmm> yyyy');
|
||||||
|
|
||||||
|
static Result<int, ExecError> epochSecTimeFromFriendicaString(
|
||||||
|
String dateString) {
|
||||||
|
final offsetDateTime = _parser.parse(dateString);
|
||||||
|
if (!offsetDateTime.success) {
|
||||||
|
return Result.error(ExecError.message(offsetDateTime.error.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(offsetDateTime.value.localDateTime
|
||||||
|
.toDateTimeLocal()
|
||||||
|
.millisecondsSinceEpoch ~/
|
||||||
|
1000);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AppScrollingBehavior 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);
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_contact.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
|
||||||
|
import 'package:friendica_archive_browser/src/services/friendica_connections.dart';
|
||||||
|
|
||||||
|
class TopInteractorsGenerator {
|
||||||
|
final _interactors = <String, InteractorItem>{};
|
||||||
|
final _processedEntryIds = <String>{};
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_interactors.clear();
|
||||||
|
_processedEntryIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void processEntry(
|
||||||
|
FriendicaTimelineEntry item, FriendicaConnections contacts) {
|
||||||
|
if (_processedEntryIds.contains(item.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_processedEntryIds.add(item.id);
|
||||||
|
|
||||||
|
if (item.parentAuthorId.isNotEmpty) {
|
||||||
|
final interactorItem =
|
||||||
|
_getInteractorItemById(item.parentAuthorId, contacts);
|
||||||
|
_interactors[item.parentAuthorId] =
|
||||||
|
interactorItem.incrementResharedOrCommentedOn();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final like in item.likes) {
|
||||||
|
final interactorItem =
|
||||||
|
_interactors[like.id] ?? InteractorItem(contact: like);
|
||||||
|
_interactors[like.id] = interactorItem.incrementLike();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final dislike in item.dislikes) {
|
||||||
|
final interactorItem =
|
||||||
|
_interactors[dislike.id] ?? InteractorItem(contact: dislike);
|
||||||
|
_interactors[dislike.id] = interactorItem.incrementDislike();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<InteractorItem> getTopCommentReshare(int threshold) {
|
||||||
|
final forResult = List.of(_interactors.values);
|
||||||
|
forResult.sort((i1, i2) =>
|
||||||
|
i2.resharedOrCommentedOn.compareTo(i1.resharedOrCommentedOn));
|
||||||
|
return forResult.take(threshold).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<InteractorItem> getTopLikes(int threshold) {
|
||||||
|
final forResult = List.of(_interactors.values);
|
||||||
|
forResult.sort((i1, i2) => i2.likeCount.compareTo(i1.likeCount));
|
||||||
|
return forResult.take(threshold).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<InteractorItem> getTopDislikes(int threshold) {
|
||||||
|
final forResult = List.of(_interactors.values);
|
||||||
|
forResult.sort((i1, i2) => i2.dislikeCount.compareTo(i1.dislikeCount));
|
||||||
|
return forResult.take(threshold).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
InteractorItem _getInteractorItemById(
|
||||||
|
String id, FriendicaConnections contacts) {
|
||||||
|
if (_interactors.containsKey(id)) {
|
||||||
|
return _interactors[id]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final contact = contacts.getById(id).fold(
|
||||||
|
onSuccess: (contact) => contact,
|
||||||
|
onError: (error) => FriendicaContact(
|
||||||
|
status: ConnectionStatus.none,
|
||||||
|
name: '',
|
||||||
|
id: id,
|
||||||
|
profileUrl: Uri(),
|
||||||
|
network: 'network'));
|
||||||
|
return InteractorItem(contact: contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InteractorItem {
|
||||||
|
final FriendicaContact contact;
|
||||||
|
final int resharedOrCommentedOn;
|
||||||
|
final int likeCount;
|
||||||
|
final int dislikeCount;
|
||||||
|
|
||||||
|
InteractorItem(
|
||||||
|
{required this.contact,
|
||||||
|
this.resharedOrCommentedOn = 0,
|
||||||
|
this.likeCount = 0,
|
||||||
|
this.dislikeCount = 0});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'InteractorItem{contact: $contact, resharedOrCommentedOn: $resharedOrCommentedOn, likeCount: $likeCount, dislikeCount: $dislikeCount}';
|
||||||
|
}
|
||||||
|
|
||||||
|
InteractorItem copy(
|
||||||
|
{FriendicaContact? contact,
|
||||||
|
int? resharedOrCommentedOn,
|
||||||
|
int? likeCount,
|
||||||
|
int? dislikeCount}) {
|
||||||
|
return InteractorItem(
|
||||||
|
contact: contact ?? this.contact,
|
||||||
|
resharedOrCommentedOn:
|
||||||
|
resharedOrCommentedOn ?? this.resharedOrCommentedOn,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
dislikeCount: dislikeCount ?? this.dislikeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
InteractorItem incrementResharedOrCommentedOn() =>
|
||||||
|
copy(resharedOrCommentedOn: this.resharedOrCommentedOn + 1);
|
||||||
|
|
||||||
|
InteractorItem incrementLike() => copy(likeCount: this.likeCount + 1);
|
||||||
|
|
||||||
|
InteractorItem incrementDislike() =>
|
||||||
|
copy(dislikeCount: this.dislikeCount + 1);
|
||||||
|
}
|
148
friendica_archive_browser/lib/src/utils/word_map_generator.dart
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:html/parser.dart' show parse;
|
||||||
|
|
||||||
|
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 topLevelText = parse(text)
|
||||||
|
.body
|
||||||
|
?.nodes
|
||||||
|
.where((element) => element.nodeType == 3)
|
||||||
|
.join(' ') ??
|
||||||
|
text;
|
||||||
|
|
||||||
|
final wordsFromText = topLevelText
|
||||||
|
.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',
|
||||||
|
};
|
1
friendica_archive_browser/linux/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
flutter/ephemeral
|
116
friendica_archive_browser/linux/CMakeLists.txt
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
cmake_minimum_required(VERSION 3.10)
|
||||||
|
project(runner LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(BINARY_NAME "friendica_archive_browser")
|
||||||
|
set(APPLICATION_ID "social.myportal.friendica_archive_browser")
|
||||||
|
|
||||||
|
cmake_policy(SET CMP0063 NEW)
|
||||||
|
|
||||||
|
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||||
|
|
||||||
|
# Root filesystem for cross-building.
|
||||||
|
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||||
|
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
||||||
|
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Configure build options.
|
||||||
|
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||||
|
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||||
|
STRING "Flutter build mode" FORCE)
|
||||||
|
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||||
|
"Debug" "Profile" "Release")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Compilation settings that should be applied to most targets.
|
||||||
|
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||||
|
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||||
|
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||||
|
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||||
|
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||||
|
|
||||||
|
# Flutter library and tool build rules.
|
||||||
|
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||||
|
|
||||||
|
# System-level dependencies.
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||||
|
|
||||||
|
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||||
|
|
||||||
|
# Application build
|
||||||
|
add_executable(${BINARY_NAME}
|
||||||
|
"main.cc"
|
||||||
|
"my_application.cc"
|
||||||
|
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||||
|
)
|
||||||
|
apply_standard_settings(${BINARY_NAME})
|
||||||
|
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||||
|
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||||
|
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||||
|
# Only the install-generated bundle's copy of the executable will launch
|
||||||
|
# correctly, since the resources must in the right relative locations. To avoid
|
||||||
|
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||||
|
# the default top-level location.
|
||||||
|
set_target_properties(${BINARY_NAME}
|
||||||
|
PROPERTIES
|
||||||
|
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generated plugin build rules, which manage building the plugins and adding
|
||||||
|
# them to the application.
|
||||||
|
include(flutter/generated_plugins.cmake)
|
||||||
|
|
||||||
|
|
||||||
|
# === Installation ===
|
||||||
|
# By default, "installing" just makes a relocatable bundle in the build
|
||||||
|
# directory.
|
||||||
|
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||||
|
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||||
|
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Start with a clean build bundle directory every time.
|
||||||
|
install(CODE "
|
||||||
|
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||||
|
" COMPONENT Runtime)
|
||||||
|
|
||||||
|
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||||
|
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||||
|
|
||||||
|
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
|
||||||
|
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
|
||||||
|
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
|
||||||
|
if(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
|
||||||
|
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||||
|
# from a previous install.
|
||||||
|
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||||
|
install(CODE "
|
||||||
|
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||||
|
" COMPONENT Runtime)
|
||||||
|
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||||
|
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||||
|
|
||||||
|
# Install the AOT library on non-Debug builds only.
|
||||||
|
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||||
|
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
endif()
|
87
friendica_archive_browser/linux/flutter/CMakeLists.txt
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
cmake_minimum_required(VERSION 3.10)
|
||||||
|
|
||||||
|
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||||
|
|
||||||
|
# Configuration provided via flutter tool.
|
||||||
|
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||||
|
|
||||||
|
# TODO: Move the rest of this into files in ephemeral. See
|
||||||
|
# https://github.com/flutter/flutter/issues/57146.
|
||||||
|
|
||||||
|
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||||
|
# which isn't available in 3.10.
|
||||||
|
function(list_prepend LIST_NAME PREFIX)
|
||||||
|
set(NEW_LIST "")
|
||||||
|
foreach(element ${${LIST_NAME}})
|
||||||
|
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||||
|
endforeach(element)
|
||||||
|
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
# === Flutter Library ===
|
||||||
|
# System-level dependencies.
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||||
|
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||||
|
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||||
|
|
||||||
|
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||||
|
|
||||||
|
# Published to parent scope for install step.
|
||||||
|
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||||
|
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||||
|
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||||
|
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||||
|
|
||||||
|
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||||
|
"fl_basic_message_channel.h"
|
||||||
|
"fl_binary_codec.h"
|
||||||
|
"fl_binary_messenger.h"
|
||||||
|
"fl_dart_project.h"
|
||||||
|
"fl_engine.h"
|
||||||
|
"fl_json_message_codec.h"
|
||||||
|
"fl_json_method_codec.h"
|
||||||
|
"fl_message_codec.h"
|
||||||
|
"fl_method_call.h"
|
||||||
|
"fl_method_channel.h"
|
||||||
|
"fl_method_codec.h"
|
||||||
|
"fl_method_response.h"
|
||||||
|
"fl_plugin_registrar.h"
|
||||||
|
"fl_plugin_registry.h"
|
||||||
|
"fl_standard_message_codec.h"
|
||||||
|
"fl_standard_method_codec.h"
|
||||||
|
"fl_string_codec.h"
|
||||||
|
"fl_value.h"
|
||||||
|
"fl_view.h"
|
||||||
|
"flutter_linux.h"
|
||||||
|
)
|
||||||
|
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||||
|
add_library(flutter INTERFACE)
|
||||||
|
target_include_directories(flutter INTERFACE
|
||||||
|
"${EPHEMERAL_DIR}"
|
||||||
|
)
|
||||||
|
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||||
|
target_link_libraries(flutter INTERFACE
|
||||||
|
PkgConfig::GTK
|
||||||
|
PkgConfig::GLIB
|
||||||
|
PkgConfig::GIO
|
||||||
|
)
|
||||||
|
add_dependencies(flutter flutter_assemble)
|
||||||
|
|
||||||
|
# === Flutter tool backend ===
|
||||||
|
# _phony_ is a non-existent file to force this command to run every time,
|
||||||
|
# since currently there's no way to get a full input/output list from the
|
||||||
|
# flutter tool.
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E env
|
||||||
|
${FLUTTER_TOOL_ENVIRONMENT}
|
||||||
|
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||||
|
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
add_custom_target(flutter_assemble DEPENDS
|
||||||
|
"${FLUTTER_LIBRARY}"
|
||||||
|
${FLUTTER_LIBRARY_HEADERS}
|
||||||
|
)
|
|
@ -0,0 +1,19 @@
|
||||||
|
//
|
||||||
|
// Generated file. Do not edit.
|
||||||
|
//
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
|
||||||
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <desktop_window/desktop_window_plugin.h>
|
||||||
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) desktop_window_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWindowPlugin");
|
||||||
|
desktop_window_plugin_register_with_registrar(desktop_window_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
//
|
||||||
|
// Generated file. Do not edit.
|
||||||
|
//
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
|
||||||
|
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||||
|
#define GENERATED_PLUGIN_REGISTRANT_
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
|
||||||
|
// Registers Flutter plugins.
|
||||||
|
void fl_register_plugins(FlPluginRegistry* registry);
|
||||||
|
|
||||||
|
#endif // GENERATED_PLUGIN_REGISTRANT_
|
|
@ -0,0 +1,17 @@
|
||||||
|
#
|
||||||
|
# Generated file, do not edit.
|
||||||
|
#
|
||||||
|
|
||||||
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
desktop_window
|
||||||
|
url_launcher_linux
|
||||||
|
)
|
||||||
|
|
||||||
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|
||||||
|
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||||
|
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
||||||
|
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||||
|
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||||
|
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||||
|
endforeach(plugin)
|
6
friendica_archive_browser/linux/main.cc
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
#include "my_application.h"
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
g_autoptr(MyApplication) app = my_application_new();
|
||||||
|
return g_application_run(G_APPLICATION(app), argc, argv);
|
||||||
|
}
|
104
friendica_archive_browser/linux/my_application.cc
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
#include "my_application.h"
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#ifdef GDK_WINDOWING_X11
|
||||||
|
#include <gdk/gdkx.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
struct _MyApplication {
|
||||||
|
GtkApplication parent_instance;
|
||||||
|
char** dart_entrypoint_arguments;
|
||||||
|
};
|
||||||
|
|
||||||
|
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||||
|
|
||||||
|
// Implements GApplication::activate.
|
||||||
|
static void my_application_activate(GApplication* application) {
|
||||||
|
MyApplication* self = MY_APPLICATION(application);
|
||||||
|
GtkWindow* window =
|
||||||
|
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
||||||
|
|
||||||
|
// Use a header bar when running in GNOME as this is the common style used
|
||||||
|
// by applications and is the setup most users will be using (e.g. Ubuntu
|
||||||
|
// desktop).
|
||||||
|
// If running on X and not using GNOME then just use a traditional title bar
|
||||||
|
// in case the window manager does more exotic layout, e.g. tiling.
|
||||||
|
// If running on Wayland assume the header bar will work (may need changing
|
||||||
|
// if future cases occur).
|
||||||
|
gboolean use_header_bar = TRUE;
|
||||||
|
#ifdef GDK_WINDOWING_X11
|
||||||
|
GdkScreen* screen = gtk_window_get_screen(window);
|
||||||
|
if (GDK_IS_X11_SCREEN(screen)) {
|
||||||
|
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
|
||||||
|
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
|
||||||
|
use_header_bar = FALSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if (use_header_bar) {
|
||||||
|
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||||
|
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||||
|
gtk_header_bar_set_title(header_bar, "Friendica Archive Browser");
|
||||||
|
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||||
|
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||||
|
} else {
|
||||||
|
gtk_window_set_title(window, "Friendica Archive Browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
gtk_window_set_default_size(window, 900, 700);
|
||||||
|
gtk_widget_show(GTK_WIDGET(window));
|
||||||
|
|
||||||
|
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||||
|
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
|
||||||
|
|
||||||
|
FlView* view = fl_view_new(project);
|
||||||
|
gtk_widget_show(GTK_WIDGET(view));
|
||||||
|
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
|
||||||
|
|
||||||
|
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||||
|
|
||||||
|
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implements GApplication::local_command_line.
|
||||||
|
static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
|
||||||
|
MyApplication* self = MY_APPLICATION(application);
|
||||||
|
// Strip out the first argument as it is the binary name.
|
||||||
|
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
|
||||||
|
|
||||||
|
g_autoptr(GError) error = nullptr;
|
||||||
|
if (!g_application_register(application, nullptr, &error)) {
|
||||||
|
g_warning("Failed to register: %s", error->message);
|
||||||
|
*exit_status = 1;
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_application_activate(application);
|
||||||
|
*exit_status = 0;
|
||||||
|
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implements GObject::dispose.
|
||||||
|
static void my_application_dispose(GObject* object) {
|
||||||
|
MyApplication* self = MY_APPLICATION(object);
|
||||||
|
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||||
|
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void my_application_class_init(MyApplicationClass* klass) {
|
||||||
|
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
|
||||||
|
G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
|
||||||
|
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void my_application_init(MyApplication* self) {}
|
||||||
|
|
||||||
|
MyApplication* my_application_new() {
|
||||||
|
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||||
|
"application-id", APPLICATION_ID,
|
||||||
|
"flags", G_APPLICATION_NON_UNIQUE,
|
||||||
|
nullptr));
|
||||||
|
}
|
18
friendica_archive_browser/linux/my_application.h
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
#ifndef FLUTTER_MY_APPLICATION_H_
|
||||||
|
#define FLUTTER_MY_APPLICATION_H_
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
|
||||||
|
GtkApplication)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* my_application_new:
|
||||||
|
*
|
||||||
|
* Creates a new Flutter-based application.
|
||||||
|
*
|
||||||
|
* Returns: a new #MyApplication.
|
||||||
|
*/
|
||||||
|
MyApplication* my_application_new();
|
||||||
|
|
||||||
|
#endif // FLUTTER_MY_APPLICATION_H_
|
7
friendica_archive_browser/macos/.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Flutter-related
|
||||||
|
**/Flutter/ephemeral/
|
||||||
|
**/Pods/
|
||||||
|
|
||||||
|
# Xcode-related
|
||||||
|
**/dgph
|
||||||
|
**/xcuserdata/
|
|
@ -0,0 +1,2 @@
|
||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
|
#include "ephemeral/Flutter-Generated.xcconfig"
|
|
@ -0,0 +1,2 @@
|
||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
|
#include "ephemeral/Flutter-Generated.xcconfig"
|
|
@ -0,0 +1,18 @@
|
||||||
|
//
|
||||||
|
// Generated file. Do not edit.
|
||||||
|
//
|
||||||
|
|
||||||
|
import FlutterMacOS
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
import desktop_window
|
||||||
|
import path_provider_macos
|
||||||
|
import shared_preferences_macos
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
DesktopWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWindowPlugin"))
|
||||||
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
}
|
40
friendica_archive_browser/macos/Podfile
Normal file
|
@ -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
|
@ -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
|
633
friendica_archive_browser/macos/Runner.xcodeproj/project.pbxproj
Normal file
|
@ -0,0 +1,633 @@
|
||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 51;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXAggregateTarget section */
|
||||||
|
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
|
||||||
|
isa = PBXAggregateTarget;
|
||||||
|
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
|
||||||
|
buildPhases = (
|
||||||
|
33CC111E2044C6BF0003C045 /* ShellScript */,
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = "Flutter Assemble";
|
||||||
|
productName = FLX;
|
||||||
|
};
|
||||||
|
/* End PBXAggregateTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
||||||
|
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||||
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||||
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||||
|
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||||
|
4BDE3286C73FDA8B999E5FF1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A62DF101BD155ACE6A97EE5 /* Pods_Runner.framework */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
|
||||||
|
remoteInfo = FLX;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
33CC110E2044A8840003C045 /* Bundle Framework */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 10;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
name = "Bundle Framework";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||||
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||||
|
33CC10ED2044A3C60003C045 /* FriendicaArchiveBrowser.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Friendica Archive Browser.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||||
|
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
|
||||||
|
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
|
||||||
|
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; 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>"; };
|
||||||
|
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||||
|
4BB1ABD4272E2E48001A21BE /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
|
||||||
|
5A62DF101BD155ACE6A97EE5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
65CE868E4C57844CD2D62123 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
8FA4A7CF34D8F959E50C03F8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
982DE8DD39E855D2451A342E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
33CC10EA2044A3C60003C045 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
4BDE3286C73FDA8B999E5FF1 /* Pods_Runner.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
1AD654E9D11F7EC5F226D2B4 /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
982DE8DD39E855D2451A342E /* Pods-Runner.debug.xcconfig */,
|
||||||
|
65CE868E4C57844CD2D62123 /* Pods-Runner.release.xcconfig */,
|
||||||
|
8FA4A7CF34D8F959E50C03F8 /* Pods-Runner.profile.xcconfig */,
|
||||||
|
);
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33BA886A226E78AF003329D5 /* Configs */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
|
||||||
|
);
|
||||||
|
path = Configs;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33CC10E42044A3C60003C045 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
33FAB671232836740065AC1E /* Runner */,
|
||||||
|
33CEB47122A05771004F2AC0 /* Flutter */,
|
||||||
|
33CC10EE2044A3C60003C045 /* Products */,
|
||||||
|
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||||
|
1AD654E9D11F7EC5F226D2B4 /* Pods */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
33CC10ED2044A3C60003C045 /* FriendicaArchiveBrowser.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33CC11242044D66E0003C045 /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
33CC10F22044A3C60003C045 /* Assets.xcassets */,
|
||||||
|
33CC10F42044A3C60003C045 /* MainMenu.xib */,
|
||||||
|
33CC10F72044A3C60003C045 /* Info.plist */,
|
||||||
|
);
|
||||||
|
name = Resources;
|
||||||
|
path = ..;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||||
|
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||||
|
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||||
|
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
|
||||||
|
);
|
||||||
|
path = Flutter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33FAB671232836740065AC1E /* Runner */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4BB1ABD4272E2E48001A21BE /* RunnerDebug.entitlements */,
|
||||||
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||||
|
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||||
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||||
|
33E51914231749380026EE4D /* Release.entitlements */,
|
||||||
|
33CC11242044D66E0003C045 /* Resources */,
|
||||||
|
33BA886A226E78AF003329D5 /* Configs */,
|
||||||
|
);
|
||||||
|
path = Runner;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5A62DF101BD155ACE6A97EE5 /* Pods_Runner.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
33CC10EC2044A3C60003C045 /* Runner */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
|
buildPhases = (
|
||||||
|
433B9574F7E94075D058E585 /* [CP] Check Pods Manifest.lock */,
|
||||||
|
33CC10E92044A3C60003C045 /* Sources */,
|
||||||
|
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||||
|
33CC10EB2044A3C60003C045 /* Resources */,
|
||||||
|
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||||
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
|
44C7D6FFFB0D86BDDC27CD29 /* [CP] Embed Pods Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
33CC11202044C79F0003C045 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = Runner;
|
||||||
|
productName = Runner;
|
||||||
|
productReference = 33CC10ED2044A3C60003C045 /* FriendicaArchiveBrowser.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
33CC10E52044A3C60003C045 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
LastSwiftUpdateCheck = 0920;
|
||||||
|
LastUpgradeCheck = 0930;
|
||||||
|
ORGANIZATIONNAME = "";
|
||||||
|
TargetAttributes = {
|
||||||
|
33CC10EC2044A3C60003C045 = {
|
||||||
|
CreatedOnToolsVersion = 9.2;
|
||||||
|
LastSwiftMigration = 1100;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
SystemCapabilities = {
|
||||||
|
com.apple.Sandbox = {
|
||||||
|
enabled = 1;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
33CC111A2044C6BA0003C045 = {
|
||||||
|
CreatedOnToolsVersion = 9.2;
|
||||||
|
ProvisioningStyle = Manual;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
|
||||||
|
compatibilityVersion = "Xcode 9.3";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 33CC10E42044A3C60003C045;
|
||||||
|
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
33CC10EC2044A3C60003C045 /* Runner */,
|
||||||
|
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
33CC10EB2044A3C60003C045 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
|
||||||
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
3399D490228B24CF009A79C7 /* ShellScript */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
|
||||||
|
};
|
||||||
|
33CC111E2044C6BF0003C045 /* ShellScript */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
Flutter/ephemeral/FlutterInputs.xcfilelist,
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
Flutter/ephemeral/tripwire,
|
||||||
|
);
|
||||||
|
outputFileListPaths = (
|
||||||
|
Flutter/ephemeral/FlutterOutputs.xcfilelist,
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||||
|
};
|
||||||
|
433B9574F7E94075D058E585 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
44C7D6FFFB0D86BDDC27CD29 /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
33CC10E92044A3C60003C045 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
||||||
|
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
||||||
|
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
|
||||||
|
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
33CC10F52044A3C60003C045 /* Base */,
|
||||||
|
);
|
||||||
|
name = MainMenu.xib;
|
||||||
|
path = Runner;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
338D0CE9231458BD00FA5F75 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "-";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 10.11;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
338D0CEA231458BD00FA5F75 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
338D0CEB231458BD00FA5F75 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
33CC10F92044A3C60003C045 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "-";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 10.11;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
33CC10FA2044A3C60003C045 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "-";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 10.11;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
33CC10FC2044A3C60003C045 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
33CC10FD2044A3C60003C045 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
33CC111C2044C6BA0003C045 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
33CC111D2044C6BA0003C045 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
33CC10F92044A3C60003C045 /* Debug */,
|
||||||
|
33CC10FA2044A3C60003C045 /* Release */,
|
||||||
|
338D0CE9231458BD00FA5F75 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
33CC10FC2044A3C60003C045 /* Debug */,
|
||||||
|
33CC10FD2044A3C60003C045 /* Release */,
|
||||||
|
338D0CEA231458BD00FA5F75 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
33CC111C2044C6BA0003C045 /* Debug */,
|
||||||
|
33CC111D2044C6BA0003C045 /* Release */,
|
||||||
|
338D0CEB231458BD00FA5F75 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?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>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1000"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
|
BuildableName = "FriendicaArchiveBrowser.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
|
BuildableName = "FriendicaArchiveBrowser.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
|
BuildableName = "FriendicaArchiveBrowser.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Profile"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
|
BuildableName = "FriendicaArchiveBrowser.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
10
friendica_archive_browser/macos/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?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>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
9
friendica_archive_browser/macos/Runner/AppDelegate.swift
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import Cocoa
|
||||||
|
import FlutterMacOS
|
||||||
|
|
||||||
|
@NSApplicationMain
|
||||||
|
class AppDelegate: FlutterAppDelegate {
|
||||||
|
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "16x16",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_16.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "16x16",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_32.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "32x32",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_32.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "32x32",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_64.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "128x128",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_128.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "128x128",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_256.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "256x256",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_256.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "256x256",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_512.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "512x512",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_512.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "512x512",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "fba_app_icon_1024.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
After Width: | Height: | Size: 46 KiB |
After Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 5.8 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 2 KiB |
After Width: | Height: | Size: 479 B |
After Width: | Height: | Size: 3.3 KiB |
After Width: | Height: | Size: 762 B |
After Width: | Height: | Size: 6.5 KiB |
After Width: | Height: | Size: 1.3 KiB |
340
friendica_archive_browser/macos/Runner/Base.lproj/MainMenu.xib
Normal file
|
@ -0,0 +1,340 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="19455" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="macosx"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="19455"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<objects>
|
||||||
|
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
|
||||||
|
<connections>
|
||||||
|
<outlet property="delegate" destination="Voe-Tx-rLC" id="GzC-gU-4Uq"/>
|
||||||
|
</connections>
|
||||||
|
</customObject>
|
||||||
|
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||||
|
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||||
|
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="friendica_archive_browser" customModuleProvider="target">
|
||||||
|
<connections>
|
||||||
|
<outlet property="applicationMenu" destination="uQy-DD-JDr" id="XBo-yE-nKs"/>
|
||||||
|
<outlet property="mainFlutterWindow" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
|
||||||
|
</connections>
|
||||||
|
</customObject>
|
||||||
|
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
|
||||||
|
<menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
|
||||||
|
<items>
|
||||||
|
<menuItem title="APP_NAME" id="1Xt-HY-uBw">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="APP_NAME" systemMenu="apple" id="uQy-DD-JDr">
|
||||||
|
<items>
|
||||||
|
<menuItem title="About APP_NAME" id="5kV-Vb-QxS">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="orderFrontStandardAboutPanel:" target="-1" id="Exp-CZ-Vem"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
|
||||||
|
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
|
||||||
|
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
|
||||||
|
<menuItem title="Services" id="NMo-om-nkz">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
|
||||||
|
<menuItem title="Hide APP_NAME" keyEquivalent="h" id="Olw-nP-bQN">
|
||||||
|
<connections>
|
||||||
|
<action selector="hide:" target="-1" id="PnN-Uc-m68"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="hideOtherApplications:" target="-1" id="VT4-aY-XCT"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Show All" id="Kd2-mp-pUS">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="unhideAllApplications:" target="-1" id="Dhg-Le-xox"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
|
||||||
|
<menuItem title="Quit APP_NAME" keyEquivalent="q" id="4sb-4s-VLi">
|
||||||
|
<connections>
|
||||||
|
<action selector="terminate:" target="-1" id="Te7-pn-YzF"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Edit" id="5QF-Oa-p0T">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Edit" id="W48-6f-4Dl">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
|
||||||
|
<connections>
|
||||||
|
<action selector="undo:" target="-1" id="M6e-cu-g7V"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
|
||||||
|
<connections>
|
||||||
|
<action selector="redo:" target="-1" id="oIA-Rs-6OD"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
|
||||||
|
<menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
|
||||||
|
<connections>
|
||||||
|
<action selector="cut:" target="-1" id="YJe-68-I9s"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
|
||||||
|
<connections>
|
||||||
|
<action selector="copy:" target="-1" id="G1f-GL-Joy"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
|
||||||
|
<connections>
|
||||||
|
<action selector="paste:" target="-1" id="UvS-8e-Qdg"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="pasteAsPlainText:" target="-1" id="cEh-KX-wJQ"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Delete" id="pa3-QI-u2k">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="delete:" target="-1" id="0Mk-Ml-PaM"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
|
||||||
|
<connections>
|
||||||
|
<action selector="selectAll:" target="-1" id="VNm-Mi-diN"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
|
||||||
|
<menuItem title="Find" id="4EN-yA-p0u">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Find" id="1b7-l0-nxx">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="cD7-Qs-BN4"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="WD3-Gg-5AJ"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="NDo-RZ-v9R"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="HOh-sY-3ay"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="U76-nv-p5D"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
|
||||||
|
<connections>
|
||||||
|
<action selector="centerSelectionInVisibleArea:" target="-1" id="IOG-6D-g5B"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
|
||||||
|
<connections>
|
||||||
|
<action selector="showGuessPanel:" target="-1" id="vFj-Ks-hy3"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
|
||||||
|
<connections>
|
||||||
|
<action selector="checkSpelling:" target="-1" id="fz7-VC-reM"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
|
||||||
|
<menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleContinuousSpellChecking:" target="-1" id="7w6-Qz-0kB"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleGrammarChecking:" target="-1" id="muD-Qn-j4w"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticSpellingCorrection:" target="-1" id="2lM-Qi-WAP"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Substitutions" id="9ic-FL-obx">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Show Substitutions" id="z6F-FW-3nz">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="orderFrontSubstitutionsPanel:" target="-1" id="oku-mr-iSq"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
|
||||||
|
<menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleSmartInsertDelete:" target="-1" id="3IJ-Se-DZD"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Smart Quotes" id="hQb-2v-fYv">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticQuoteSubstitution:" target="-1" id="ptq-xd-QOA"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Smart Dashes" id="rgM-f4-ycn">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticDashSubstitution:" target="-1" id="oCt-pO-9gS"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Smart Links" id="cwL-P1-jid">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticLinkDetection:" target="-1" id="Gip-E3-Fov"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Data Detectors" id="tRr-pd-1PS">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticDataDetection:" target="-1" id="R1I-Nq-Kbl"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Text Replacement" id="HFQ-gK-NFA">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticTextReplacement:" target="-1" id="DvP-Fe-Py6"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Transformations" id="2oI-Rn-ZJC">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Transformations" id="c8a-y6-VQd">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Make Upper Case" id="vmV-6d-7jI">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="uppercaseWord:" target="-1" id="sPh-Tk-edu"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Make Lower Case" id="d9M-CD-aMd">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="lowercaseWord:" target="-1" id="iUZ-b5-hil"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Capitalize" id="UEZ-Bs-lqG">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="capitalizeWord:" target="-1" id="26H-TL-nsh"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Speech" id="xrE-MZ-jX0">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Speech" id="3rS-ZA-NoH">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Start Speaking" id="Ynk-f8-cLZ">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="startSpeaking:" target="-1" id="654-Ng-kyl"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Stop Speaking" id="Oyz-dy-DGm">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="stopSpeaking:" target="-1" id="dX8-6p-jy9"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="View" id="H8h-7b-M4v">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="View" id="HyV-fh-RgO">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Enter Full Screen" keyEquivalent="f" id="4J7-dP-txa">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleFullScreen:" target="-1" id="dU3-MA-1Rq"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Window" id="aUF-d1-5bR">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
|
||||||
|
<connections>
|
||||||
|
<action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Zoom" id="R4o-n2-Eq4">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
|
||||||
|
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="arrangeInFront:" target="-1" id="DRN-fu-gQh"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
<point key="canvasLocation" x="142" y="-258"/>
|
||||||
|
</menu>
|
||||||
|
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="friendica_archive_browser" customModuleProvider="target">
|
||||||
|
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||||
|
<rect key="contentRect" x="0.0" y="175" width="915" height="700"/>
|
||||||
|
<rect key="screenRect" x="0.0" y="0.0" width="1440" height="875"/>
|
||||||
|
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="915" height="700"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
</view>
|
||||||
|
<point key="canvasLocation" x="139" y="401"/>
|
||||||
|
</window>
|
||||||
|
</objects>
|
||||||
|
</document>
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Application-level settings for the Runner target.
|
||||||
|
//
|
||||||
|
// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
|
||||||
|
// future. If not, the values below would default to using the project name when this becomes a
|
||||||
|
// 'flutter create' template.
|
||||||
|
|
||||||
|
// The application's name. By default this is also the title of the Flutter window.
|
||||||
|
PRODUCT_NAME = friendica_archive_browser
|
||||||
|
|
||||||
|
// The application's bundle identifier
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = social.myportal.friendica_archive_browser
|
||||||
|
|
||||||
|
// The copyright displayed in application information
|
||||||
|
PRODUCT_COPYRIGHT = Copyright © 2021 Hank G. All rights reserved.
|