Compare commits

...

11 commits

Author SHA1 Message Date
cf71d5457a
Implemented project import 2022-06-19 09:23:04 +03:00
775a53871f
Optimized list item replacement 2022-06-19 08:50:14 +03:00
073a8e77c1
Updated Android app name 2022-06-19 08:45:11 +03:00
bd10853866
Implemented project export 2022-06-19 08:05:29 +03:00
dd62a26004
Moved remove input/output icons to trailing side 2022-06-19 08:02:21 +03:00
500dc297d3
Changed red button text color to white 2022-06-19 08:01:05 +03:00
c002e70616
Separate truth table to file, add column borders 2022-06-19 07:59:04 +03:00
ba6cc9489c
Add padding at bottom of edit component page
Previously, the save floating action button was overlapping truth table.
Now, the screen can be scrolled such that the button will no longer
overlap due to the new empty space.
2022-06-19 07:53:15 +03:00
aea18c3262
Made new project dialog scrollable
This is useful in case of small screens
2022-06-19 07:48:24 +03:00
012de37b4a
Changed red button text color to white 2022-06-19 07:46:40 +03:00
8c510074c9
Add autofocus to dialog 2022-06-18 02:07:04 +03:00
14 changed files with 711 additions and 239 deletions

View file

@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="ro.dcdev.logic_circuits_simulator"> package="ro.dcdev.logic_circuits_simulator">
<application <application
android:label="logic_circuits_simulator" android:label="Logic Circuits Simulator"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

View file

@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:logic_circuits_simulator/utils/iterable_extension.dart';
class TruthTableHeaderText extends StatelessWidget {
final String text;
final BoxBorder? border;
const TruthTableHeaderText(this.text, {super.key, this.border});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: border,
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
);
}
}
class TruthTableTrue extends StatelessWidget {
final BoxBorder? border;
final void Function()? onTap;
const TruthTableTrue({super.key, this.border, this.onTap});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: border,
),
child: InkWell(
onTap: onTap,
child: Text(
'T',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: Colors.green,
fontSize: 20,
),
),
),
);
}
}
class TruthTableFalse extends StatelessWidget {
final BoxBorder? border;
final void Function()? onTap;
const TruthTableFalse({super.key, this.border, this.onTap});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: border,
),
child: InkWell(
onTap: onTap,
child: Text(
'F',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: Colors.red,
fontSize: 20,
),
),
),
);
}
}
class TruthTableEditor extends StatelessWidget {
static const marginBorder = BorderSide(
color: Colors.black,
width: 2,
);
static const innerBorder = BorderSide(
color: Colors.black45,
width: 1,
);
final List<String> inputs;
final List<String> outputs;
final List<String> truthTable;
final void Function(int, String)? onUpdateTable;
const TruthTableEditor({Key? key, required this.inputs, required this.outputs, required this.truthTable, required this.onUpdateTable}) : super(key: key);
@override
Widget build(BuildContext context) {
return Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
border: TableBorder.symmetric(outside: const BorderSide(width: 2)),
children: List.generate(
truthTable.length + 1,
(index) {
if (index == 0) {
return TableRow(
children: inputs
.indexedMap<Widget>(
(index, e) => TruthTableHeaderText(
e,
border: Border(
bottom: marginBorder,
right: index == inputs.length - 1
? marginBorder
: innerBorder,
),
)
)
.followedBy(
outputs
.indexedMap(
(index, e) => TruthTableHeaderText(
e,
border: Border(
bottom: const BorderSide(width: 2),
right: index == outputs.length - 1
? BorderSide.none
: innerBorder,
),
)
)
)
.toList(),
);
}
final inputBinary = (index - 1).toRadixString(2).padLeft(inputs.length, '0');
final outputBinary = truthTable[index - 1];
Widget runeToWidget({required int rune, void Function()? onTap, BoxBorder? border}) {
return int.parse(String.fromCharCode(rune)) != 0
? TruthTableTrue(
border: border,
onTap: onTap,
)
: TruthTableFalse(
border: border,
onTap: onTap,
);
}
return TableRow(
children: inputBinary.runes.indexedMap(
(i, r) => runeToWidget(
rune: r,
border: i == inputBinary.runes.length - 1
? const Border(right: marginBorder)
: const Border(right: innerBorder),
)
)
.followedBy(outputBinary.runes.indexedMap(
(i, r) => runeToWidget(
rune: r,
border: i == outputBinary.runes.length - 1
? null
: const Border(right: innerBorder),
onTap: onUpdateTable == null ? null : () {
onUpdateTable!(index - 1, outputBinary.replaceRange(i, i+1, (outputBinary[i] == "1") ? "0" : "1"));
},
),
))
.toList(),
);
},
),
);
}
}

