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.
|