Skip to content

Commit 1adc6a0

Browse files
authored
Merge pull request #4 from pvdthings/feature/thing-conversion
Thing Conversion
2 parents 0f42552 + fcc7b75 commit 1adc6a0

File tree

18 files changed

+577
-110
lines changed

18 files changed

+577
-110
lines changed

.github/workflows/deploy-dev.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Deploy
2+
3+
on:
4+
push:
5+
branches:
6+
- dev
7+
8+
jobs:
9+
deploy-api:
10+
name: Deploy API
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v2
14+
- uses: akhileshns/heroku-deploy@v3.13.15
15+
with:
16+
appdir: "apps/api"
17+
heroku_api_key: ${{secrets.HEROKU_API_KEY}}
18+
heroku_app_name: "pvdthings-api-dev"
19+
heroku_email: ${{secrets.HEROKU_EMAIL}}

apps/api/Procfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
web: npm start

apps/api/apps/librarian/routes/inventory.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { fetchItems, fetchItem, createItems, updateItem, deleteItem } = require('../../../services/inventory');
1+
const { fetchItems, fetchItem, createItems, updateItem, deleteItem, convertItem } = require('../../../services/inventory');
22

33
const express = require('express');
44
const router = express.Router();
@@ -44,6 +44,19 @@ router.patch('/:id', async (req, res) => {
4444
}
4545
});
4646

47+
router.post('/:id/convert', async (req, res) => {
48+
const { id } = req.params;
49+
const { thingId } = req.body;
50+
51+
try {
52+
await convertItem(id, thingId);
53+
res.status(204).send();
54+
} catch (error) {
55+
console.error(error);
56+
res.status(error.status || 500).send({ errors: [error] });
57+
}
58+
});
59+
4760
router.delete('/:id', async (req, res) => {
4861
const { id } = req.params;
4962

apps/api/docs/librarian/paths/inventory.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,13 @@ paths:
4545
- $ref: '#/components/parameters/SupabaseRefreshToken'
4646
responses:
4747
'204':
48-
description: Item updated
48+
description: Item updated
49+
/lending/inventory/{id}/convert:
50+
post:
51+
summary: Converts an item into a different type of thing
52+
parameters:
53+
- $ref: '#/components/parameters/SupabaseAccessToken'
54+
- $ref: '#/components/parameters/SupabaseRefreshToken'
55+
responses:
56+
'204':
57+
description: Item converted

apps/api/services/inventory/service.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ const updateItem = async (id, { brand, description, estimatedValue, hidden, cond
9191
await items.update(id, updatedFields);
9292
}
9393

94+
const convertItem = async (id, thingId) => {
95+
await items.update(id, { Thing: [thingId] });
96+
}
97+
9498
const deleteItem = async (id) => {
9599
await items.destroy(id);
96100
}
@@ -100,5 +104,6 @@ module.exports = {
100104
fetchItem,
101105
createItems,
102106
updateItem,
107+
convertItem,
103108
deleteItem
104109
};

apps/librarian/lib/src/api/lending_api.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,15 @@ class LendingApi {
171171
});
172172
}
173173

174+
static Future<Response> convertInventoryItem(
175+
String id,
176+
String thingId,
177+
) async {
178+
return await DioClient.instance.post('/inventory/$id/convert', data: {
179+
'thingId': thingId,
180+
});
181+
}
182+
174183
static Future<Response> deleteInventoryItem(String id) async {
175184
return await DioClient.instance.delete('/inventory/$id');
176185
}

apps/librarian/lib/src/features/dashboard/pages/dashboard_page.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:librarian_app/src/features/authentication/providers/user_tray.da
55
import 'package:librarian_app/src/features/borrowers/widgets/layouts/borrowers_desktop_layout.dart';
66
import 'package:librarian_app/src/features/borrowers/widgets/borrowers_list/searchable_borrowers_list.dart';
77
import 'package:librarian_app/src/features/borrowers/widgets/needs_attention_view.dart';
8+
import 'package:librarian_app/src/features/dashboard/providers/end_drawer_provider.dart';
89
import 'package:librarian_app/src/features/dashboard/widgets/create_menu_item.dart';
910
import 'package:librarian_app/src/features/inventory/providers/things_repository_provider.dart';
1011
import 'package:librarian_app/src/features/inventory/widgets/layouts/inventory_desktop_layout.dart';
@@ -233,6 +234,7 @@ class _DashboardPageState extends ConsumerState<DashboardPage> {
233234
),
234235
)
235236
: null,
237+
endDrawer: ref.watch(endDrawerProvider).drawer,
236238
floatingActionButton: mobile ? menuAnchor : null,
237239
);
238240
},
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
4+
class EndDrawerController {
5+
EndDrawerController(this.ref);
6+
7+
final Ref ref;
8+
Widget? drawer;
9+
10+
openEndDrawer(BuildContext context, Widget drawer) {
11+
this.drawer = drawer;
12+
ref.notifyListeners();
13+
Future.delayed(const Duration(milliseconds: 500), () {
14+
Scaffold.of(context).openEndDrawer();
15+
});
16+
}
17+
}
18+
19+
final endDrawerProvider = Provider((ref) => EndDrawerController(ref));