View file

@ -40,6 +40,7 @@ class NewAskForNameDialog extends HookWidget {
child: Container( child: Container(
constraints: const BoxConstraints(minWidth: 300), constraints: const BoxConstraints(minWidth: 300),
child: TextField( child: TextField(
autofocus: true,
controller: tec, controller: tec,
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),

View file

@ -1,7 +1,12 @@
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:logic_circuits_simulator/state/projects.dart'; import 'package:logic_circuits_simulator/state/projects.dart';
import 'package:logic_circuits_simulator/utils/provider_hook.dart'; import 'package:logic_circuits_simulator/utils/provider_hook.dart';
import 'package:provider/provider.dart';
class NewProjectDialog extends HookWidget { class NewProjectDialog extends HookWidget {
const NewProjectDialog({Key? key}) : super(key: key); const NewProjectDialog({Key? key}) : super(key: key);
@ -20,89 +25,205 @@ class NewProjectDialog extends HookWidget {
}; };
}, [newDialogNameController.text]); }, [newDialogNameController.text]);
final importProjectAction = useMemoized(() {
return () async {
final projectsState = Provider.of<ProjectsState>(context, listen: false);
final msg = ScaffoldMessenger.of(context);
final nav = Navigator.of(context);
try {
final inputFiles = await FilePicker.platform.pickFiles(
dialogTitle: 'Import Project',
allowedExtensions: Platform.isLinux || Platform.isWindows ? ['lcsproj'] : null,
lockParentWindow: true,
type: Platform.isLinux || Platform.isWindows ? FileType.custom : FileType.any,
allowMultiple: false,
withData: true,
);
if (inputFiles == null) {
return;
}
final inputFile = inputFiles.files.first;
final dec = ZipDecoder();
final archive = dec.decodeBytes(inputFile.bytes!);
// bool editAfter = false;
final result = await projectsState.importProject(
archive: archive,
onConflictingId: () async {
final response = await showDialog<bool>(
barrierDismissible: false,
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Conflicting ID'),
content: const Text('You already have a project with the same ID as the one you are importing.\n\nAre you sure you want to replace the current project with the imported one?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: const Text('Cancel'),
),
Theme(
data: ThemeData(
brightness: Theme.of(context).brightness,
primarySwatch: Colors.red,
),
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Overwrite and import'),
),
),
],
);
},
);
// Allow conflicting id ONLY if allow button is explicitly tapped
return response == true;
},
onConflictingName: (String name) async {
final response = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Conflicting name'),
content: Text('You already have a project named $name.\n\nYou may import the project and have both coexist, but confusion may arise.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Import anyway'),
),
],
);
},
);
// Allow conflicting name UNLESS deny button is explicitly tapped
return response != false;
},
);
if (result != null) {
nav.pop();
// if (!editAfter) {
msg.showSnackBar(
SnackBar(
content: Text('Project ${result.projectName} imported'),
),
);
// }
// else {
// // TODO: Allow editing project name in the future
// }
}
}
catch (e) {
nav.pop();
msg.showSnackBar(
SnackBar(
content: Text('Failed to import project: $e'),
duration: const Duration(seconds: 10),
),
);
}
};
});
return Dialog( return Dialog(
child: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.all(8.0), child: Padding(
child: IntrinsicWidth( padding: const EdgeInsets.all(8.0),
child: Column( child: IntrinsicWidth(
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ mainAxisSize: MainAxisSize.min,
Align( children: [
alignment: Alignment.centerRight, Align(
child: IconButton( alignment: Alignment.centerRight,
icon: const Icon(Icons.close), child: IconButton(
tooltip: 'Close', icon: const Icon(Icons.close),
onPressed: () { tooltip: 'Close',
Navigator.of(context).pop();
},
),
),
Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: OutlinedButton.icon(
onPressed: () { onPressed: () {
// TODO: Implement project importing Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Import coming soon...'),
));
}, },
icon: const Icon(Icons.download),
label: const Text('Import Project'),
), ),
), ),
), Center(
Row( child: Padding(
children: [ padding: const EdgeInsets.all(8.0),
const Expanded( child: OutlinedButton.icon(
child: Padding( onPressed: importProjectAction,
padding: EdgeInsets.all(8.0), icon: const Icon(Icons.download),
child: Divider(), label: const Text('Import Project'),
), ),
), ),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'OR',
style: Theme.of(context).textTheme.caption,
),
),
const Expanded(
child: Padding(
padding: EdgeInsets.all(8.0),
child: Divider(),
),
),
],
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'New Project',
style: Theme.of(context).textTheme.headline6,
textAlign: TextAlign.center,
), ),
), Row(
Padding( children: [
padding: const EdgeInsets.all(8.0), const Expanded(
child: Container( child: Padding(
constraints: const BoxConstraints(minWidth: 300), padding: EdgeInsets.all(8.0),
child: TextField( child: Divider(),
controller: newDialogNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'Project name',
suffixIcon: IconButton(
icon: const Icon(Icons.done),
onPressed: newProjectAction,
), ),
), ),
onSubmitted: newProjectAction == null ? null : (_) => newProjectAction(), Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'OR',
style: Theme.of(context).textTheme.caption,
),
),
const Expanded(
child: Padding(
padding: EdgeInsets.all(8.0),
child: Divider(),
),
),
],
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'New Project',
style: Theme.of(context).textTheme.headline6,
textAlign: TextAlign.center,
), ),
), ),
), Padding(
], padding: const EdgeInsets.all(8.0),
child: Container(
constraints: const BoxConstraints(minWidth: 300),
child: TextField(
controller: newDialogNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'Project name',
suffixIcon: IconButton(
icon: const Icon(Icons.done),
onPressed: newProjectAction,
),
),
onSubmitted: newProjectAction == null ? null : (_) => newProjectAction(),
),
),
),
],
),
), ),
), ),
), ),

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:logic_circuits_simulator/components/truth_table.dart';
import 'package:logic_circuits_simulator/dialogs/new_ask_for_name.dart'; import 'package:logic_circuits_simulator/dialogs/new_ask_for_name.dart';
import 'package:logic_circuits_simulator/models/project.dart'; import 'package:logic_circuits_simulator/models/project.dart';
import 'package:logic_circuits_simulator/state/project.dart'; import 'package:logic_circuits_simulator/state/project.dart';
@ -97,6 +98,7 @@ class EditComponentPage extends HookWidget {
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
}, },
style: ButtonStyle( style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Colors.white),
backgroundColor: MaterialStateProperty.all(Colors.red), backgroundColor: MaterialStateProperty.all(Colors.red),
), ),
child: const Text('Discard'), child: const Text('Discard'),
@ -171,7 +173,8 @@ class EditComponentPage extends HookWidget {
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, idx) => ListTile( (context, idx) => ListTile(
leading: inputs.value.length > 1 ? IconButton( title: Text(inputs.value[idx]),
trailing: inputs.value.length > 1 ? IconButton(
icon: const Icon(Icons.remove_circle), icon: const Icon(Icons.remove_circle),
color: Colors.red, color: Colors.red,
tooltip: 'Remove input ${inputs.value[idx]}', tooltip: 'Remove input ${inputs.value[idx]}',
@ -192,6 +195,7 @@ class EditComponentPage extends HookWidget {
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
}, },
style: ButtonStyle( style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Colors.white),
backgroundColor: MaterialStateProperty.all(Colors.red), backgroundColor: MaterialStateProperty.all(Colors.red),
), ),
child: const Text('Remove'), child: const Text('Remove'),
@ -220,7 +224,6 @@ class EditComponentPage extends HookWidget {
} }
}, },
) : null, ) : null,
title: Text(inputs.value[idx]),
), ),
childCount: inputs.value.length, childCount: inputs.value.length,
), ),
@ -263,7 +266,8 @@ class EditComponentPage extends HookWidget {
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, idx) => ListTile( (context, idx) => ListTile(
leading: outputs.value.length > 1 ? IconButton( title: Text(outputs.value[idx]),
trailing: outputs.value.length > 1 ? IconButton(
icon: const Icon(Icons.remove_circle), icon: const Icon(Icons.remove_circle),
color: Colors.red, color: Colors.red,
tooltip: 'Remove output ${outputs.value[idx]}', tooltip: 'Remove output ${outputs.value[idx]}',
@ -284,6 +288,7 @@ class EditComponentPage extends HookWidget {
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
}, },
style: ButtonStyle( style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Colors.white),
backgroundColor: MaterialStateProperty.all(Colors.red), backgroundColor: MaterialStateProperty.all(Colors.red),
), ),
child: const Text('Remove'), child: const Text('Remove'),
@ -302,7 +307,6 @@ class EditComponentPage extends HookWidget {
} }
}, },
) : null, ) : null,
title: Text(outputs.value[idx]),
), ),
childCount: outputs.value.length, childCount: outputs.value.length,
), ),
@ -373,6 +377,9 @@ class EditComponentPage extends HookWidget {
), ),
) )
], ],
const SliverPadding(
padding: EdgeInsets.only(bottom: 56 + 16 + 16),
),
], ],
), ),
floatingActionButton: !dirty ? null : FloatingActionButton( floatingActionButton: !dirty ? null : FloatingActionButton(
@ -395,155 +402,3 @@ class EditComponentPage extends HookWidget {
); );
} }
} }
class TruthTableHeaderText extends StatelessWidget {
final String text;
final BoxBorder? border;
const TruthTableHeaderText(this.text, {super.key, this.border});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: border,
),
child: Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
);
}
}
class TruthTableTrue extends StatelessWidget {
final BoxBorder? border;
final void Function()? onTap;
const TruthTableTrue({super.key, this.border, this.onTap});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: border,
),
child: InkWell(
onTap: onTap,
child: Text(
'T',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: Colors.green,
fontSize: 20,
),
),
),
);
}
}
class TruthTableFalse extends StatelessWidget {
final BoxBorder? border;
final void Function()? onTap;
const TruthTableFalse({super.key, this.border, this.onTap});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: border,
),
child: InkWell(
onTap: onTap,
child: Text(
'F',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: Colors.red,
fontSize: 20,
),
),
),
);
}
}
class TruthTableEditor extends StatelessWidget {
final List<String> inputs;
final List<String> outputs;
final List<String> truthTable;
final void Function(int, String) onUpdateTable;
const TruthTableEditor({Key? key, required this.inputs, required this.outputs, required this.truthTable, required this.onUpdateTable}) : super(key: key);
@override
Widget build(BuildContext context) {
return Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
border: TableBorder.symmetric(outside: const BorderSide(width: 2)),
children: List.generate(
truthTable.length + 1,
(index) {
if (index == 0) {
return TableRow(
children: inputs
.indexedMap<Widget>(
(index, e) => TruthTableHeaderText(
e,
border: Border(
bottom: const BorderSide(width: 2),
right: index == inputs.length - 1 ? const BorderSide(width: 2) : BorderSide.none,
),
)
)
.followedBy(
outputs
.map((e) => TruthTableHeaderText(e, border: const Border(bottom: BorderSide(width: 2)),))
)
.toList(),
);
}
final inputBinary = (index - 1).toRadixString(2).padLeft(inputs.length, '0');
final outputBinary = truthTable[index - 1];
Widget runeToWidget({required int rune, void Function()? onTap, BoxBorder? border}) {
return int.parse(String.fromCharCode(rune)) != 0
? TruthTableTrue(
border: border,
onTap: onTap,
)
: TruthTableFalse(
border: border,
onTap: onTap,
);
}
return TableRow(
children: inputBinary.runes.indexedMap(
(i, r) => runeToWidget(
rune: r,
border: i == inputBinary.runes.length - 1 ? const Border(right: BorderSide(width: 2)) : null,
)
)
.followedBy(outputBinary.runes.indexedMap(
(i, r) => runeToWidget(
rune: r,
onTap: () {
onUpdateTable(index - 1, outputBinary.replaceRange(i, i+1, (outputBinary[i] == "1") ? "0" : "1"));
},
),
))
.toList(),
);
},
),
);
}
}

