diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 00000000..44248147 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,59 @@ +generated* +Generated* +pubspec.lock +*~ +ignore + +# Platform-specific build folders +/android/ +/ios/ +/linux/ +/macos/ +/windows/ +/web/ + +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 00000000..644060a6 --- /dev/null +++ b/example/.metadata @@ -0,0 +1,30 @@ +# 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: "3b62efc2a3da49882f43c372e0bc53daef7295a6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: web + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/Makefile b/example/Makefile new file mode 100644 index 00000000..61e82401 --- /dev/null +++ b/example/Makefile @@ -0,0 +1,83 @@ +######################################################################## +# +# Generic Makefile +# +# Time-stamp: +# +# Copyright (c) Graham.Williams@togaware.com +# +# License: Creative Commons Attribution-ShareAlike 4.0 International. +# +######################################################################## + +# App is often the current directory name. +# +# App version numbers +# Major release +# Minor update +# Trivial update or bug fix + +APP=$(shell pwd | xargs basename) +VER= +DATE=$(shell date +%Y-%m-%d) + +# Identify a destination used by install.mk + +DEST=/var/www/html/$(APP) + +######################################################################## +# Supported Makefile modules. + +# Often the support Makefiles will be in the local support folder, or +# else installed in the local user's shares. + +INC_BASE=../support + +# Specific Makefiles will be loaded if they are found in +# INC_BASE. Sometimes the INC_BASE is shared by multiple local +# Makefiles and we want to skip specific makes. Simply define the +# appropriate INC to a non-existant location and it will be skipped. + +INC_DOCKER=skip +INC_MLHUB=skip +INC_WEBCAM=skip + +# Load any modules available. + +INC_MODULE=$(INC_BASE)/modules.mk + +ifneq ("$(wildcard $(INC_MODULE))","") + include $(INC_MODULE) +endif + +######################################################################## +# HELP +# +# Help for targets defined in this Makefile. + +define HELP +$(APP): + + locals No local targets defined yet. + +endef +export HELP + +help:: + @echo "$$HELP" + +######################################################################## +# LOCAL TARGETS + +locals: + @echo "This might be the instructions to install $(APP)" + +apk:: + rsync -avzh installers/$(APP)* solidcommunity.au:/var/www/html/installers/ + ssh solidcommunity.au chmod -R go+rX /var/www/html/installers/ + ssh solidcommunity.au chmod go=x /var/www/html/installers/ + +tgz:: + rsync -avzh installers/$(APP)* solidcommunity.au:/var/www/html/installers/ + ssh solidcommunity.au chmod -R go+rX /var/www/html/installers/ + ssh solidcommunity.au chmod go=x /var/www/html/installers/ diff --git a/example/README.md b/example/README.md new file mode 100644 index 00000000..e829587a --- /dev/null +++ b/example/README.md @@ -0,0 +1,7 @@ +# A Demonstrator of SolidPod Functionality + +Through this app we demonstrate the suite of functionality provided by +the solidpod package. + +Visit the [AU Solid Community](https://solidcommunity.au) for a suite +of solidpod apps developed using the solidpod package. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/example/assets/images/demopod_image.png b/example/assets/images/demopod_image.png new file mode 100644 index 00000000..63a3f7ee Binary files /dev/null and b/example/assets/images/demopod_image.png differ diff --git a/example/assets/images/demopod_logo.png b/example/assets/images/demopod_logo.png new file mode 100644 index 00000000..1509e627 Binary files /dev/null and b/example/assets/images/demopod_logo.png differ diff --git a/example/lib/constants/app.dart b/example/lib/constants/app.dart new file mode 100644 index 00000000..4e293162 --- /dev/null +++ b/example/lib/constants/app.dart @@ -0,0 +1,53 @@ +/// Constants used throughout the app. +/// +// Time-stamp: +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Graham Williams + +library; + +import 'package:flutter/material.dart'; + +const titleBackgroundColor = Color(0xFFF0E4D7); + +// const dataFile = 'key-value.ttl'; +const dataFile = 'keyvalue/key-value.ttl'; + +//const dataFilePlain = 'key-value-plain.ttl'; +const dataFilePlain = dataFile; + +String createDemoTtlStr(String fileName) { + return '''@prefix demo: <#> . +@prefix rdfs: . +@prefix foaf: . + +demo:sampleData$fileName a demo:DemoResource ; + rdfs:label "Demo File $fileName" ; + demo:created "${DateTime.now().toIso8601String()}" ; + demo:description "This is a file containing some demo ttl content" ; + foaf:maker "Solid Demo" . + +demo:exampleData$fileName + demo:sampleProperty "Sample value" ; + demo:category "demo-data". +'''; +} diff --git a/example/lib/dialogs/about.dart b/example/lib/dialogs/about.dart new file mode 100644 index 00000000..dd383f03 --- /dev/null +++ b/example/lib/dialogs/about.dart @@ -0,0 +1,61 @@ +/// about dialog for the app +/// +// Time-stamp: +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Kevin Wang + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart'; + +Future aboutDialog(BuildContext context) async { + final appInfo = await getAppNameVersion(); + + // Fix the use_build_context_synchronously lint error. + + if (context.mounted) { + showAboutDialog( + context: context, + applicationName: + '${appInfo.name[0].toUpperCase()}${appInfo.name.substring(1)}', + applicationVersion: appInfo.version, + applicationLegalese: '© 2024 Software Innovation Institute ANU', + applicationIcon: Image.asset( + 'assets/images/demopod_logo.png', + width: 100, + height: 100, + ), + children: [ + const SizedBox( + width: 300, // Limit the width. + child: SelectableText('\nA demostrator of SolidPod functionality.\n\n' + 'Demopod is a demonstrator app for the solidpod package.' + ' It provides a collection of buttons to exhibit the different' + ' calabilities provided by solidpod.\n\n' + 'Authors: Anuska Vidanage, Graham Williams, Dawei Chen.'), + ), + ], + ); + } +} diff --git a/example/lib/dialogs/alert.dart b/example/lib/dialogs/alert.dart new file mode 100644 index 00000000..5d80c059 --- /dev/null +++ b/example/lib/dialogs/alert.dart @@ -0,0 +1,44 @@ +/// Show an Alert dialog +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Dawei Chen + +library; + +import 'package:flutter/material.dart'; + +// Show an alert dialog +Future alert(BuildContext context, String msg, + [String title = 'Notice']) async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(msg), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('OK')) + ], + )); +} diff --git a/example/lib/features/create_acl_inherited_file.dart b/example/lib/features/create_acl_inherited_file.dart new file mode 100644 index 00000000..61c996ac --- /dev/null +++ b/example/lib/features/create_acl_inherited_file.dart @@ -0,0 +1,203 @@ +/// A page to create resources with ACL inheritance +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://www.gnu.org/licenses/gpl-3.0.en.html. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Anushka Vidanage + +library; + +import 'package:flutter/material.dart'; + +import 'package:demopod/constants/app.dart'; + +import 'package:solidpod/solidpod.dart' show writePod, setInheritKeyDir; + +// A widget to create a resource with inherited ACL. +// +// The resource will be created inside a parent directory and the ACL of that +// directory will be inherited for that resource. +// +// If resource need to be encrypted, a single encryption key assigned to the +// parent directory will be used for the encryption. +class CreateAclInheritedFile extends StatefulWidget { + const CreateAclInheritedFile({super.key}); + + @override + CreateAclInheritedFileState createState() => CreateAclInheritedFileState(); +} + +class CreateAclInheritedFileState extends State { + final _formKey = GlobalKey(); + + // Controllers for the text fields + final TextEditingController _resourcePathController = TextEditingController(); + final TextEditingController _parentDirectoryController = + TextEditingController(); + + // Toggle switch value + bool _isEncrypted = true; + + @override + void dispose() { + // Dispose controllers when widget is removed + _resourcePathController.dispose(); + _parentDirectoryController.dispose(); + super.dispose(); + } + + void _submitForm() async { + if (_formKey.currentState!.validate()) { + // Retrieve entered values + String resourcePath = _resourcePathController.text.trim(); + String parentDirectory = _parentDirectoryController.text.trim(); + + final demoTtlContent = createDemoTtlStr(resourcePath); + final messenger = ScaffoldMessenger.of(context); + + try { + if (_isEncrypted) { + if (!context.mounted) return; + await writePod(resourcePath, demoTtlContent, + encrypted: _isEncrypted, + createAcl: false, + overwrite: true, + inheritKeyFrom: parentDirectory); + } else { + // First check and create the corresponding directory + await setInheritKeyDir(parentDirectory); + if (!context.mounted) return; + // ignore: use_build_context_synchronously + await writePod(resourcePath, demoTtlContent, + encrypted: _isEncrypted, createAcl: false); + } + + messenger.showSnackBar( + const SnackBar(content: Text('Resource created successfully!')), + ); + } on Object catch (e, trace) { + debugPrint(e.toString()); + debugPrint(trace.toString()); + + messenger.showSnackBar( + const SnackBar( + content: Text('There was a problem creating resource! ' + 'Please try again later.')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Create a resource with ACL inheritance'), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + children: [ + // Instruction paragraph + const Text( + 'Fill out the following two text fields according to the below ' + 'instructions. The "Resource Path" field should contain the path ' + 'to the resource itself including the actual resource name. An ' + 'example would be "parentDir/sampleRes.ttl". The "Parent Dir"' + 'should contain the path to the actual parent directory where the ' + 'resource will inherit the ACL file from. An example would be ' + '"parentDir".', + style: TextStyle(fontSize: 16.0, height: 1.5), + ), + const SizedBox(height: 24), + + // Resource path field + TextFormField( + controller: _resourcePathController, + decoration: const InputDecoration( + labelText: 'Resource Path', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a resource path'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Parent directory field + TextFormField( + controller: _parentDirectoryController, + decoration: const InputDecoration( + labelText: 'Parent Directory', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a parent directory'; + } + return null; + }, + ), + const SizedBox(height: 10), + + // Encrypted Toggle Switch + SwitchListTile( + title: const Text( + 'Encrypted', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + subtitle: Text( + _isEncrypted + ? 'This resource content will be stored in encrypted form.' + : 'This resource content will not be encrypted.', + ), + value: _isEncrypted, + onChanged: (bool value) { + setState(() { + _isEncrypted = value; + }); + }, + thumbColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.selected)) { + return Colors.green; + } + return null; + }, + ), + ), + const SizedBox(height: 24), + + // Submit Button + ElevatedButton( + onPressed: _submitForm, + child: const Text('Create resource'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/features/edit_keyvalue.dart b/example/lib/features/edit_keyvalue.dart new file mode 100644 index 00000000..ba406fa0 --- /dev/null +++ b/example/lib/features/edit_keyvalue.dart @@ -0,0 +1,280 @@ +/// A widget to edit key/value pairs and save them in a POD. +/// +// Time-stamp: +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Dawei Chen + +library; + +import 'package:flutter/material.dart'; + +import 'package:demopod/constants/app.dart'; +import 'package:demopod/dialogs/alert.dart'; +import 'package:demopod/utils/rdf.dart'; +import 'package:editable/editable.dart'; +import 'package:solidui/solidui.dart' show getKeyFromUserIfRequired; + +import 'package:solidpod/solidpod.dart' show isUserLoggedIn, writePod; + +class KeyValueEdit extends StatefulWidget { + /// Constructor + const KeyValueEdit( + {required this.title, + required this.fileName, + required this.child, + this.encrypted = true, + this.keyValuePairs, + super.key}); + + final String title; + final String fileName; // file to be saved in PODs + final Widget child; + final bool encrypted; + final List<({String key, dynamic value})>? + keyValuePairs; // initial key value pairs + + @override + State createState() => _KeyValueEditState(); +} + +class _KeyValueEditState extends State { + /// Create a Key for EditableState + final _editableKey = GlobalKey(); + final regExp = RegExp(r'\s+'); + static const rowKey = 'row'; // key of row index in editedRows + static const keyStr = 'key'; + static const valStr = 'value'; + final List rows = []; + final List cols = [ + {'title': 'Key', 'key': keyStr}, + {'title': 'Value', 'key': valStr}, + ]; + final dataMap = {}; + bool _isLoading = false; // Loading indicator for data submission + + @override + void initState() { + super.initState(); + + // A column is a {'title': TITLE, 'key': KEY} + // A row is a {KEY: VALUE} + + // Initialise the rows + if (widget.keyValuePairs != null) { + for (final (:key, :value) in widget.keyValuePairs!) { + rows.add({keyStr: key, valStr: value}); + } + } + + // Save initial data + for (var i = 0; i < rows.length; i++) { + dataMap[i] = (key: rows[i][keyStr], value: rows[i][valStr]); + } + } + + // Add a new row using the global key assigined to the Editable widget + // to access its current state + void _addNewRow() { + setState(() { + _editableKey.currentState?.createRow(); + }); + } + + void _saveEditedRows() { + final editedRows = _editableKey.currentState?.editedRows as List; + // print('edited_rows: ${editedRows}'); + // print('#rows: ${_editableKey.currentState?.rowCount}'); + // print('#cols: ${_editableKey.currentState?.columnCount}'); + // print('rows:'); + // print(rows); // edits are not saved in `rows' + if (editedRows.isEmpty) { + return; + } + for (final r in editedRows) { + final rowInd = r[rowKey] as int; + dataMap[rowInd] = (key: r[keyStr] as String, value: r[valStr]); + rows[rowInd] = {keyStr: r[keyStr], valStr: r[valStr]}; + } + } + + Future _alert(String msg) async => alert(context, msg); + + // Get key value pairs + Future?> _getKeyValuePairs() async { + final rowInd = dataMap.keys.toList()..sort(); + final keys = {}; + final pairs = <({String key, dynamic value})>[]; + for (final i in rowInd) { + final k = dataMap[i]!.key.trim(); + if (k.isEmpty) { + await _alert('Invalide key: "$k"'); + return null; + } + if (keys.contains(k)) { + await _alert('Invalide key: Duplicate key "$k"'); + return null; + } + if (regExp.hasMatch(k)) { + await _alert('Invalided key: Whitespace found in key "$k"'); + return null; + } + keys.add(k); + final v = dataMap[i]!.value; + pairs.add((key: k, value: v)); + } + return pairs; + } + + // Save data to PODs + Future _saveToPod(BuildContext context) async { + _saveEditedRows(); + + final pairs = await _getKeyValuePairs(); + if (dataMap.isEmpty) { + await _alert('No data to submit'); + return false; + } + + setState(() { + // Begin loading. + + _isLoading = true; + }); + + try { + // Write to POD + if (context.mounted) { + if (!await isUserLoggedIn()) { + await _alert('Please login to write data to your POD'); + return false; + } + + if (widget.encrypted) { + if (!context.mounted) return false; + await getKeyFromUserIfRequired(context, widget); + } + + // Generate TTL str with dataMap + final ttlStr = await genTTLStr(pairs!); + + try { + await writePod( + widget.fileName, + ttlStr, + encrypted: widget.encrypted, + overwrite: true, + ); + + await _alert('Successfully saved ${dataMap.length} key-value pairs' + ' to "${widget.fileName}" in PODs'); + return true; + } on Object catch (e, trace) { + debugPrint(e.toString()); + debugPrint(trace.toString()); + + await _alert('Something went wrong. Please try again!'); + return false; + } + } + } on Exception catch (e) { + debugPrint('Exception: $e'); + } finally { + if (mounted) { + setState(() { + // End loading. + + _isLoading = false; + }); + } + } + return false; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + backgroundColor: titleBackgroundColor, + leadingWidth: 100, + actions: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + TextButton.icon( + onPressed: _addNewRow, + icon: const Icon(Icons.add), + label: const Text('Add', + style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: () async { + final saved = await _saveToPod(context); + if (saved) { + if (!context.mounted) return; + await Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => widget.child)); + } + }, + child: const Text('Submit', + style: TextStyle(fontWeight: FontWeight.bold))), + ])), + ], + ), + body: Center( + child: _isLoading + ? const CircularProgressIndicator() // Show loading indicator + : Editable( + key: _editableKey, + columns: cols, + rows: rows, + // zebraStripe: false, + // stripeColor1: Colors.blue[50]!, + // stripeColor2: Colors.grey[200]!, + onRowSaved: print, + onSubmitted: print, + borderColor: Colors.blueGrey, + tdStyle: const TextStyle(fontWeight: FontWeight.bold), + trHeight: 20, + thStyle: const TextStyle( + fontSize: 15, fontWeight: FontWeight.bold), + thAlignment: TextAlign.center, + thVertAlignment: CrossAxisAlignment.end, + thPaddingBottom: 3, + // showSaveIcon: + // false, // do not show the save icon at the right of a row + // saveIconColor: Colors.black, + // showCreateButton: false, // do not show the + button at top-left + tdAlignment: TextAlign.left, + tdEditableMaxLines: 100, // don't limit and allow data to wrap + tdPaddingTop: 5, + tdPaddingBottom: 5, + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.zero), + ), + )); + } +} diff --git a/example/lib/features/file_service.dart b/example/lib/features/file_service.dart new file mode 100644 index 00000000..8147eb27 --- /dev/null +++ b/example/lib/features/file_service.dart @@ -0,0 +1,670 @@ +/// A widget to demonstrate the upload, download, and delete large files. +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Dawei Chen + +library; + +import 'package:flutter/material.dart'; + +import 'package:demopod/dialogs/alert.dart'; +import 'package:file_picker/file_picker.dart'; + +import 'package:solidpod/solidpod.dart'; + +class FileService extends StatefulWidget { + const FileService({required this.child, required this.webId, super.key}); + final String webId; + final Widget child; + + @override + State createState() => _FileServiceState(); +} + +class _FileServiceState extends State { + String defaultRemoteFileName = 'large_file.bin'; + String? uploadFile; + String? downloadFile; + String? downloadSharedFile; + + double uploadPercent = 0.0; + double downloadPercent = 0.0; + double downloadSharedPercent = 0.0; + double deletePercent = 0.0; + + bool uploadDone = false; + bool downloadDone = false; + bool downloadSharedDone = false; + bool deleteDone = false; + + bool uploadInProgress = false; + bool downloadInProgress = false; + bool downloadSharedInProgress = false; + bool deleteInProgress = false; + + final remoteFolderController = TextEditingController(); + final keyRefFolderController = TextEditingController(); + final sharedUrlController = TextEditingController(); + + final smallGapH = const SizedBox(width: 10); + final smallGapV = const SizedBox(height: 10); + final largeGapV = const SizedBox(height: 30); + + String getRemoteFileName() => + '${remoteFolderController.text.trim()}$defaultRemoteFileName'; + + String? getKeyRefPath() { + final folder = keyRefFolderController.text.trim(); + return folder.isNotEmpty ? folder : null; + } + + Widget getProgressBar(String message, bool isDone, double percent) { + const textStyle = TextStyle( + color: Colors.green, + fontWeight: FontWeight.bold, + ); + + final prefix = Text(message, style: textStyle); + final suffix = Text('${(percent * 100).toInt()}%', style: textStyle); + final progress = SizedBox( + width: 300, + height: 10, + child: LinearProgressIndicator( + value: percent, + minHeight: 2, + backgroundColor: Colors.black12, + color: Colors.greenAccent, + ), + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + prefix, + smallGapH, + progress, + smallGapH, + suffix, + ], + ); + } + + @override + void initState() { + super.initState(); + remoteFolderController.addListener(() => remoteFolderController.value = + remoteFolderController.value.copyWith( + text: _sanitiseFolderPath(remoteFolderController.text.trim()))); + keyRefFolderController.addListener(() => keyRefFolderController.value = + keyRefFolderController.value.copyWith( + text: _sanitiseFolderPath(keyRefFolderController.text.trim()))); + } + + @override + void dispose() { + remoteFolderController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final browseButton = ElevatedButton( + onPressed: () async { + final result = await FilePicker.platform.pickFiles(); + if (result != null) { + setState(() { + uploadFile = result.files.single.path!; + uploadDone = false; + uploadPercent = 0.0; + }); + } + }, + child: const Text('Browse'), + ); + + final uploadButton = ElevatedButton( + onPressed: (uploadFile == null || + uploadInProgress || + downloadInProgress || + downloadSharedInProgress || + deleteInProgress) + ? null + : () async { + try { + setState(() { + uploadInProgress = true; + }); + + final keyPath = getKeyRefPath(); + + if (keyPath != null) { + await setInheritKeyDir(keyPath, createAcl: true); + } + + if (!context.mounted) return; + + await writeLargeFile( + localFilePath: uploadFile!, + remoteFilePath: getRemoteFileName(), + inheritKeyFrom: keyPath, + createAcl: false, + onProgress: (sent, total) { + setState(() { + uploadDone = sent == total; + uploadPercent = sent / total; + }); + }); + if (uploadDone) { + setState(() { + uploadInProgress = false; + }); + } + } on Object catch (e) { + setState(() { + uploadFile = null; + uploadInProgress = false; + }); + if (context.mounted) { + await alert(context, 'Failed to send file. $e', 'Error'); + } + debugPrint('$e'); + } + }, + child: const Text('Upload'), + ); + + final downloadButton = ElevatedButton( + onPressed: (uploadInProgress || + downloadInProgress || + downloadSharedInProgress || + deleteInProgress) + ? null + : () async { + String? outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Please set the output file:', + // fileName: 'download.bin', + ); + + if (outputFile == null) { + // User canceled the picker + debugPrint('Download is cancelled'); + } else { + setState(() { + downloadFile = outputFile; + }); + try { + // remoteFileUrl ??= await getRemoteFileUrl(); + setState(() { + downloadInProgress = true; + }); + + await readLargeFile( + remoteFilePath: getRemoteFileName(), + localFilePath: outputFile, + onProgress: (received, total) { + setState(() { + downloadDone = received == total; + downloadPercent = received / total; + }); + }); + + if (downloadDone) { + setState(() { + downloadInProgress = false; + }); + } + } on Object catch (e) { + setState(() { + downloadFile = null; + downloadInProgress = false; + }); + if (context.mounted) { + await alert( + context, 'Failed to download file. $e', 'Error'); + } + debugPrint('$e'); + } + } + }, + child: const Text('Download'), + ); + + final downloadSharedButton = ElevatedButton( + onPressed: (uploadInProgress || + downloadInProgress || + downloadSharedInProgress || + deleteInProgress) + ? null + : () async { + String? outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Please set the output file:', + ); + if (outputFile == null) { + // User canceled the picker + debugPrint('Download is cancelled'); + } else { + setState(() { + downloadSharedFile = outputFile; + }); + try { + setState(() { + downloadSharedInProgress = true; + }); + + final sharedFileUrl = sharedUrlController.text.trim(); + if (sharedFileUrl.isEmpty) { + final msg = 'Shared file URL is empty'; + if (context.mounted) await alert(context, msg); + throw Exception(msg); + } + + // URL format: https://SERVER_URL/POD_NAME/APP_NAME/data/FILE_PATH + final uri = Uri.parse(sharedFileUrl); + + // [POD_NAME, APP_NAME, data, FILE_PATH] + assert(uri.pathSegments.length > 3); + + final podName = uri.pathSegments.first; + final ownerWebId = + [uri.origin, podName, 'profile/card#me'].join('/'); + + final fileName = uri.pathSegments + .getRange(3, uri.pathSegments.length) + .join('/'); + + if (context.mounted) { + await readLargeFile( + remoteFilePath: fileName, + localFilePath: outputFile, + ownerWebId: ownerWebId, + onProgress: (received, total) { + setState(() { + downloadSharedDone = received == total; + downloadSharedPercent = received / total; + }); + }); + if (downloadDone) { + setState(() { + downloadSharedInProgress = false; + }); + } + } + } on Object catch (e) { + setState(() { + downloadSharedFile = null; + downloadSharedInProgress = false; + }); + if (context.mounted) { + await alert( + context, 'Failed to download shared file. $e', 'Error'); + } + debugPrint('$e'); + } + } + }, + child: const Text('Download Shared Large File'), + ); + + final deleteButton = ElevatedButton( + onPressed: (uploadInProgress || + downloadInProgress || + downloadSharedInProgress || + deleteInProgress) + ? null + : () async { + try { + // remoteFileUrl ??= await getRemoteFileUrl(); + setState(() { + deleteInProgress = true; + }); + await deleteLargeFile( + remoteFilePath: getRemoteFileName(), + onProgress: (deleted, total) { + setState(() { + deleteDone = deleted == total; + deletePercent = deleted / total; + }); + }); + if (deleteDone) { + setState(() { + deleteInProgress = false; + }); + } + } on Object catch (e) { + setState(() { + deleteInProgress = false; + }); + if (context.mounted) { + await alert(context, 'Failed to delete file. $e', 'Error'); + } + debugPrint('$e'); + } + }, + child: const Text('Delete'), + ); + + // Widgets of the file upload section + + final uploadSection = [ + Text( + 'Upload a local large file and save it as "$defaultRemoteFileName" in POD', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + smallGapV, + Table( + columnWidths: const { + 0: FixedColumnWidth(450), + // 1: FixedColumnWidth(50), + // 1: FlexColumnWidth(), + }, + children: [ + TableRow( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + uploadFile ?? + 'Click the Browse button to choose a local file', + style: TextStyle( + color: uploadFile == null ? Colors.red : Colors.blue, + fontStyle: FontStyle.italic, + fontSize: 16, + ), + ), + smallGapH, + if (uploadDone) const Icon(Icons.done, color: Colors.green), + ], + ), + ], + ), + TableRow( + children: [ + TextFormField( + controller: remoteFolderController, + enabled: !(uploadInProgress || uploadDone), + decoration: const InputDecoration( + // labelText: 'Remote Folder', + // border: OutlineInputBorder(), + hintText: '(Optional) save to folder in POD, e.g. dir1/dir2/', + hintStyle: TextStyle( + color: Colors.brown, + fontStyle: FontStyle.italic, + fontSize: 15, + ), + ), + // validator: (value) { + // if (value != null || value!.trim().isNotEmpty) { + // if (!value.endsWith('/')) { + // return 'Folder path must ends with /'; + // } + // } + // return null; + // }, + ), + ], + ), + TableRow(children: [ + TextFormField( + controller: keyRefFolderController, + enabled: !(uploadInProgress || uploadDone), + decoration: const InputDecoration( + hintText: + '(Optional) Inherit encryption key of folder in POD, e.g. dir1/', + hintStyle: TextStyle( + color: Colors.brown, + fontStyle: FontStyle.italic, + fontSize: 15, + ), + ), + ), + ]), + ], + ), + smallGapV, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + browseButton, + smallGapH, + uploadButton, + ], + ), + ]; + + // Widgets of the file download section + + final downloadSection = [ + Text( + 'Download the "$defaultRemoteFileName" from POD', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + smallGapV, + if (downloadFile != null) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Save file'), + smallGapH, + Text( + downloadFile!, + style: const TextStyle(color: Colors.blue), + ), + smallGapH, + if (downloadDone) const Icon(Icons.done, color: Colors.green), + ], + ), + smallGapV, + downloadButton, + ]; + + // Widgets of the shared file download section + // Widgets of the file download section + + final downloadSharedSection = [ + Text( + 'Download a shared large file from an external POD', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + smallGapV, + SizedBox( + width: 550, + child: TextFormField( + controller: sharedUrlController, + enabled: !(downloadSharedInProgress || downloadSharedDone), + decoration: const InputDecoration( + hintText: 'URL of shared large file in external POD', + hintStyle: TextStyle( + color: Colors.brown, + fontStyle: FontStyle.italic, + fontSize: 15, + ), + ), + ), + ), + smallGapV, + if (downloadSharedFile != null) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Save file'), + smallGapH, + Text( + downloadSharedFile!, + style: const TextStyle(color: Colors.blue), + ), + smallGapH, + if (downloadSharedDone) const Icon(Icons.done, color: Colors.green), + ], + ), + smallGapV, + downloadSharedButton, + ]; + + // Widgets of the file delete section + + final deleteSection = [ + Text( + 'Delete the "$defaultRemoteFileName" from POD', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + smallGapV, + if (deleteInProgress || deleteDone) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Delete remote file'), + smallGapH, + Text( + defaultRemoteFileName, + style: const TextStyle(color: Colors.blue), + ), + smallGapH, + if (deleteDone) const Icon(Icons.done, color: Colors.green), + ], + ), + smallGapV, + deleteButton, + ]; + + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(10), + child: Stack( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + largeGapV, + largeGapV, + + // Upload + + ...uploadSection, + + largeGapV, + + // Download + + ...downloadSection, + + largeGapV, + + // Delete + + ...deleteSection, + + largeGapV, + + // Download shared file + + ...downloadSharedSection, + ], + ), + + // Uploading progress bar + + if (uploadInProgress) + Positioned( + top: 20, + left: 0, + right: 0, + child: getProgressBar('Uploading:', uploadDone, uploadPercent), + ), + + // Downloading progress bar + + if (downloadInProgress) + Positioned( + top: 20, + left: 0, + right: 0, + child: getProgressBar( + 'Downloading:', downloadDone, downloadPercent), + ), + + // Downloading shared file progress bar + + if (downloadSharedInProgress) + Positioned( + top: 20, + left: 0, + right: 0, + child: getProgressBar( + 'Downloading:', downloadSharedDone, downloadSharedPercent), + ), + + // Deleting progress bar + + if (deleteInProgress) + Positioned( + top: 20, + left: 0, + right: 0, + child: getProgressBar('Deleting:', deleteDone, deletePercent), + ), + + // Navigate back to demo page + Positioned( + top: 10, + left: 10, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Back to Demo'), + ), + ), + + // Widget to show Web ID + Positioned( + bottom: 10, + right: 10, + child: Text( + 'WEB ID - ${widget.webId}', + style: const TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, + ), + ), + ) + ], + ), + ), + ); + } +} + +String _sanitiseFolderPath(String folderPath) { + final folder = folderPath.endsWith('/') ? folderPath : '$folderPath/'; + + return folder.startsWith('/') ? folder.substring(1) : folder; +} diff --git a/example/lib/features/permission_callback_demo.dart b/example/lib/features/permission_callback_demo.dart new file mode 100644 index 00000000..5c48d4e1 --- /dev/null +++ b/example/lib/features/permission_callback_demo.dart @@ -0,0 +1,627 @@ +/// A demonstration widget showcasing the onPermissionGranted callback functionality. +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Ashley Tang + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart'; +import 'package:solidui/solidui.dart' show GrantPermissionUi; + +/// A widget demonstrating the onPermissionGranted callback functionality. + +class PermissionCallbackDemo extends StatefulWidget { + const PermissionCallbackDemo({required this.child, super.key}); + + final Widget child; + + @override + State createState() => _PermissionCallbackDemoState(); +} + +class _PermissionCallbackDemoState extends State { + // Status tracking variables. + + bool _workflowCompleted = false; + int _currentStep = 1; + String _statusMessage = 'Ready to start permission workflow'; + + // Sample files to demonstrate batch permission granting. + // All files will be auto-created for the demo. + + final List _sampleFiles = [ + 'callback-demo/sample-1.ttl', + 'callback-demo/sample-2.ttl', + 'callback-demo/sample-3.ttl', + ]; + + int _currentFileIndex = 0; + + // Reset the demo state. + + void _resetDemo() { + setState(() { + _workflowCompleted = false; + _currentStep = 1; + _currentFileIndex = 0; + _statusMessage = 'Ready to start permission workflow'; + }); + } + + // Create all demo files automatically. + + Future _ensureDemoFilesExist() async { + try { + for (int i = 0; i < _sampleFiles.length; i++) { + final fileName = _sampleFiles[i]; + final filePath = [await getDataDirPath(), fileName].join('/'); + + // Create rich demo content with different data for each file + final fileNumber = i + 1; + final demoContent = '''@prefix demo: <#> . +@prefix rdfs: . +@prefix foaf: . + +demo:sampleData$fileNumber a demo:DemoResource ; + rdfs:label "Permission Callback Demo File $fileNumber" ; + demo:created "${DateTime.now().toIso8601String()}" ; + demo:purpose "Demonstrating onPermissionGranted callback functionality" ; + demo:fileNumber "$fileNumber" ; + demo:description "This file demonstrates how callbacks enable automated workflows when sharing multiple files sequentially." ; + foaf:maker "SolidPod Permission Callback Demo" . + +demo:exampleData$fileNumber + demo:sampleProperty "Sample value $fileNumber" ; + demo:category "demo-data" ; + demo:testValue ${fileNumber * 100} . +'''; + + // Always create/overwrite the demo file for consistency. + + if (!mounted) return; + + await writePod(filePath, demoContent); + } + } catch (e) { + debugPrint('❌ [CallbackDemo] Error creating demo files: $e'); + rethrow; // Re-throw to show user the error + } + } + + // Navigate to Grant Permission UI with callback. + + Future _startPermissionWorkflow() async { + // Always ensure demo files exist on first run. + + if (_currentFileIndex == 0) { + setState(() { + _statusMessage = 'Setting up demo files for fresh POD...'; + }); + + try { + await _ensureDemoFilesExist(); + setState(() { + _statusMessage = 'Demo files created! Starting workflow...'; + }); + // Small delay to show success message. + + await Future.delayed(const Duration(milliseconds: 500)); + } catch (e) { + setState(() { + _statusMessage = 'Failed to create demo files: $e'; + }); + } + } + + setState(() { + _currentStep = 2; + _statusMessage = + 'Navigate to permission screen for file ${_currentFileIndex + 1} of ${_sampleFiles.length}'; + }); + + if (!mounted) return; + + await Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: true, + builder: (navContext) => Theme( + data: Theme.of(context), + child: Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + appBar: AppBar( + title: Text( + 'Share "${_sampleFiles[_currentFileIndex]}" (${_currentFileIndex + 1}/${_sampleFiles.length})', + ), + backgroundColor: Theme.of(context).appBarTheme.backgroundColor, + foregroundColor: Theme.of(context).appBarTheme.foregroundColor, + ), + body: GrantPermissionUi( + resourceName: _sampleFiles[_currentFileIndex], + title: 'Demo: Grant Permission with Callback', + accessModeList: const ['read'], // Simplified for demo. + recipientTypeList: const ['indi'], // Individual permissions only. + showAppBar: false, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + child: widget.child, + + // 🎯 Key callback being demonstrated. + + onPermissionGranted: () async { + // This callback is triggered when permissions are successfully granted. + + if (mounted) { + // Update our state to reflect success. + + setState(() { + _currentStep = 3; + _statusMessage = + 'Permission granted successfully! Processing next file...'; + }); + + // Navigate back from the permission screen. + + Navigator.of(navContext).pop(true); + + // Continue with the next file in our workflow. + + await _continueWorkflow(); + } + }, + + // Handle user cancellation/navigation back. + + onNavigateBack: () { + if (mounted) { + setState(() { + _statusMessage = 'Permission granting cancelled by user'; + }); + Navigator.of(navContext).pop(false); + } + }, + ), + ), + ), + ), + ); + } + + // Continue workflow after permission is granted. + + Future _continueWorkflow() async { + // Simulate some processing time. + + await Future.delayed(const Duration(milliseconds: 500)); + + if (!mounted) return; + + _currentFileIndex++; + + if (_currentFileIndex < _sampleFiles.length) { + // More files to process. + + setState(() { + _currentStep = 2; + _statusMessage = + 'Moving to next file: ${_sampleFiles[_currentFileIndex]}'; + }); + + // Automatically start next file (in real app, you might want user confirmation). + + await Future.delayed(const Duration(milliseconds: 800)); + if (mounted) { + await _startPermissionWorkflow(); + } + } else { + // All files processed. + + setState(() { + _workflowCompleted = true; + _currentStep = 4; + _statusMessage = 'All files shared successfully! Workflow completed.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey[50], + appBar: AppBar( + title: const Text('Permission Callback Demo'), + backgroundColor: Colors.blue[700], + foregroundColor: Colors.white, + elevation: 2, + actions: [ + IconButton( + icon: const Icon(Icons.help_outline), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Troubleshooting'), + content: const SingleChildScrollView( + child: Text( + 'This demo is fully self-contained!\n\n' + '• All demo files are created automatically\n' + '• No manual setup required\n' + '• Works on fresh PODs out of the box\n' + '• Just enter a valid WebID and click "Start Auto-Demo"\n' + '• Try sharing with yourself first for testing\n' + '• Check console (F12) for detailed logs if needed\n\n' + 'Common issues:\n' + '• Verify recipient WebID format ends with #me\n' + '• Ensure recipient has logged into their POD at least once\n' + '• Check browser console for detailed error logs', + style: TextStyle(fontSize: 14, height: 1.4), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + }, + tooltip: 'Troubleshooting Help', + ), + ], + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header section. + + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, + color: Colors.blue[700], size: 28), + const SizedBox(width: 12), + const Text( + 'Why Use onPermissionGranted Callback?', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'The onPermissionGranted callback allows your app to automatically continue workflows after users grant permissions. This demo creates sample files automatically and shows how to share multiple files sequentially without manual navigation.', + style: TextStyle(fontSize: 16, height: 1.4), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Current workflow status section. + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.1), + spreadRadius: 1, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Workflow Status', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + + // Progress indicator. + + Row( + children: [ + _buildStepIndicator(1, 'Start', _currentStep >= 1), + _buildConnector(_currentStep >= 2), + _buildStepIndicator(2, 'Grant', _currentStep >= 2), + _buildConnector(_currentStep >= 3), + _buildStepIndicator(3, 'Process', _currentStep >= 3), + _buildConnector(_currentStep >= 4), + _buildStepIndicator(4, 'Complete', _currentStep >= 4), + ], + ), + + const SizedBox(height: 16), + + // Status message. + + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _workflowCompleted + ? Colors.green[50] + : Colors.orange[50], + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: _workflowCompleted + ? Colors.green[200]! + : Colors.orange[200]!, + ), + ), + child: Row( + children: [ + Icon( + _workflowCompleted + ? Icons.check_circle + : Icons.info, + color: _workflowCompleted + ? Colors.green[700] + : Colors.orange[700], + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusMessage, + style: TextStyle( + color: _workflowCompleted + ? Colors.green[800] + : Colors.orange[800], + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Files to process section. + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.1), + spreadRadius: 1, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Files to Share', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Tooltip( + message: + 'Demo creates all files automatically - no setup required!', + child: Icon( + Icons.auto_awesome, + size: 16, + color: Colors.green[600], + ), + ), + ], + ), + const SizedBox(height: 12), + ...List.generate(_sampleFiles.length, (index) { + final isProcessed = index < _currentFileIndex; + final isCurrent = + index == _currentFileIndex && _currentStep >= 2; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isProcessed + ? Colors.green[50] + : isCurrent + ? Colors.blue[50] + : Colors.grey[50], + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isProcessed + ? Colors.green[200]! + : isCurrent + ? Colors.blue[200]! + : Colors.grey[200]!, + ), + ), + child: Row( + children: [ + Icon( + isProcessed + ? Icons.check_circle + : isCurrent + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isProcessed + ? Colors.green[600] + : isCurrent + ? Colors.blue[600] + : Colors.grey[400], + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _sampleFiles[index], + style: TextStyle( + fontWeight: FontWeight.w500, + color: isProcessed || isCurrent + ? Colors.black87 + : Colors.grey[600], + ), + ), + ), + if (isProcessed) + const Text( + 'Shared ✓', + style: TextStyle( + color: Colors.green, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + }), + ], + ), + ), + + const SizedBox(height: 24), + + // Action buttons section. + + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: (_currentStep == 1 || _workflowCompleted) + ? _startPermissionWorkflow + : null, + icon: const Icon(Icons.auto_awesome), + label: Text(_workflowCompleted + ? 'Run Demo Again' + : 'Start Auto-Demo'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[600], + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _resetDemo, + icon: const Icon(Icons.refresh), + label: const Text('Reset Demo'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + + // Extra padding at bottom to ensure scrolling works properly. + + const SizedBox(height: 40), + ], + ), + ), + ), + ); + } + + Widget _buildStepIndicator(int step, String label, bool isActive) { + return Column( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: isActive ? Colors.blue[600] : Colors.grey[300], + shape: BoxShape.circle, + ), + child: Center( + child: Text( + step.toString(), + style: TextStyle( + color: isActive ? Colors.white : Colors.grey[600], + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + color: isActive ? Colors.blue[600] : Colors.grey[600], + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ); + } + + Widget _buildConnector(bool isActive) { + return Container( + width: 24, + height: 2, + margin: const EdgeInsets.only(bottom: 20), + color: isActive ? Colors.blue[600] : Colors.grey[300], + ); + } +} diff --git a/example/lib/features/read_acl_inherited_file.dart b/example/lib/features/read_acl_inherited_file.dart new file mode 100644 index 00000000..1051822a --- /dev/null +++ b/example/lib/features/read_acl_inherited_file.dart @@ -0,0 +1,146 @@ +/// A page to read resources with ACL inheritance +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://www.gnu.org/licenses/gpl-3.0.en.html. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Anushka Vidanage + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart' show readPod; + +// A widget to create a resource with inherited ACL. +// +// The resource will be created inside a parent directory and the ACL of that +// directory will be inherited for that resource. +// +// If resource need to be encrypted, a single encryption key assigned to the +// parent directory will be used for the encryption. +class ReadAclInheritedFile extends StatefulWidget { + const ReadAclInheritedFile({super.key}); + + @override + ReadAclInheritedFileState createState() => ReadAclInheritedFileState(); +} + +class ReadAclInheritedFileState extends State { + final _formKey = GlobalKey(); + + // Controllers for the text fields + final TextEditingController _resourcePathController = TextEditingController(); + + // File content + String _fileContent = ''; + + @override + void dispose() { + // Dispose controllers when widget is removed + _resourcePathController.dispose(); + super.dispose(); + } + + void _submitForm() async { + if (_formKey.currentState!.validate()) { + // Retrieve entered values + String resourcePath = _resourcePathController.text.trim(); + + try { + String fileContent = await readPod(resourcePath); + + setState(() { + _fileContent = fileContent; + }); + } catch (e) { + setState(() { + _fileContent = 'Error reading file: $e'; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Read a resource with ACL inheritance'), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + children: [ + // Instruction paragraph + const Text( + 'The "Resource Path" field should contain the path ' + 'to the resource itself including the actual resource name ' + 'and extention. An example would be "parentDir/sampleRes.ttl".', + style: TextStyle(fontSize: 16.0, height: 1.5), + ), + const SizedBox(height: 24), + + // Resource path field + TextFormField( + controller: _resourcePathController, + decoration: const InputDecoration( + labelText: 'Resource Path', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a resource path'; + } + return null; + }, + ), + + const SizedBox(height: 24), + + // Submit Button + ElevatedButton( + onPressed: _submitForm, + child: const Text('read resource'), + ), + + const SizedBox(height: 10), + // Display file content if available + if (_fileContent.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 20), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _fileContent, + style: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/features/view_keys.dart b/example/lib/features/view_keys.dart new file mode 100644 index 00000000..bb0a88d8 --- /dev/null +++ b/example/lib/features/view_keys.dart @@ -0,0 +1,208 @@ +/// A widget to view private key data in a Solid Pod. +/// +// Time-stamp: +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Anushka Vidanage, Graham Williams + +library; + +import 'package:flutter/material.dart'; + +import 'package:demopod/constants/app.dart'; +import 'package:demopod/utils/rdf.dart' show getEncKeyContent; + +import 'package:solidpod/solidpod.dart' show KeyManager; + +/// A widget to show the user all the encryption keys stored in their Solid Pod. + +class ViewKeys extends StatefulWidget { + /// Constructor for the widget. + + const ViewKeys({ + required this.keyInfo, + required this.title, + super.key, + }); + + // Name of the app + // final String appName; + + /// Data of the key file + final String keyInfo; + + // Title of the page + final String title; + + @override + State createState() => _ViewKeysState(); +} + +class _ViewKeysState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + /// Decrypted data cache + Map? _decryptedData; + bool _isDecrypting = false; + + /// Decrypt encrypted key values using KeyManager. + /// + /// This function attempts to decrypt the 'prvKey' (private key) value + /// using KeyManager.getPrivateKey(). If decryption fails or + /// the master key is not available, the original encrypted values are returned. + Future> _decryptKeyValues( + Map encFileData) async { + final result = Map.from(encFileData); + + try { + // Get the decrypted private key from KeyManager + final decryptedPrvKey = await KeyManager.getPrivateKey(); + + // Update the result with decrypted value, show truncated for security + if (encFileData.containsKey('prvKey')) { + final displayValue = decryptedPrvKey.length > 80 + ? '${decryptedPrvKey.substring(0, 80)}...' + : decryptedPrvKey; + result['prvKey'] = [ + encFileData['prvKey'][0], + '✓ Decrypted: $displayValue', + ]; + } + } on Exception catch (e) { + debugPrint('ViewKeys: Failed to decrypt private key: $e'); + // Keep original encrypted values, add status note + result['_status'] = ['', '✗ Decryption unavailable']; + } + + return result; + } + + @override + void initState() { + super.initState(); + _loadDecryptedData(); + } + + Future _loadDecryptedData() async { + setState(() { + _isDecrypting = true; + }); + + try { + final encFileData = getEncKeyContent(widget.keyInfo); + final decrypted = await _decryptKeyValues(encFileData); + if (mounted) { + setState(() { + _decryptedData = decrypted; + _isDecrypting = false; + }); + } + } on Exception catch (e) { + debugPrint('ViewKeys: Error loading data: $e'); + if (mounted) { + setState(() { + _isDecrypting = false; + // Fallback to encrypted data on error + _decryptedData = getEncKeyContent(widget.keyInfo); + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + appBar: AppBar( + title: Text(widget.title), + backgroundColor: titleBackgroundColor, + ), + body: _buildBody()); + } + + Widget _buildBody() { + if (_isDecrypting) { + return const Center(child: CircularProgressIndicator()); + } + + if (_decryptedData == null) { + return const Center(child: Text('No data available')); + } + + return _loadedScreen(_decryptedData!); + } + + Widget _loadedScreen(Map data) { + final dataRows = data.entries.map((entry) { + return DataRow(cells: [ + DataCell(Text( + entry.key as String, + style: const TextStyle( + fontSize: 12, + ), + )), + DataCell(SizedBox( + width: 600, + child: Text( + entry.value[1] as String, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + ), + ))), + ]); + }).toList(); + + return Padding( + padding: const EdgeInsets.all(8.0), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DataTable( + columnSpacing: 30.0, + columns: const [ + DataColumn( + label: Text( + 'Parameter', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + DataColumn( + label: Text( + 'Value', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + rows: dataRows), + ], + ), + ), + ); + } +} diff --git a/example/lib/home.dart b/example/lib/home.dart new file mode 100644 index 00000000..aec5ed3f --- /dev/null +++ b/example/lib/home.dart @@ -0,0 +1,898 @@ +/// A screen to demonstrate various capabilities of solidlogin. +/// +// Time-stamp: +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// +/// Authors: Zheyuan Xu, Anushka Vidanage, Kevin Wang, Dawei Chen, Graham Williams + +// TODO 20240411 gjw EITHER REPAIR ALL CONTEXT ISSUES OR EXPLAIN WHY NOT? + +// ignore_for_file: use_build_context_synchronously + +library; + +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart'; +import 'package:solidui/solidui.dart' + show + GrantPermissionUi, + InitialSetupScreenBody, + SharedResourcesUi, + changeKeyPopup, + getKeyFromUserIfRequired, + largeGapV, + loginIfRequired, + logoutPopup, + smallGapV; + +import 'package:demopod/constants/app.dart'; +import 'package:demopod/dialogs/about.dart'; +import 'package:demopod/dialogs/alert.dart'; +import 'package:demopod/features/create_acl_inherited_file.dart'; +import 'package:demopod/features/edit_keyvalue.dart'; +import 'package:demopod/features/file_service.dart'; +import 'package:demopod/features/permission_callback_demo.dart'; +import 'package:demopod/features/read_acl_inherited_file.dart'; +import 'package:demopod/features/view_keys.dart'; +import 'package:demopod/main.dart'; +import 'package:demopod/utils/rdf.dart'; + +/// A widget for the demonstration screen of the application. + +class Home extends StatefulWidget { + /// Initialise widget variables. + + const Home({super.key}); + + @override + HomeState createState() => HomeState(); +} + +class HomeState extends State with SingleTickerProviderStateMixin { + String sampleText = ''; + // Step 1: Loading state variable. + + bool _isLoading = false; + + // Indicator for write encrypted/plaintext data + bool _writeEncrypted = true; + + // The current webID + String? _webId; + + @override + void initState() { + super.initState(); + } + + void _resetWebId() { + setState(() { + _webId = null; + }); + } + + Future _showPrivateData(String title) async { + setState(() { + // Begin loading. + + _isLoading = true; + }); + + try { + final fileContent = await readPod( + await getEncKeyPath(), + pathType: PathType.relativeToPod, + ); + + //await Navigator.pushReplacement( // this won't show the file content if POD initialisation has just been performed + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ViewKeys( + keyInfo: fileContent, + title: title, + ), + ), + ); + //} + } on Exception catch (e) { + debugPrint('Exception: $e'); + } finally { + if (mounted) { + setState(() { + // End loading. + + _isLoading = false; + }); + } + } + } + + Future _readWritePrivateData() async { + setState(() { + // Begin loading. + _isLoading = true; + }); + + // final appName = await getAppName(); + + // final fileName = 'test-101.ttl'; + // final fileContent = 'This is for testing writePod.'; + + final fileName = _writeEncrypted ? dataFile : dataFilePlain; + + // final dataDirPath = await getDataDirPath(); + // final filePath = [dataDirPath, fileName].join('/'); + + List<({String key, dynamic value})>? pairs; + + try { + final fileContent = await readPod(fileName); + + pairs = await parseTTLStr(fileContent); + } on Exception catch (e) { + debugPrint('Exception: $e'); + } + + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => KeyValueEdit( + title: 'Basic Key Value Editor', + fileName: fileName, + keyValuePairs: pairs, + encrypted: _writeEncrypted, + child: widget, + ))); + + setState(() { + _isLoading = false; + }); + } + + Future _readMetaData() async { + final fileName = _writeEncrypted ? dataFile : dataFilePlain; + + try { + final fileMetadata = await readResMetadata(fileName); + + final dateFormatter = DateFormat('EEE, dd MMM yyyy HH:mm:ss'); + + showFileMetadataDialog( + context: context, + fileName: fileName, + lastModified: dateFormatter.format(fileMetadata.lastModified), + contentLength: fileMetadata.contentLength.toString(), + contentType: fileMetadata.contentType, + allowdAccess: fileMetadata.wacAllow, + ); + } on Exception catch (e) { + debugPrint('Exception: $e'); + } + } + + // Helper method to demonstrate the security key prompt. + + Future _showSecurityKeyPrompt() async { + // First ensure we are logged in. + + final loggedIn = await loginIfRequired( + context, + ); + + if (loggedIn) { + // Forget the security key to ensure the prompt appears. + + await KeyManager.forgetSecurityKey(); + + // Inform user about what will happen next. + + await alert(context, + 'The security key has been forgotten locally. The next step will show the security key prompt which you would normally see when accessing secured data after logging in.'); + + // Directly show the security key prompt with WebID. + + try { + // This will trigger the security key prompt since we've forgotten the key. + + await getKeyFromUserIfRequired(context, widget); + + // Only show this if the user enters the correct key. + + await alert(context, + 'Your security key was entered correctly and has been saved for this session.'); + } catch (e) { + debugPrint('Error: $e'); + await alert(context, 'Error or cancelled: $e'); + } + } + } + + void showFileMetadataDialog({ + required BuildContext context, + required String fileName, + required String contentLength, + required String lastModified, + required String contentType, + required String allowdAccess, + }) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('File Information'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _infoRow('File name', fileName), + _infoRow('Last modified', lastModified), + _infoRow('Content length', contentLength), + _infoRow('Content type', contentType), + _infoRow('Allowed operations', allowdAccess), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + + Widget _infoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + Expanded(child: Text(value)), + ], + ), + ); + } + + Widget _build(BuildContext context, String title) { + // Build the widget. + + // Include a timestamp on the screen. + + final dateStr = DateFormat('HH:mm:ss dd MMMM yyyy').format(DateTime.now()); + + // A small horizontal spacing for the widget. + + const smallGapH = SizedBox(width: 10.0); + + // Some handy widgets that will be displyed. These are defined here to + // reduce the complexity of the code below. + + final about = IconButton( + icon: const Icon( + Icons.info, + color: Colors.purple, + ), + onPressed: () async { + await aboutDialog(context); + }, + tooltip: 'Popup a window about the app.', + ); + + final date = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Date: $dateStr', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + + final webid = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _webId == null ? 'WebID: Not Logged In' : 'WebID: $_webId', + style: TextStyle( + color: _webId == null ? Colors.red : Colors.green, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + + const welcomeHeading = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pod Data File', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + + final fileDemoButton = ElevatedButton( + onPressed: () async { + final loggedIn = await loginIfRequired( + context, + ); + if (loggedIn) { + final webId = await getWebId(); + setState(() { + _webId = webId; + }); + + await getKeyFromUserIfRequired(context, widget); + + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + FileService(webId: webId!, child: widget), + ), + ); + } + } + }, + child: const Text('Upload/Download Large File')); + + final inheritanceDemoButton = ElevatedButton( + onPressed: () async { + final loggedIn = await loginIfRequired( + context, + ); + if (loggedIn) { + final webId = await getWebId(); + setState(() { + _webId = webId; + }); + + await getKeyFromUserIfRequired(context, widget); + + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CreateAclInheritedFile())); + } + } + }, + child: const Text('Create Resource with ACL Inheritance')); + + final inheritanceReadButton = ElevatedButton( + onPressed: () async { + final loggedIn = await loginIfRequired( + context, + ); + if (loggedIn) { + final webId = await getWebId(); + setState(() { + _webId = webId; + }); + + await getKeyFromUserIfRequired(context, widget); + + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ReadAclInheritedFile())); + } + } + }, + child: const Text('Read Resource with ACL Inheritance')); + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + backgroundColor: titleBackgroundColor, + title: Text(title), + actions: [ + about, + ], + ), + body: _isLoading + // If loading show the loading indicator. + ? const Center(child: CircularProgressIndicator()) + // Otherwise we show the screen. + : SingleChildScrollView( + child: Column( + children: [ + smallGapV, + Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + date, + webid, + largeGapV, + welcomeHeading, + smallGapV, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + smallGapH, + const Text( + 'Encrypt Data?', + style: TextStyle(fontWeight: FontWeight.bold), + ), + smallGapH, + Switch( + value: _writeEncrypted, + onChanged: (val) { + setState(() { + _writeEncrypted = val; + }); + }, + ) + ]), + smallGapV, + + ElevatedButton( + child: const Text('Read/Write Pod Data File'), + onPressed: () async { + await loginIfRequired(context); + await _readWritePrivateData(); + }, + ), + smallGapV, + + ElevatedButton( + child: const Text('Read Metadata of Pod Data File'), + onPressed: () async { + await loginIfRequired(context); + await _readMetaData(); + }, + ), + smallGapV, + + // SolidPod API: deleteDataFile() + ElevatedButton( + onPressed: () async { + final loggedIn = await loginIfRequired( + context, + ); + if (loggedIn) { + deleteDataFileDialog(dataFile, context); + } + }, + child: const Text('Delete Pod Data File')), + smallGapV, + + fileDemoButton, + + largeGapV, + + const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ACL Inheritance', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + + inheritanceDemoButton, + + smallGapV, + + inheritanceReadButton, + + largeGapV, + + const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Local Security Key Management', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + smallGapV, + ElevatedButton( + child: const Text('Show Security Key (Encrypted)'), + onPressed: () async { + await _showPrivateData(title); + }, + ), + smallGapV, + ElevatedButton( + child: const Text( + 'Show Security Key Prompt (For Demonstration)'), + onPressed: () async { + // Use the dedicated helper method. + + await _showSecurityKeyPrompt(); + }, + ), + smallGapV, + ElevatedButton( + onPressed: () { + changeKeyPopup(context, widget); + }, + child: const Text('Change Security Key on Pod')), + smallGapV, + ElevatedButton( + child: const Text('Forget Security Key Locally'), + onPressed: () async { + late String msg; + try { + await KeyManager.forgetSecurityKey(); + msg = 'Successfully forgot local security key.'; + _resetWebId(); + } on Exception catch (e) { + msg = 'Failed to forget local security key: $e'; + } + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Notice'), + content: Text(msg), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('OK')) + ], + ), + ); + }, + ), + largeGapV, + const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Solid Server Login Management', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + MarkdownTooltip( + message: + 'This will remove from our local device\'s memory the ' + 'solid pod login information so that the next time you ' + 'start up the app you will need to login to your solid ' + 'server hosting your pod.', + child: ElevatedButton( + child: + const Text('Forget Remote Solid Server Login'), + onPressed: () async { + final deleteRes = await deleteLogIn(); + + var deleteMsg = ''; + + if (deleteRes) { + deleteMsg = + 'Successfully forgot remote solid server login info'; + } else { + deleteMsg = + 'Failed to forget login info. Try again in a while'; + } + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Notice'), + content: Text(deleteMsg), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('OK')) + ], + ), + ); + + _resetWebId(); + }, + ), + ), + smallGapV, + MarkdownTooltip( + message: + 'This will send a request through the browser to the ' + 'remote solid server to log you out of your Pod.', + child: ElevatedButton( + onPressed: () async { + await logoutPopup(context, const DemoPod()); + }, + child: + const Text('Logout From Remote Solid Server'), + ), + ), + largeGapV, + const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Resource Permission Management', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ElevatedButton( + child: const Text( + 'Add/Delete Permissions from a Specific Resource (key-value.ttl)'), + onPressed: () async { + final loggedIn = await loginIfRequired( + context, + ); + + if (loggedIn) { + await getKeyFromUserIfRequired(context, widget); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const GrantPermissionUi( + backgroundColor: titleBackgroundColor, + resourceName: 'keyvalue/key-value.ttl', + // accessModeList: ['read', 'write'], + // recipientTypeList: ['indi', 'group'], + // isFile: false, + child: Home(), + ), + ), + ); + } + }, + ), + smallGapV, + ElevatedButton( + child: const Text('Permission Callback Demo'), + onPressed: () async { + final loggedIn = await loginIfRequired( + context, + ); + + if (loggedIn) { + await getKeyFromUserIfRequired(context, widget); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const PermissionCallbackDemo( + child: Home(), + ), + ), + ); + } + }, + ), + smallGapV, + ElevatedButton( + child: const Text( + 'Add/Delete Permissions from any Resource'), + onPressed: () async { + final loggedIn = await loginIfRequired( + context, + ); + + if (loggedIn) { + await getKeyFromUserIfRequired(context, widget); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const GrantPermissionUi( + backgroundColor: titleBackgroundColor, + child: Home(), + ), + ), + ); + } + }, + ), + largeGapV, + const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Manage External Resources with Access', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ElevatedButton( + child: const Text( + 'View specific resource (key-value.ttl) your WebID has access to'), + onPressed: () async { + final loggedIn = await loginIfRequired( + context, + ); + + if (loggedIn) { + await getKeyFromUserIfRequired(context, widget); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SharedResourcesUi( + backgroundColor: titleBackgroundColor, + fileName: 'key-value.ttl', + child: Home(), + ), + ), + ); + } + }, + ), + smallGapV, + ElevatedButton( + child: const Text( + 'View ALL Resources your WebID has access to'), + onPressed: () async { + final loggedIn = await loginIfRequired( + context, + ); + + if (loggedIn) { + await getKeyFromUserIfRequired(context, widget); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SharedResourcesUi( + backgroundColor: titleBackgroundColor, + child: Home(), + ), + ), + ); + } + }, + ), + smallGapV, + largeGapV, + const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Setup Wizard Demo', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + smallGapV, + ElevatedButton( + onPressed: () async { + // Now that the back button issue is fixed in InitialSetupScreenBody, + // we can use it directly without any custom wrapper. + + final loggedIn = await loginIfRequired(context); + + if (!loggedIn) { + debugPrint('Please login to run the demo'); + return; + } + + final webId = await getWebId(); + if (webId == null) { + debugPrint('web ID is not available'); + return; + } + + final sampleDirUrl = await getDirUrl([ + await getDataDirPath(), + 'setup_wizard_demo', + ].join('/')); + final sampleFileName = 'setup_wizard_demo.ttl'; + final sampleFileUrl = await getFileUrl([ + await getDataDirPath(), + 'sampleFileName', + ].join('/')); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Scaffold( + body: SafeArea( + child: InitialSetupScreenBody( + // Sample resources that would need to be created. + + resNeedToCreate: { + 'folders': [sampleDirUrl], + 'files': [sampleFileUrl], + 'fileNames': [sampleFileName], + }, + child: const Home(), + ), + ), + ), + ), + ); + }, + child: const Text( + 'Show Solid Pod Setup Wizard (Using Real Component)'), + ), + smallGapV, + ], + ), + ), + ], + ), + ), + ); + } + + Future<({String name, String? webId})> _getInfo() async => + (name: await AppInfo.name, webId: await getWebId()); + + @override + Widget build(BuildContext context) { + return FutureBuilder<({String name, String? webId})>( + future: _getInfo(), + builder: (context, snapshot) { + if (snapshot.hasData) { + final appName = snapshot.data?.name; + final title = 'Demonstrating solidpod functionality using ' + '${appName!.isNotEmpty ? appName[0].toUpperCase() + appName.substring(1) : ""}'; + _webId = snapshot.data?.webId; + return _build(context, title); + } else { + return const CircularProgressIndicator(); + } + }, + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 00000000..46935571 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,99 @@ +/// A template app to begin a Solid Pod project. +/// +// Time-stamp: +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along withk +// this program. If not, see . +/// +/// Authors: Graham Williams + +library; + +import 'package:flutter/material.dart'; + +import 'package:demopod/home.dart'; +import 'package:demopod/utils/is_desktop.dart'; +import 'package:solidui/solidui.dart' show SolidLogin, InfoButtonStyle; +import 'package:window_manager/window_manager.dart'; + +void main() async { + // Remove [debugPrint] messages from production code. + + debugPrint = (String? message, {int? wrapWidth}) { + null; + }; + + // Suport window size and top placement for desktop apps. + + if (isDesktop(PlatformWrapper())) { + WidgetsFlutterBinding.ensureInitialized(); + + await windowManager.ensureInitialized(); + + const windowOptions = WindowOptions( + // Setting [alwaysOnTop] here will ensure the app starts on top of other + // apps on the desktop so that it is visible. We later turn it of as we + // don't want to force it always on top. + + alwaysOnTop: true, + + // The [title] is used for the window manager's window title. + + title: 'DemoPod - Demonstrate Private Solid Pod', + ); + + await windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + await windowManager.setAlwaysOnTop(false); + }); + } + + // Ready to run the app. + + runApp(const DemoPod()); +} + +class DemoPod extends StatelessWidget { + const DemoPod({super.key}); + + // This widget is the root of our application. + + @override + Widget build(BuildContext context) { + return const MaterialApp( + title: 'Solid Pod Demonstrator', + home: SolidLogin( + // Images generated using Bing Image Creator from Designer, powered by + // DALL-E3. + + title: 'SOLID POD DEMONSTRATOR', + appDirectory: 'exampleApp', + image: AssetImage('assets/images/demopod_image.png'), + logo: AssetImage('assets/images/demopod_logo.png'), + link: 'https://github.com/anusii/solidpod/blob/main/demopod/README.md', + required: false, + infoButtonStyle: InfoButtonStyle( + tooltip: 'Visit the DemoPod documentation.', + ), + child: Home(), + ), + ); + } +} diff --git a/example/lib/utils/is_desktop.dart b/example/lib/utils/is_desktop.dart new file mode 100644 index 00000000..818e468b --- /dev/null +++ b/example/lib/utils/is_desktop.dart @@ -0,0 +1,55 @@ +/// Check if we are running a desktop (and not a browser). +/// +// Time-stamp: +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Graham Williams, Ninad Bhat + +library; + +import 'package:flutter/foundation.dart' show kIsWeb; + +import 'package:universal_io/io.dart' show Platform; + +bool isDesktop(PlatformWrapper platformWrapper) { + /// platformWrapper: PlatformWrapper class is passed in to allow mocking for testing. + /// Returns true if running on Linux, macOS or Windows. + + if (platformWrapper.isWeb) { + return false; + } + + return platformWrapper.isLinux || + platformWrapper.isMacOS || + platformWrapper.isWindows; +} + +// PlatformWrapper coverage is ignored as it is created to test isDesktop() and +// Platform and kIsWeb are not mockable. +// coverage:ignore-start +class PlatformWrapper { + /// Wraps the Platform class to allow mocking for testing. + bool get isLinux => Platform.isLinux; + bool get isMacOS => Platform.isMacOS; + bool get isWeb => kIsWeb; + bool get isWindows => Platform.isWindows; +} +// coverage:ignore-end diff --git a/example/lib/utils/rdf.dart b/example/lib/utils/rdf.dart new file mode 100644 index 00000000..08d27523 --- /dev/null +++ b/example/lib/utils/rdf.dart @@ -0,0 +1,111 @@ +/// Common utilities for working on RDF data. +/// +// Time-stamp: +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Dawei Chen +library; + +import 'package:rdflib/rdflib.dart'; + +import 'package:solidpod/solidpod.dart' show getWebId; + +// Namespace for keys +const String appTerms = 'https://solidcommunity.au/predicates/terms#'; + +/// Serialise key/value pairs [keyValuePairs] in TTL format where +/// Subject: Web ID +/// Predicate: Key +/// Object: Value + +Future genTTLStr( + List<({String key, dynamic value})> keyValuePairs) async { + assert(keyValuePairs.isNotEmpty); + assert({for (final p in keyValuePairs) p.key}.length == + keyValuePairs.length); // No duplicate keys + final webId = await getWebId(); + assert(webId != null); + final g = Graph(); + final f = URIRef(webId!); + final ns = Namespace(ns: appTerms); + + for (final p in keyValuePairs) { + g.addTripleToGroups(f, ns.withAttr(p.key), p.value); + } + + g.serialize(abbr: 'short'); + + return g.serializedString; +} + +/// Parse TTL string [ttlStr] and returns the key-value pairs from triples where +/// Subject: Web ID +/// Predicate: Key +/// Object: Value + +Future> parseTTLStr(String ttlStr) async { + assert(ttlStr.isNotEmpty); + final g = Graph(); + g.parseTurtle(ttlStr); + final keys = {}; + final pairs = <({String key, dynamic value})>[]; + final webId = await getWebId(); + assert(webId != null); + String extract(String str) => str.contains('#') ? str.split('#')[1] : str; + for (final t in g.triples) { + final sub = t.sub.value as String; + if (sub == webId) { + final pre = extract(t.pre.value as String); + final obj = extract(t.obj.value as String); + assert(!keys.contains(pre)); + keys.add(pre); + pairs.add((key: pre, value: obj)); + } + } + return pairs; +} + +/// Parses enc-key file information and extracts content into a map. +/// +/// This function processes the provided file information, which is expected to be +/// in Turtle (Terse RDF Triple Language) format. It uses a graph-based approach +/// to parse the Turtle data and extract key attributes and their values. + +Map getEncKeyContent(String fileInfo) { + final g = Graph(); + g.parseTurtle(fileInfo); + final fileContentMap = {}; + final fileContentList = []; + for (final t in g.triples) { + final predicate = t.pre.value as String; + if (predicate.contains('#')) { + final subject = t.sub.value; + final attributeName = predicate.split('#')[1]; + final attrVal = t.obj.value.toString(); + if (attributeName != 'type') { + fileContentList.add([subject, attributeName, attrVal]); + } + fileContentMap[attributeName] = [subject, attrVal]; + } + } + + return fileContentMap; +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 00000000..d16aff7d --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,36 @@ +name: demopod +description: 'A SolidPod Demonstrator.' +publish_to: 'none' +version: 0.0.1+1 + +environment: + sdk: '>=3.4.3 <4.0.0' + +dependencies: + editable: ^2.0.0 + file_picker: ^10.3.10 + flutter: + sdk: flutter + intl: any + markdown_tooltip: ^0.0.10 + rdflib: ^0.2.12 + solidpod: ^0.12.0 + solidui: ^0.3.2 + universal_io: ^2.3.1 + window_manager: ^0.5.1 + +dependency_overrides: + solidpod: + path: .. + solidui: + git: + url: https://github.com/anusii/solidui.git + ref: dev + +dev_dependencies: + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + assets: + - assets/images/ diff --git a/example/support/clean.mk b/example/support/clean.mk new file mode 100644 index 00000000..e4ad7e33 --- /dev/null +++ b/example/support/clean.mk @@ -0,0 +1,29 @@ +######################################################################## +# +# Makefile template for Cleaning +# +# Copyright 2018-2019 (c) Graham.Williams@togaware.com +# +# License: Creative Commons Attribution-ShareAlike 4.0 International. +# +######################################################################## + +define CLEAN_HELP +Cleanup: + + clean + realclean + +endef +export CLEAN_HELP + +help:: + @echo "$$CLEAN_HELP" + +.PHONY: clean +clean:: + find . -type f \( -name "*~" -o -name "*.bak" \) -exec rm {} \; + +.PHONY: realclean +realclean:: clean + diff --git a/example/support/coverage.sh b/example/support/coverage.sh new file mode 100644 index 00000000..5723a917 --- /dev/null +++ b/example/support/coverage.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +cat coverage/lcov.info | + egrep '^(SF|LF|LH)' | + awk '{gsub(/^(SF:|LF:|LH:)/, "", $0); print}' | + awk 'ORS=NR%3?",":"\n"' | + awk -F',' 'BEGIN { linesTotal=0; hitsTotal=0; } + { linesTotal+=$2; hitsTotal+=$3; print $0; } + END { printf "total,%d,%d\n", linesTotal, hitsTotal; }' | + awk 'BEGIN{ print("file,lines,hits") } {print}' | + mlr --csv put '$cover = round(100*$hits/$lines)' | + tee coverage/coverage.csv | + mlr --csv --opprint --barred cat + +cover=$(mlr --csv filter '$file=="total"' then cut -f cover coverage/coverage.csv | tail +2) + +if [ "$cover" -lt 90 ]; then + echo -e "\nThe code coverage is too low: ${cover}%. Expected at least 90%." + exit 1 +fi diff --git a/example/support/flutter.mk b/example/support/flutter.mk new file mode 100644 index 00000000..e8d2168f --- /dev/null +++ b/example/support/flutter.mk @@ -0,0 +1,411 @@ +######################################################################## +# +# Makefile template for Flutter +# +# Copyright 2021 (c) Graham.Williams@togaware.com +# +# License: Creative Commons Attribution-ShareAlike 4.0 International. +# +######################################################################## + +# App version numbers +# Major release +# Minor update +# Trivial update or bug fix + +ifeq ($(VER),) + VER = $(shell egrep '^version:' pubspec.yaml | cut -d' ' -f2) +endif + +define FLUTTER_HELP +flutter: + + android Run with an attached Android device; + chrome Run with the chrome device; + emu Run with the android emulator; + linux Run with the linux device; + qlinux Run with the linux device and debugPrint() turned off; + + prep Prep for PR by running tests, checks, docs. + push Do a git push and bump the build number if there is one. + + docs Run `dart doc` to create documentation. + + import_order Run import order checking. + import_order_fix Run import order fixing. + + pubspec Choose actual/local pubspec using meld. + + fix Run `dart fix --apply`. + format Run `dart format`. + analyze Run `flutter analyze`. + depend Run `dart run dependency_validator`. + ignore Look for usage of ignore directives. + license Look for missing top license in source code. + + test Run `flutter test` for testing. + itest Run `flutter test integration_test` for interation testing. + qtest Run above test with PAUSE=0. + coverage Run with `--coverage`. + coview View the generated html coverage in browser. + + riverpod Setup `pubspec.yaml` to support riverpod. + runner Build the auto generated code as *.g.dart files. + + desktops Set up for all desktop platforms (linux, windows, macos) + + distributions + apk Builds installers/$(APP).apk + tgz Builds installers/$(APP).tar.gz + + publish Publish a package to pub.dev + +Also supported: + + *.itest + *.qtest + +endef +export FLUTTER_HELP + +help:: + @echo "$$FLUTTER_HELP" + +.PHONY: chrome +chrome: + flutter run -d chrome + +# 20220503 gjw The following fails if the target files already exist - +# just needs to be run once. +# +# dart run build_runner build --delete-conflicting-outputs +# +# List the files that are automatically generated. Then they will get +# built as required. + +# BUILD_RUNNER = \ +# lib/models/synchronise_time.g.dart + +# $(BUILD_RUNNER): +# dart run build_runner build --delete-conflicting-outputs + +pubspec.lock: + flutter pub get + +.PHONY: linux +linux: pubspec.lock $(BUILD_RUNNER) + flutter run --device-id linux + +# Turn off debugPrint() output. + +.PHONY: qlinux +qlinux: pubspec.lock $(BUILD_RUNNER) + flutter run --dart-define DEBUG_PRINT="FALSE" --device-id linux + +.PHONY: macos +macos: $(BUILD_RUNNER) + flutter run --device-id macos + +.PHONY: android +android: $(BUILD_RUNNER) + flutter run --device-id $(shell flutter devices | grep android | tr '•' '|' | tr -s '|' | tr -s ' ' | cut -d'|' -f2 | tr -d ' ') + +.PHONY: emu +emu: + @if [ -n "$(shell flutter devices | grep emulator | cut -d" " -f 6)" ]; then \ + flutter run --device-id $(shell flutter devices | grep emulator | cut -d" " -f 6); \ + else \ + flutter emulators --launch Pixel_3a_API_30; \ + echo "Emulator has been started. Rerun `make emu` to build the app."; \ + fi + +.PHONY: linux_config +linux_config: + flutter config --enable-linux-desktop + +.PHONY: prep +prep: analyze fix import_order_fix format ignore license todo + @echo "ADVISORY: make tests docs" + @echo $(SEPARATOR) + +.PHONY: docs +docs:: + dart doc + chmod -R go+rX doc + +SEPARATOR="------------------------------------------------------------------------" + +.PHONY: pubspec +pubspec: + meld pubspec.yaml.actual pubspec.yaml pubspec.yaml.local + +.PHONY: fix +fix: + @echo "Dart: FIX" + dart fix --apply lib + @echo $(SEPARATOR) + +.PHONY: format +format: + @echo "Dart: FORMAT" + dart format lib/ + @echo $(SEPARATOR) + +# My emacs IDE is starting to add imports of backups automagically! + +.PHONY: bakfix +bakfix: + @echo "Find and fix imports of backups." + find lib -type f -name '*.dart*' -exec sed -i 's/\.dart\.~\([0-9]\)~/\.dart/g' {} + + @echo $(SEPARATOR) + +.PHONY: tests +tests:: test qtest + +.PHONY: analyze +analyze: + @echo "Futter ANALYZE" + -flutter analyze lib +# dart run custom_lint + @echo $(SEPARATOR) + +# dart pub global activate dependency_validator + +.PHONY: depend +depend: + @echo "Review pubspec.yaml dependencies." + -dependency_validator + @echo $(SEPARATOR) + +.PHONY: ignore +ignore: + @echo "Files that override lint checks with IGNORE:\n" + @-if grep -r -n ignore: lib; then exit 1; else exit 0; fi + @echo $(SEPARATOR) + +.PHONY: todo +todo: + @echo "Files that include TODO items to be resolved:\n" + @-if grep -r -n ' TODO ' lib; then exit 1; else exit 0; fi + @echo $(SEPARATOR) + +.PHONY: license +license: + @echo "Files without a LICENSE:\n" + @-find lib -type f -not -name '*~' ! -exec grep -qE '^(/// .*|/// Copyright|/// Licensed)' {} \; -print | xargs printf "\t%s\n" + @echo $(SEPARATOR) + +.PHONY: riverpod +riverpod: + flutter pub add flutter_riverpod + flutter pub add riverpod_annotation + flutter pub add dev:riverpod_generator + flutter pub add dev:build_runner + flutter pub add dev:custom_lint + flutter pub add dev:riverpod_lint + +.PHONY: runner +runner: + dart run build_runner build + +# Support desktop platforms: Linux, MacOS and Windows. Using the +# project name as in the already existant pubspec.yaml ensures the +# project name is a valid name. Otherwise it is obtained from the +# folder name and may not necessarily be a valid flutter project name. + +.PHONY: desktops +desktops: + flutter create --platforms=windows,macos,linux --project-name $(shell grep 'name: ' pubspec.yaml | awk '{print $$2}') . + +######################################################################## +# INTEGRATION TESTING +# +# Run the integration tests for the desktop device (linux, windows, +# macos). Without this explictly specified, if I have my android +# device connected to the computer then the testing defaults to trying +# to install on android. 20230713 gjw + +.PHONY: test +test: + @echo "Unit TEST:" + -flutter test test + @echo $(SEPARATOR) + +# For a specific interactive test we think of it as providing a +# demonstration of the app functionality that we may actually use to +# create a narrated video. A INTERACT of 5 or more is then useful. + +%.itest: + @device_id=$(shell flutter devices | grep -E 'linux|macos|windows' | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|'); \ + if [ -z "$$device_id" ]; then \ + echo "No desktop device found. Please ensure you have the correct desktop platform enabled."; \ + exit 1; \ + fi; \ + flutter test --dart-define=INTERACT=5 --device-id $$device_id integration_test/$*.dart + +# For a run over all tests interactively we INTERACT a little but not as +# much as when running the individual tests. + +.PHONY: itest +itest: + @device_id=$(shell flutter devices | grep -E 'linux|macos|windows' | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|'); \ + if [ -z "$$device_id" ]; then \ + echo "No desktop device found. Please ensure you have the correct desktop platform enabled."; \ + exit 1; \ + fi; \ + for t in integration_test/*.dart; do flutter test --dart-define=INTERACT=2 --device-id $$device_id $$t; done + @echo $(SEPARATOR) + +# For the quick tests we do not INTERACT at all. The aim is to quickly +# test all functionality. + +.PHONY: qtest +qtest: + @device_id=$(shell flutter devices | grep -E 'linux|macos|windows' | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|'); \ + if [ -z "$$device_id" ]; then \ + echo "No desktop device found. Please ensure you have the correct desktop platform enabled."; \ + exit 1; \ + fi; \ + for t in integration_test/*.dart; do \ + echo "========================================"; \ + echo $$t; /bin/echo -n $$t >&2; \ + echo "========================================"; \ + flutter test --dart-define=INTERACT=0 --device-id $$device_id --reporter failures-only $$t 2>/dev/null; \ + if [ "$$?" -eq 0 ]; then /bin/echo ' YES' >&2; else /bin/echo -n ' ...' >&2; \ + echo '****************************************> TRY AGAIN'; \ + flutter test --dart-define=INTERACT=0 --device-id $$device_id --reporter failures-only $$t 2>/dev/null; \ + if [ "$$?" -eq 0 ]; then /bin/echo ' YES' >&2; else /bin/echo ' NO *****' >&2; fi; fi; \ + done + @echo $(SEPARATOR) + +%.qtest: + @device_id=$(shell flutter devices | grep -E 'linux|macos|windows' | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|'); \ + if [ -z "$$device_id" ]; then \ + echo "No desktop device found. Please ensure you have the correct desktop platform enabled."; \ + exit 1; \ + fi; \ + flutter test --dart-define=INTERACT=0 --device-id $$device_id --reporter failures-only integration_test/$*.dart 2>/dev/null + +.PHONY: qtest.all +qtest.all: + @echo $(APP) `egrep '^version: ' pubspec.yaml` + @echo "flutter version:" `flutter --version | head -1 | cut -d ' ' -f 2` + make qtest > qtest_$(shell date +%Y%m%d%H%M%S).txt + +clean:: + rm -f qtest_*.txt + +.PHONY: atest +atest: + @echo "Full integration TEST:" + flutter test --dart-define=INTERACT=0 --verbose --device-id \ + $(shell flutter devices | grep desktop | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|') \ + integration_test + @echo $(SEPARATOR) + +.PHONY: coverage +coverage: + @echo "COVERAGE" + @flutter test --coverage + @echo + @-/bin/bash support/coverage.sh + @echo $(SEPARATOR) + +.PHONY: coview +coview: + @genhtml coverage/lcov.info -o coverage/html + @open coverage/html/index.html + +realclean:: + rm -rf coverage + +# Crate an installer for Linux as a tar.gz archive. + +tgz:: $(APP)-$(VER)-linux-x86_64.tar.gz + +$(APP)-$(VER)-linux-x86_64.tar.gz: clean + mkdir -p installers + rm -rf build/linux/x64/release + flutter build linux --release + tar --transform 's|^build/linux/x64/release/bundle|$(APP)|' -czvf $@ build/linux/x64/release/bundle + cp $@ installers/ + mv $@ installers/$(APP).tar.gz + +apk:: + flutter build apk --release + cp build/app/outputs/flutter-apk/app-release.apk installers/$(APP).apk + cp build/app/outputs/flutter-apk/app-release.apk installers/$(APP)-$(VER).apk + +appbundle: + flutter build appbundle --release + +realclean:: + flutter clean + flutter pub get + +# For the `dev` branch only, update the version sequence number prior +# to a push (relies on the git.mk being loaded after this +# flutter.mk). This is only undertaken through `make push` rather than +# a `git push` in any other way. If +# the pubspec.yaml is not using a build number then do not push to bump +# the build number. + +VERSEQ=$(shell grep '^version: ' pubspec.yaml | cut -d'+' -f2 | awk '{print $$1+1}') + +BRANCH := $(shell git branch --show-current) + +ifeq ($(BRANCH),dev) +push:: + @echo $(SEPARATOR) + perl -pi -e 's|(^version: .*)\+.*|$$1+$(VERSEQ)|' pubspec.yaml + -egrep '^version: .*\+.*' pubspec.yaml && \ + git commit -m "Bump sequence $(VERSEQ)" pubspec.yaml +endif + +.PHONY: publish +publish: + dart pub publish + +# dart pub global activate import_order_lint + +.PHONY: import_order +import_order: + @echo "Dart: CHECK IMPORT ORDER" + dart run custom_lint + @echo $(SEPARATOR) + +.PHONY: import_order_fix +import_order_fix: + @echo "Dart: FIX IMPORT ORDER" + fix_imports --project-name=$(APP) -r lib + @echo $(SEPARATOR) + +### TODO THESE SHOULD BE CHECKED AND CLEANED UP + + +.PHONY: docs +docs:: + rsync -avzh doc/api/ root@solidcommunity.au:/var/www/html/docs/$(APP)/ + +.PHONY: versions +versions: + perl -pi -e 's|applicationVersion = ".*";|applicationVersion = "$(VER)";|' \ + lib/constants/app.dart + +.PHONY: wc +wc: lib/*.dart + @cat $(shell find lib -name '*.dart') \ + | egrep -v '^ */' \ + | egrep -v '^ *$$' \ + | wc -l + +# +# Manage the production install on the remote server. +# + +.PHONY: solidcommunity +solidcommunity: + rsync -avzh ./ solidcommunity.au:projects/$(APP)/ \ + --exclude .dart_tool --exclude build --exclude ios --exclude macos \ + --exclude linux --exclude windows --exclude android + ssh solidcommunity.au '(cd projects/$(APP); flutter upgrade; make prod)' diff --git a/example/support/git.mk b/example/support/git.mk new file mode 100644 index 00000000..b4d4bf92 --- /dev/null +++ b/example/support/git.mk @@ -0,0 +1,127 @@ +######################################################################## +# +# Makefile template for Version Control - git +# +# Time-stamp: +# +# Copyright 2018-2024 (c) Graham.Williams@togaware.com +# +# License: Creative Commons Attribution-ShareAlike 4.0 International. +# +######################################################################## + +define GIT_HELP +git: + + info Identify the git repository; + status Status listing untracked files; + qstatus A quieter status ignoring untracked files; + + enter Do a git status, fetch, and rebase + exit Do a git status + + push + pull + + fetch Update local repo from remote. + stash Stash changes to allow a rebase. + merge Update local repo with remote updates. + rebase Rebase local repo to include remote in history. + pop Pop the stash. + + main Checkout the main branch; + dev Checkout the dev branch; + log + flog Show the full log; + gdiff + vdiff Show a visual diff using meld. + + upstream Merge from upstrem to local repo. + +endef +export GIT_HELP + +help:: + @echo "$$GIT_HELP" + +info: + @echo "-------------------------------------------------------" + git config --get remote.origin.url + @echo "-------------------------------------------------------" + +status: + @echo "-------------------------------------------------------" + git status + @echo "-------------------------------------------------------" + +qstatus: + @echo "-------------------------------------------------------" + git status --untracked-files=no + @echo "-------------------------------------------------------" + +enter:: status fetch rebase +exit:: status push + +# Use :: to allow push to be augmented in other makefiles. + +push:: + @echo "-------------------------------------------------------" + git push + @echo "-------------------------------------------------------" + +pull: + @echo "-------------------------------------------------------" + git pull --stat + @echo "-------------------------------------------------------" + +fetch: + @echo "-------------------------------------------------------" + git fetch + @echo "-------------------------------------------------------" + +stash: + @echo "-------------------------------------------------------" + git stash + @echo "-------------------------------------------------------" + +rebase: + @echo "-------------------------------------------------------" + git rebase + @echo "-------------------------------------------------------" + +pop: + @echo "-------------------------------------------------------" + git stash pop + @echo "-------------------------------------------------------" + +main: + @echo "-------------------------------------------------------" + git checkout main + @echo "-------------------------------------------------------" + +dev: + @echo "-------------------------------------------------------" + git checkout $(USER)/dev + @echo "-------------------------------------------------------" + +log: + @echo "-------------------------------------------------------" + git --no-pager log --stat --max-count=10 + @echo "-------------------------------------------------------" + +flog: + @echo "-------------------------------------------------------" + git --no-pager log + @echo "-------------------------------------------------------" + +gdiff: + @echo "-------------------------------------------------------" + git --no-pager diff --color + @echo "-------------------------------------------------------" + +vdiff: + git difftool --tool=meld + +upstream: + git fetch upstream + git merge upstream/main diff --git a/example/support/install.mk b/example/support/install.mk new file mode 100644 index 00000000..dafafeae --- /dev/null +++ b/example/support/install.mk @@ -0,0 +1,60 @@ +######################################################################## +# +# Makefile template for Installations +# +# Time-stamp: +# +# Copyright (c) Graham.Williams@togaware.com +# +# License: Creative Commons Attribution-ShareAlike 4.0 International. +# +######################################################################## + +# Define PROD and MINE if not already defined. + +PROD ?= $(DEST) +MINE ?= $(DEST:$(APP)=$(USER)) + +# Only allow prod if in main branch. + +BRANCH := $(shell git branch --show-current) + +ifeq ($(BRANCH),main) + PROD ?= $(DEST) +else + PROD ?= $(MINE) +endif + +define INSTALL_HELP +installs: + + prod Install $(APP) into $(PROD) + install Install $(APP) into $(MINE) + +endef +export INSTALL_HELP + +help:: + @echo "$$INSTALL_HELP" + +######################################################################## +# LOCAL TARGETS + +install: $(USER).install + +ifeq ($(BRANCH),main) +prod: $(APP).install +else +prod: $(USER).install +endif + +%.install: + cp web/index.html web/index.html.bak + perl -pi -e 's|^ |' web/index.html + flutter build web + mv web/index.html.bak web/index.html + if [ ! -e $(DEST:$(APP)=$*) ]; then \ + sudo mkdir $(DEST:$(APP)=$*); \ + fi + sudo rsync -azvh build/web/ $(DEST:$(APP)=$*) + sudo chmod -R a+rX $(DEST:$(APP)=$*) diff --git a/example/support/modules.mk b/example/support/modules.mk new file mode 100644 index 00000000..a11b5a53 --- /dev/null +++ b/example/support/modules.mk @@ -0,0 +1,68 @@ +######################################################################## +# Supported Makefile modules. +# +# Often the support Makefiles will be in the local support folder, or +# else installed in the local user's shares. + +INC_CLEAN ?= $(INC_BASE)/clean.mk +INC_BOOKDOWN ?= $(INC_BASE)/bookdown.mk +INC_R ?= $(INC_BASE)/r.mk +INC_KNITR ?= $(INC_BASE)/knitr.mk +INC_PANDOC ?= $(INC_BASE)/pandoc.mk +INC_GIT ?= $(INC_BASE)/git.mk +INC_AZURE ?= $(INC_BASE)/azure.mk +INC_LATEX ?= $(INC_BASE)/latex.mk +INC_PDF ?= $(INC_BASE)/pdf.mk +INC_DOCKER ?= $(INC_BASE)/docker.mk +INC_FLUTTER ?= $(INC_BASE)/flutter.mk +INC_JEKYLL ?= $(INC_BASE)/jekyll.mk +INC_MLHUB ?= $(INC_BASE)/mlhub.mk +INC_WEBCAM ?= $(INC_BASE)/webcam.mk +INC_INSTALL ?= $(INC_BASE)/install.mk + +ifneq ("$(wildcard $(INC_CLEAN))","") + include $(INC_CLEAN) +endif +ifneq ("$(wildcard $(INC_BOOKDOWN))","") + include $(INC_BOOKDOWN) +endif +ifneq ("$(wildcard $(INC_R))","") + include $(INC_R) +endif +ifneq ("$(wildcard $(INC_KNITR))","") + include $(INC_KNITR) +endif +ifneq ("$(wildcard $(INC_PANDOC))","") + include $(INC_PANDOC) +endif +ifneq ("$(wildcard $(INC_FLUTTER))","") + include $(INC_FLUTTER) +endif +ifneq ("$(wildcard $(INC_GIT))","") + include $(INC_GIT) +endif +ifneq ("$(wildcard $(INC_AZURE))","") + include $(INC_AZURE) +endif +ifneq ("$(wildcard $(INC_LATEX))","") + include $(INC_LATEX) +endif +ifneq ("$(wildcard $(INC_PDF))","") + include $(INC_PDF) +endif +ifneq ("$(wildcard $(INC_DOCKER))","") + include $(INC_DOCKER) +endif +ifneq ("$(wildcard $(INC_JEKYLL))","") + include $(INC_JEKYLL) +endif +ifneq ("$(wildcard $(INC_MLHUB))","") + include $(INC_MLHUB) +endif +ifneq ("$(wildcard $(INC_WEBCAM))","") + include $(INC_WEBCAM) +endif +ifneq ("$(wildcard $(INC_INSTALL))","") + include $(INC_INSTALL) +endif +