apps/librarian/lib/src/features/inventory/data/inventory_repository.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ class InventoryRepository extends Notifier<Future<List<ThingModel>>> {
175175
ref.invalidateSelf();
176176
}
177177

178+
Future<void> convertItem(String id, String thingId) async {
179+
await LendingApi.convertInventoryItem(id, thingId);
180+
ref.invalidateSelf();
181+
}
182+
178183
Future<void> deleteItem(String id) async {
179184
await LendingApi.deleteInventoryItem(id);
180185
ref.invalidateSelf();

apps/librarian/lib/src/features/inventory/pages/item_details_page.dart

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,24 @@ class ItemDetailsPage extends ConsumerStatefulWidget {
2222

2323
class _ItemDetailsPageState extends ConsumerState<ItemDetailsPage> {
2424
late final _controller = ItemDetailsController(
25-
item: widget.item,
26-
repository: ref.read(thingsRepositoryProvider.notifier));
25+
item: widget.item,
26+
repository: ref.read(thingsRepositoryProvider.notifier),
27+
);
2728

2829
@override
2930
Widget build(BuildContext context) {
3031
return Scaffold(
3132
appBar: AppBar(
32-
title: Text('#${widget.item.number} ${widget.item.name}'),
33+
title: Text('#${widget.item.number}'),
3334
),
34-
body: Padding(
35-
padding: const EdgeInsets.all(16.0),
36-
child: ItemDetails(
37-
controller: _controller,
38-
item: widget.item,
39-
hiddenLocked: widget.hiddenLocked,
35+
body: SingleChildScrollView(
36+
child: Padding(
37+
padding: const EdgeInsets.all(16.0),
38+
child: ItemDetails(
39+
controller: _controller,
40+
item: widget.item,
41+
hiddenLocked: widget.hiddenLocked,
42+
),
4043
),
4144
),
4245
floatingActionButton: ListenableBuilder(
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_riverpod/flutter_riverpod.dart';
5+
import 'package:librarian_app/src/features/inventory/providers/things_repository_provider.dart';
6+
import 'package:librarian_app/src/features/inventory/widgets/conversion/icon.dart';
7+
8+
import '../../models/thing_model.dart';
9+
10+
class ConvertDialog extends ConsumerStatefulWidget {
11+
const ConvertDialog({super.key, required this.itemId});
12+
13+
final String itemId;
14+
15+
@override
16+
ConsumerState<ConvertDialog> createState() => _ConvertDialogState();
17+
}
18+
19+
class _ConvertDialogState extends ConsumerState<ConvertDialog> {
20+
final selectedThingId = ValueNotifier<String?>(null);
21+
22+
@override
23+
Widget build(BuildContext context) {
24+
return AlertDialog(
25+
icon: const Icon(convertIcon),
26+
title: const Text('Convert Item'),
27+
content: SizedBox(
28+
width: 500,
29+
child: Column(
30+
mainAxisSize: MainAxisSize.min,
31+
children: [
32+
const Text('Choose a thing to convert to, then click Convert.'),
33+
const SizedBox(height: 16),
34+
FutureBuilder(
35+
future: ref.read(thingsRepositoryProvider),
36+
builder: (context, snapshot) {
37+
final List<ThingModel> thingOptions =
38+
snapshot.connectionState != ConnectionState.done
39+
? []
40+
: snapshot.data!;
41+
42+
return _Autocomplete(
43+
optionsBuilder: (value) {
44+
if (value.text.isEmpty) {
45+
return const Iterable.empty();
46+
}
47+
48+
return thingOptions.where((o) => o.name
49+
.toLowerCase()
50+
.contains(value.text.toLowerCase()));
51+
},
52+
onChanged: (_) {
53+
selectedThingId.value = null;
54+
},
55+
onSelected: (value) {
56+
selectedThingId.value = value.id;
57+
},
58+
);
59+
},
60+
),
61+
],
62+
),
63+
),
64+
actions: [
65+
OutlinedButton(
66+
onPressed: () => Navigator.of(context).pop(false),
67+
child: const Text('Cancel'),
68+
),
69+
ListenableBuilder(
70+
listenable: selectedThingId,
71+
builder: (context, child) {
72+
return FilledButton(
73+
onPressed: selectedThingId.value == null
74+
? null
75+
: () {
76+
ref
77+
.read(thingsRepositoryProvider.notifier)
78+
.convertItem(widget.itemId, selectedThingId.value!)
79+
.then((_) => Navigator.of(context).pop(true));
80+
},
81+
child: const Text('Convert'),
82+
);
83+
},
84+
),
85+
],
86+
);
87+
}
88+
}
89+
90+
class _Autocomplete extends StatelessWidget {
91+
const _Autocomplete({
92+
required this.optionsBuilder,
93+
required this.onChanged,
94+
required this.onSelected,
95+
});
96+
97+
final FutureOr<Iterable<ThingModel>> Function(TextEditingValue)
98+
optionsBuilder;
99+
100+
final void Function(ThingModel value)? onSelected;
101+
final void Function(String value)? onChanged;
102+
103+
@override
104+
Widget build(BuildContext context) {
105+
return LayoutBuilder(
106+
builder: (_, BoxConstraints constraints) => Autocomplete<ThingModel>(
107+
displayStringForOption: (option) => option.name,
108+
fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) {
109+
return TextField(
110+
controller: controller,
111+
decoration: const InputDecoration(
112+
hintText: 'Search...',
113+
labelText: 'Thing',
114+
),
115+
focusNode: focusNode,
116+
onChanged: onChanged,
117+
);
118+
},
119+
optionsBuilder: optionsBuilder,
120+
optionsViewBuilder: (_, onSelected, options) {
121+
return Align(
122+
alignment: Alignment.topLeft,
123+
child: Material(
124+
elevation: 4,
125+
child: SizedBox(
126+
width: constraints.maxWidth,
127+
child: ListView.builder(
128+
shrinkWrap: true,
129+
itemCount: options.length,
130+
itemBuilder: (context, index) {
131+
final element = options.elementAt(index);
132+
return ListTile(
133+
tileColor:
134+
AutocompleteHighlightedOption.of(context) == index
135+
? Theme.of(context).highlightColor
136+
: null,
137+
onTap: () => onSelected(element),
138+
title: Text(element.name),
139+
);
140+
},
141+
),
142+
),
143+
),
144+
);
145+
},
146+
onSelected: onSelected,
147+
),
148+
);
149+
}
150+
}
151+
152+
Future<bool> showConvertDialog(BuildContext context, String itemId) async {
153+
final result = await showDialog<bool?>(
154+
context: context, builder: (context) => ConvertDialog(itemId: itemId));
155+
return result ?? false;
156+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import 'package:flutter/material.dart';
2+
3+
const convertIcon = Icons.transform;

apps/librarian/lib/src/features/inventory/widgets/inventory_details/inventory_details.dart

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import 'package:file_picker/_internal/file_picker_web.dart';
22
import 'package:file_picker/file_picker.dart';
33
import 'package:flutter/material.dart';
44
import 'package:flutter_riverpod/flutter_riverpod.dart';
5+
import 'package:librarian_app/src/features/dashboard/providers/end_drawer_provider.dart';
56
import 'package:librarian_app/src/features/inventory/widgets/inventory_details/items/create_items/create_items_dialog.dart';
7+
import 'package:librarian_app/src/features/inventory/widgets/item_details_drawer/drawer.dart';
68
import 'package:librarian_app/src/widgets/fields/checkbox_field.dart';
79
import 'package:librarian_app/src/widgets/input_decoration.dart';
810
import 'package:librarian_app/src/features/inventory/models/updated_image_model.dart';
@@ -12,11 +14,12 @@ import 'package:librarian_app/src/features/inventory/providers/selected_thing_pr
1214
import 'package:librarian_app/src/features/inventory/providers/thing_details_provider.dart';
1315
import 'package:librarian_app/src/features/inventory/providers/things_repository_provider.dart';
1416
import 'package:librarian_app/src/features/inventory/widgets/inventory_details/categories_card.dart';
15-
import 'package:librarian_app/src/features/inventory/widgets/inventory_details/items/item_details/item_details_dialog.dart';
1617
import 'package:librarian_app/src/features/inventory/widgets/inventory_details/items/items_card.dart';
1718
import 'package:librarian_app/src/features/inventory/widgets/inventory_details/thing_image_card/thing_image_card.dart';
1819
import 'package:librarian_app/src/utils/media_query.dart';
1920

21+
import 'items/item_details/item_details_controller.dart';
22+
2023
class InventoryDetails extends ConsumerWidget {
2124
const InventoryDetails({super.key});
2225

@@ -122,15 +125,22 @@ class InventoryDetails extends ConsumerWidget {
122125
return;
123126
}
124127

125-
showDialog(
126-
context: context,
127-
builder: (context) {
128-
return ItemDetailsDialog(
129-
item: item,
130-
hiddenLocked: details.hidden,
131-
);
128+
final detailsController = ItemDetailsController(
129+
item: item,
130+
repository: ref.read(thingsRepositoryProvider.notifier),
131+
onSave: () {
132+
// setState(() => _isLoading = true);
133+
},
134+
onSaveComplete: () {
135+
// setState(() => _isLoading = false);
132136
},
133137
);
138+
139+
ref.read(endDrawerProvider).openEndDrawer(
140+
context,
141+
ItemDetailsDrawer(
142+
controller: detailsController,
143+
));
134144
},
135145
onAddItemsPressed: () {
136146
showDialog(

0 commit comments

Comments
 (0)