View file

@ -28,6 +28,7 @@ class ProjectPage extends StatelessWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
style: ButtonStyle( style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Colors.white),
backgroundColor: MaterialStateProperty.all(Colors.red), backgroundColor: MaterialStateProperty.all(Colors.red),
), ),
child: const Text('Delete'), child: const Text('Delete'),

View file

@ -1,3 +1,7 @@
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:logic_circuits_simulator/dialogs/new_project.dart'; import 'package:logic_circuits_simulator/dialogs/new_project.dart';
@ -5,7 +9,10 @@ import 'package:logic_circuits_simulator/models/projects.dart';
import 'package:logic_circuits_simulator/pages/project.dart'; import 'package:logic_circuits_simulator/pages/project.dart';
import 'package:logic_circuits_simulator/state/project.dart'; import 'package:logic_circuits_simulator/state/project.dart';
import 'package:logic_circuits_simulator/state/projects.dart'; import 'package:logic_circuits_simulator/state/projects.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
class ProjectsPage extends StatelessWidget { class ProjectsPage extends StatelessWidget {
const ProjectsPage({Key? key}) : super(key: key); const ProjectsPage({Key? key}) : super(key: key);
@ -58,6 +65,61 @@ class ProjectsPage extends StatelessWidget {
Navigator.of(context).pushNamed(ProjectPage.routeName); Navigator.of(context).pushNamed(ProjectPage.routeName);
} }
bool get canExport => Platform.isWindows || Platform.isMacOS || Platform.isLinux;
void onProjectExport(BuildContext context, ProjectEntry p) async {
final projectsState = Provider.of<ProjectsState>(context, listen: false);
final msg = ScaffoldMessenger.of(context);
final outputFile = await FilePicker.platform.saveFile(
dialogTitle: 'Export ${p.projectName}',
fileName: '${p.projectId}.lcsproj',
allowedExtensions: ['lcsproj'],
lockParentWindow: true,
type: FileType.custom,
);
if (outputFile == null) {
return;
}
final enc = ZipEncoder();
await projectsState.archiveProject(
p,
(archive) async {
enc.encode(archive, output: OutputFileStream(outputFile));
},
);
msg.showSnackBar(
SnackBar(
content: Text('Project ${p.projectName} exported'),
),
);
}
bool get canShare => !(Platform.isWindows || Platform.isLinux);
void onProjectShare(BuildContext context, ProjectEntry p) async {
final projectsState = Provider.of<ProjectsState>(context, listen: false);
final tmpDir = await getTemporaryDirectory();
final archiveFile = File(path.join(tmpDir.path, '${p.projectId}.lcsproj'));
final enc = ZipEncoder();
await projectsState.archiveProject(
p,
(archive) async {
enc.encode(archive, output: OutputFileStream(archiveFile.path));
},
);
await Share.shareFiles(
[archiveFile.path],
mimeTypes: ['application/zip'],
);
await archiveFile.delete();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final projects = Provider.of<ProjectsState>(context).projects; final projects = Provider.of<ProjectsState>(context).projects;
@ -77,10 +139,8 @@ class ProjectsPage extends StatelessWidget {
child: ProjectTile( child: ProjectTile(
p, p,
onProjectDelete: () => onProjectDelete(context, p), onProjectDelete: () => onProjectDelete(context, p),
onProjectExport: () { onProjectExport: canExport ? () => onProjectExport(context, p) : null,
// TODO: Implement project export onProjectShare: canShare ? () => onProjectShare(context, p) : null,
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Export coming soon...')));
},
onProjectSelect: () => onProjectSelect(context, p), onProjectSelect: () => onProjectSelect(context, p),
), ),
)).toList(growable: false), )).toList(growable: false),
@ -126,10 +186,11 @@ class ProjectTile extends StatelessWidget {
final ProjectEntry project; final ProjectEntry project;
final void Function() onProjectSelect; final void Function() onProjectSelect;
final void Function() onProjectDelete; final void Function() onProjectDelete;
final void Function() onProjectExport; final void Function()? onProjectExport;
final void Function()? onProjectShare;
const ProjectTile(this.project, const ProjectTile(this.project,
{Key? key, required this.onProjectSelect, required this.onProjectDelete, required this.onProjectExport}) {Key? key, required this.onProjectSelect, required this.onProjectDelete, required this.onProjectExport, required this.onProjectShare})
: super(key: key); : super(key: key);
@override @override
@ -158,7 +219,10 @@ class ProjectTile extends StatelessWidget {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.all(2.0), padding: const EdgeInsets.symmetric(
vertical: 2.0,
horizontal: 8.0,
),
child: Text( child: Text(
DateFormat.yMMMd().add_jms().format(project.lastUpdate.toLocal()), DateFormat.yMMMd().add_jms().format(project.lastUpdate.toLocal()),
style: Theme.of(context).textTheme.caption, style: Theme.of(context).textTheme.caption,
@ -173,10 +237,14 @@ class ProjectTile extends StatelessWidget {
child: PopupMenuButton<String>( child: PopupMenuButton<String>(
icon: const Icon(Icons.more_horiz), icon: const Icon(Icons.more_horiz),
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem( if (onProjectExport != null) const PopupMenuItem(
value: 'export', value: 'export',
child: Text('Export'), child: Text('Export'),
), ),
if (onProjectShare != null) const PopupMenuItem(
value: 'share',
child: Text('Share'),
),
const PopupMenuItem( const PopupMenuItem(
value: 'delete', value: 'delete',
child: Text('Delete'), child: Text('Delete'),
@ -188,7 +256,10 @@ class ProjectTile extends StatelessWidget {
onProjectDelete(); onProjectDelete();
break; break;
case 'export': case 'export':
onProjectExport(); onProjectExport?.call();
break;
case 'share':
onProjectShare?.call();
break; break;
default: default:
throw Exception('Unexpected option: $selectedOption'); throw Exception('Unexpected option: $selectedOption');

View file

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:logic_circuits_simulator/models/projects.dart'; import 'package:logic_circuits_simulator/models/projects.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -73,8 +74,116 @@ class ProjectsState extends ChangeNotifier {
index.copyWith( index.copyWith(
projects: index.projects projects: index.projects
.where((p) => p.projectId != project.projectId) .where((p) => p.projectId != project.projectId)
.toList() + [project] .followedBy([project])
.toList()
) )
); );
} }
Future<T> archiveProject<T>(ProjectEntry project, Future<T> Function(Archive archive) callback) async {
final projectsDir = await _getProjectsDir();
// Create dir where export is prepared
final exportDir = Directory(path.join(projectsDir.path, '.export'));
await exportDir.create();
// Write index.json with only that project
final exportIndex = index.copyWith(
projects: index.projects.where((p) => p.projectId == project.projectId).toList(growable: false),
);
final exportIndexFile = File(path.join(exportDir.path, 'index.json'));
await exportIndexFile.writeAsString(jsonEncode(exportIndex));
final exportProjectIdFile = File(path.join(exportDir.path, 'projectId.txt'));
await exportProjectIdFile.writeAsString(project.projectId);
// Copy project folder
final projectDir = Directory(path.join(projectsDir.path, project.projectId));
final exportProjectDir = Directory(path.join(exportDir.path, project.projectId));
await exportProjectDir.create();
await for (final entry in projectDir.list(recursive: true, followLinks: false)) {
final filename = path.relative(entry.path, from: projectDir.path);
if (entry is Directory) {
final newDir = Directory(path.join(exportProjectDir.path, filename));
await newDir.create(recursive: true);
}
else if (entry is File) {
await entry.copy(path.join(exportProjectDir.path, filename));
}
else if (entry is Link) {
final newLink = Link(path.join(exportProjectDir.path, filename));
await newLink.create(await entry.target());
}
}
// Create archive
final archive = createArchiveFromDirectory(exportDir, includeDirName: false);
final result = await callback(archive);
// Remove preparation dir
await exportDir.delete(recursive: true);
return result;
}
Future<ProjectEntry?> importProject({required Archive archive, required Future<bool> Function() onConflictingId, required Future<bool> Function(String name) onConflictingName}) async {
final projectsDir = await _getProjectsDir();
// Create dir where import is prepared
final importDir = Directory(path.join(projectsDir.path, '.import'));
await importDir.create();
extractArchiveToDisk(archive, importDir.path);
final projectIdFile = File(path.join(importDir.path, 'projectId.txt'));
final projectId = (await projectIdFile.readAsString()).trim();
final indexFile = File(path.join(importDir.path, 'index.json'));
final importIndex = ProjectsIndex.fromJson(jsonDecode(await indexFile.readAsString()));
if (index.projects.map((p) => p.projectId).contains(projectId)) {
if (!await onConflictingId()) {
return null;
}
}
final importIndexEntry = importIndex.projects.where((p) => p.projectId == projectId).first;
final importProjectName = importIndexEntry.projectName;
if (index.projects.where((p) => p.projectId != projectId).map((p) => p.projectName).contains(importProjectName)) {
if (!await onConflictingName(importProjectName)) {
return null;
}
}
await _updateIndex(index.copyWith(
projects: index.projects.where((p) => p.projectId != projectId).followedBy([importIndexEntry]).toList(),
));
// Copy project folder
final projectDir = Directory(path.join(projectsDir.path, projectId));
if (await projectDir.exists()) {
await projectDir.delete(recursive: true);
}
await projectDir.create();
final importProjectDir = Directory(path.join(importDir.path, projectId));
await for (final entry in importProjectDir.list(recursive: true, followLinks: false)) {
final filename = path.relative(entry.path, from: importProjectDir.path);
if (entry is Directory) {
final newDir = Directory(path.join(projectDir.path, filename));
await newDir.create(recursive: true);
}
else if (entry is File) {
await entry.copy(path.join(projectDir.path, filename));
}
else if (entry is Link) {
final newLink = Link(path.join(projectDir.path, filename));
await newLink.create(await entry.target());
}
}
await importDir.delete(recursive: true);
return index.projects.where((p) => p.projectId == projectId).first;
}
} }

View file

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
} }

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -15,6 +15,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
archive:
dependency: "direct main"
description:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "3.3.0"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -183,6 +190,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.2" version: "6.1.2"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "4.6.1"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -209,6 +223,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -464,6 +485,48 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.0" version: "1.2.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.8"
share_plus_linux:
dependency: transitive
description:
name: share_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
share_plus_macos:
dependency: transitive
description:
name: share_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.3"
share_plus_web:
dependency: transitive
description:
name: share_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_windows:
dependency: transitive
description:
name: share_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -616,6 +679,62 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
url_launcher:
dependency: transitive
description:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.3"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.17"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.17"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.12"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
uuid: uuid:
dependency: "direct main" dependency: "direct main"
description: description:
@ -667,4 +786,4 @@ packages:
version: "3.1.0" version: "3.1.0"
sdks: sdks:
dart: ">=2.17.0-266.1.beta <3.0.0" dart: ">=2.17.0-266.1.beta <3.0.0"
flutter: ">=2.8.1" flutter: ">=2.10.0"

View file

@ -35,6 +35,9 @@ dependencies:
flutter_hooks: ^0.18.3 flutter_hooks: ^0.18.3
uuid: ^3.0.6 uuid: ^3.0.6
collection: ^1.16.0 collection: ^1.16.0
archive: ^3.3.0
file_picker: ^4.6.1
share_plus: ^4.0.8
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST