Merge branch 'browser' into 'main'

Friendica Archive Browser 1.0 and Friendica Archiver 1.1.0

See merge request mysocialportal/friendica-archiving-tools!1
This commit is contained in:
HankG 2022-01-23 17:14:13 +00:00
commit eebfc52713
143 changed files with 9244 additions and 1 deletions

373
LICENSE Normal file
View file

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

46
friendica_archive_browser/.gitignore vendored Normal file
View 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

View 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

View file

@ -0,0 +1,3 @@
# Friendica Archive Browser Changelog
## Version 1.0.0

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

View file

@ -0,0 +1,3 @@
arb-dir: lib/src/localization
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

View 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;
}

View 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),
),
);
},
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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}')),
])),
);
}
}

View file

@ -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}')),
])),
);
}
}

View file

@ -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')),
]),
);
}
}

View file

@ -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);
}
}

View file

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

View file

@ -0,0 +1,33 @@
import 'dart:ui';
import 'package:friendica_archive_browser/src/friendica/models/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';
}
}

View file

@ -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,
)
],
),
),
],
))));
}
}

View file

@ -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);
}),
);
}
}

View file

@ -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 = '';
}

View file

@ -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(),
)
],
),
),
),
);
}
}

View file

@ -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";
}
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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,
);
}
}

View file

@ -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)],
),
],
],
),
]);
}
}

View file

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

View file

@ -0,0 +1,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,
);
}),
);
});
}
}

View file

@ -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,
),
),
)),
)),
);
}
}

View file

@ -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);
}
}

View file

@ -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']);
}
}

View file

@ -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'
];
}

View 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,
));
}

View file

@ -0,0 +1,6 @@
{
"appTitle": "Friendica Archive Browser",
"@appTitle": {
"description": "A browser of Friendica Archive Folders"
}
}

View file

@ -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,
};
}

View file

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

View file

@ -0,0 +1,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);
}

View file

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

View file

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

View file

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

View file

@ -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;
}
}
}
}

View file

@ -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');
}
}

View file

@ -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);
}
}

View 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;
}
}

View 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 ?? '';
});
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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);
}
}

View file

@ -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,
};
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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);
}

View 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',
};

View file

@ -0,0 +1 @@
flutter/ephemeral

View 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()

View 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}
)

View file

@ -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);
}

View file

@ -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_

View file

@ -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)

View 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);
}

View 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));
}

View 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_

View file

@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/

View file

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

View file

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

View file

@ -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"))
}

View file

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

View file

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

View file

@ -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 */;
}

View file

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

View file

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

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

View file

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

View file

@ -0,0 +1,9 @@
import Cocoa
import FlutterMacOS
@NSApplicationMain
class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}

View file

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

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

View file

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

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