Compare commits

647 Commits

Author SHA1 Message Date
061eccd87f fill gaps in env templates 2026-04-21 18:44:11 -06:00
68055ba720 document backend switching 2026-04-21 18:41:24 -06:00
e0505cbaaa accept email on login 2026-04-21 17:48:10 -06:00
8786cae3f8 fix product search matching descriptions 2026-04-21 12:32:00 -06:00
489543ffca fix tester-reported bugs 2026-04-21 11:09:18 -06:00
6631ad8e1f Fix seed product names 2026-04-20 22:22:15 -06:00
b4cef51a91 add web author headers 2026-04-20 22:01:45 -06:00
46c59336f4 Revert "add web file headers"
This reverts commit c4086c8072.
2026-04-20 22:01:15 -06:00
c4086c8072 add web file headers 2026-04-20 22:00:29 -06:00
97acb7e17f comment android app 2026-04-20 21:52:02 -06:00
329053bfb9 Merge pull request #338 from RecentRunner/final
merge final branch
2026-04-20 21:33:19 -06:00
Nikitha
d0f8445848 Updated PetDetailModelView
Added Comments
2026-04-20 20:48:37 -06:00
18016888ce clarify activity log checkbox 2026-04-20 20:26:47 -06:00
bbedb8af8c remove staff role from UI 2026-04-20 20:20:46 -06:00
f90cc4eb9b Merge pull request #337 from RecentRunner/web-lastfixes
web last fixes
2026-04-20 19:31:55 -06:00
d2f6a5fe82 Merge pull request #336 from RecentRunner/commenting-backend-desktop
comment backend and desktop
2026-04-20 19:19:40 -06:00
augmentedpotato
2cb0a94bbb Comments, appointments adjustments, fixed some issues 2026-04-20 19:19:30 -06:00
80ee62fb24 comment desktop models and utils 2026-04-20 17:51:03 -06:00
ca4375379e comment desktop api layer 2026-04-20 17:06:27 -06:00
049e142845 comment desktop controllers 2026-04-20 16:22:39 -06:00
3a155b6c03 comment backend events and utils 2026-04-20 15:43:11 -06:00
0f35da597f comment backend DTOs 2026-04-20 15:18:54 -06:00
447b5fc346 comment backend entities and repos 2026-04-20 14:24:33 -06:00
24e3d5ab80 comment backend controllers 2026-04-20 13:47:08 -06:00
3c6382318b add service file headers 2026-04-20 13:03:21 -06:00
e26239ae85 add service file headers 2026-04-20 12:38:42 -06:00
9c94ba41fb comment security and config 2026-04-20 12:02:17 -06:00
b6f8131b2e delayed refresh after CRUD 2026-04-20 11:24:43 -06:00
bbbfaaa31d Merge pull request #335 from RecentRunner/web-lastfixes
web last fixes
2026-04-20 11:23:59 -06:00
b4c9940013 add XSS content filter to DTOs 2026-04-20 10:45:45 -06:00
ea73ab687b auto-refresh product list 2026-04-20 10:41:49 -06:00
dd9ea67d90 validate pet price and species 2026-04-20 10:26:01 -06:00
43a298723a evict cache on logout 2026-04-20 10:10:49 -06:00
789c1c3172 fix remaining staff roles 2026-04-20 09:48:53 -06:00
639da61802 title-case all DB strings 2026-04-20 09:42:21 -06:00
7977380c16 auto-complete scheduled appointments 2026-04-20 08:24:28 -06:00
c346c9036f catch sort query exceptions 2026-04-20 08:15:59 -06:00
ef7384515d handle missing exception types 2026-04-20 08:02:45 -06:00
ecbcab8e31 fix store imageUrl length 2026-04-20 06:28:08 -06:00
9353749899 drop status from seed data 2026-04-20 06:16:56 -06:00
8c0c705dd8 idempotent schema indexes 2026-04-20 06:02:05 -06:00
164f738776 fix flyway baseline config 2026-04-20 05:53:49 -06:00
6e1dfdb79f add flyway baseline config 2026-04-20 05:45:10 -06:00
augmentedpotato
d3b9c51952 Minor tweaks, changed checkout UI 2026-04-20 05:41:36 -06:00
0f937f12f5 clean flyway config 2026-04-20 05:36:52 -06:00
88e4718f2e fix HQL pet query 2026-04-20 05:10:13 -06:00
Alex
0f5ac1f1f4 Merge branch 'AttachmentsToChat' 2026-04-19 20:19:57 -06:00
Alex
8aeccb0cab added comments to android 2026-04-19 20:18:28 -06:00
446d1bb7ae configurable rate limiter 2026-04-19 19:05:07 -06:00
ebb9b77025 add client run instructions 2026-04-19 19:04:13 -06:00
75651a6bd3 add project README 2026-04-19 18:56:58 -06:00
e409e4684d fix profile placeholder flash 2026-04-19 18:44:08 -06:00
ce0a79196c fix validation bugs 2026-04-19 18:26:16 -06:00
f5ebab5d01 fix seed data gaps 2026-04-19 17:57:22 -06:00
58a48215da flatten flyway migrations 2026-04-19 17:42:30 -06:00
11618f0cfd fix read state tracking 2026-04-19 17:33:48 -06:00
002f58f24e force logout on 401 2026-04-19 17:12:29 -06:00
augmentedpotato
a717837bd4 Replaced emoticons 2026-04-19 13:33:52 -06:00
augmentedpotato
a0eb6aa1f9 Plethora of changes 2026-04-19 10:33:17 -06:00
augmentedpotato
357fac2d56 Mobile UI for ai chat, fixed backend issue 2026-04-19 08:25:14 -06:00
d30f4b7add fix closed conversation messages 2026-04-18 20:36:46 -06:00
07c3219bec show store selector always 2026-04-18 16:36:15 -06:00
da74491d55 Merge pull request #326 from RecentRunner/web-refactor
web styling refactor
2026-04-18 16:23:00 -06:00
augmentedpotato
077d147498 Styling refactor 2026-04-18 16:22:38 -06:00
049c39984b Merge pull request #325 from RecentRunner/fix/postman-and-clients
fix postman and clients
2026-04-18 16:19:22 -06:00
4f7eeb9def fix cross-client consistency 2026-04-18 15:45:00 -06:00
3cdcc95656 fix postman collection 2026-04-18 14:15:00 -06:00
dc0d929eaf Merge pull request #324 from RecentRunner/refactor/backend-cleanup
backend DRY/KISS cleanup
2026-04-18 08:23:37 -06:00
f5430a1940 fix compatibility regressions 2026-04-17 18:52:00 -06:00
e2b9ae6e0c extract image delete to storage 2026-04-17 18:24:00 -06:00
99768ec9b9 add read-only transactional annotations 2026-04-17 17:58:00 -06:00
1ce390e528 simplify controllers and utilities 2026-04-17 17:35:00 -06:00
4d96d1961c inject AuthenticationHelper bean 2026-04-17 17:08:00 -06:00
18030d5d2e standardize CRUD services 2026-04-17 16:40:00 -06:00
4f73da1218 centralize StringUtils usage 2026-04-17 16:15:00 -06:00
ee0a643636 clean remaining code smells 2026-04-17 15:48:00 -06:00
287e71f2a9 fix review findings 2026-04-17 15:12:00 -06:00
d198fb3d42 externalize business constants 2026-04-17 14:43:00 -06:00
80df6116ab consolidate shared constants 2026-04-17 14:18:00 -06:00
f7c1ff453f use shared StringUtils.trimToNull 2026-04-17 13:54:00 -06:00
4162e34a5f unify error handling 2026-04-17 13:31:00 -06:00
46bdd5c3d7 fix tests and silent failures 2026-04-17 13:02:00 -06:00
a6056c11e4 fix appointment cancellation 2026-04-16 09:45:21 -06:00
be4932661d hide chat history button 2026-04-16 09:05:28 -06:00
87233a2240 fix navbar clipping 2026-04-16 08:57:24 -06:00
3c4ec9cac9 fix chat session leak 2026-04-16 08:40:38 -06:00
a008b18838 redeploy 2026-04-16 08:25:08 -06:00
c4d5c44a44 Merge pull request #322 from RecentRunner/android-desktop-parity
fix six app bugs
2026-04-16 08:13:06 -06:00
50f55ce8c0 merge main into branch 2026-04-16 08:12:46 -06:00
d26c963ee8 normalize pet status casing 2026-04-16 07:58:46 -06:00
2c9dedb65e guard stale init effects 2026-04-16 07:56:57 -06:00
7b4874b8b1 fix six app bugs 2026-04-16 07:55:13 -06:00
eda4549c36 Add V9 sales seed 2026-04-16 07:23:04 -06:00
4ca57c92f1 ai greets first with full context 2026-04-16 00:49:58 -06:00
03abba0679 add order history to profile 2026-04-16 00:38:10 -06:00
cd824dcc63 Seed activity logs, fix role filter 2026-04-16 00:34:13 -06:00
8d8ba41edf fix chat scroll behaviour 2026-04-16 00:25:32 -06:00
c09309a47a fix chat ux and ai model 2026-04-16 00:11:08 -06:00
86a1b0f72c fix contact form and appt ui 2026-04-15 23:37:16 -06:00
4f6807b28f Merge pull request #320 from RecentRunner/fix-desktop-launch
Desktop fixes
2026-04-15 23:21:28 -06:00
4e5f221749 restore activity logs endpoint 2026-04-15 23:19:22 -06:00
95b45c2e54 fix contact layout and chat ui 2026-04-15 23:12:23 -06:00
4402d0398f fix chat badge on reply 2026-04-15 23:11:53 -06:00
1972488eb0 pet owner search 2026-04-15 23:03:54 -06:00
73c4bc6cc7 fix about section spacing and text 2026-04-15 22:59:18 -06:00
006023e289 fix adopt search mobile layout 2026-04-15 22:51:39 -06:00
cd7ef12085 fix chat escalation and sidebar 2026-04-15 22:46:49 -06:00
52404422bd fix images and empty space 2026-04-15 22:37:51 -06:00
690c35415b fix nav and pagination 2026-04-15 22:30:30 -06:00
b711635afa fix layout and demo payment 2026-04-15 22:22:18 -06:00
795adacb57 mobile layout fixes (#319) 2026-04-15 22:01:49 -06:00
adcb695d85 fix mobile nav and env examples 2026-04-15 21:34:15 -06:00
b60db151cb fix stripe publishable key 2026-04-15 21:21:37 -06:00
f4cc5d4641 Merge pull request #318 from RecentRunner/fix-customer-ws-subscription
fix customer ws subscription
2026-04-15 21:19:58 -06:00
b08d1d29ae fix customer ws subscription 2026-04-15 20:57:50 -06:00
51829dd833 fix CORS for production 2026-04-15 18:39:00 -06:00
653560ee31 fix websocket backend url 2026-04-15 18:32:38 -06:00
b5a798adcc disable server compression 2026-04-15 18:20:18 -06:00
e7d5765ae1 remove incompatible jackson config 2026-04-15 18:12:16 -06:00
6b11de24b8 fix JPQL pet query field 2026-04-15 18:01:23 -06:00
5e5c79faff fix duplicate spring key 2026-04-15 17:25:31 -06:00
89e6e05e8e replace chat polling with websocket 2026-04-15 17:04:23 -06:00
b2f3bc117d fix UserServiceTest constructor 2026-04-15 16:33:14 -06:00
892d0394af force revision on deploy 2026-04-15 16:31:22 -06:00
726e1ee52d fix yaml and swagger defaults 2026-04-15 16:31:22 -06:00
f50928fef1 perf: azure deployment optimizations 2026-04-15 16:31:22 -06:00
e87bb7bebf species service validation (#317)
* fix species-service validation

* add grooming for hamster, other

* expand reptile and other services
2026-04-15 16:25:14 -06:00
e23f9f9318 hardcode stripe key 2026-04-15 16:24:58 -06:00
feeaed6244 exclude next cache from build 2026-04-15 16:21:27 -06:00
f68559d028 fix stripe key 2026-04-15 16:17:51 -06:00
ee94703773 Merge pull request #316 from RecentRunner/android-desktop-parity
add pet image support
2026-04-15 16:16:14 -06:00
f61a765624 add pet image support 2026-04-15 16:13:50 -06:00
c9904b18a1 fix stripe key 2026-04-15 16:10:38 -06:00
7154a4a7ba fix logo LCP 2026-04-15 16:09:07 -06:00
175fead68a fix nav and color theme 2026-04-15 16:09:07 -06:00
812f197b75 Merge pull request #314 from RecentRunner/bug-fixes
Backend bug fixes
2026-04-15 16:08:23 -06:00
8a9e018031 avatar on staff register (#315) 2026-04-15 16:06:17 -06:00
285da7df05 decouple emails from transactions 2026-04-15 16:03:10 -06:00
9a4039680e Merge pull request #313 from RecentRunner/worktree-fix-refund-idempotency
lock all stateful mutations
2026-04-15 16:02:37 -06:00
186eb36c7e lock all stateful mutations 2026-04-15 16:01:32 -06:00
2077c1b10c user avatar in edit dialogs (#312) 2026-04-15 15:58:46 -06:00
52e333b31c fix validation and 500 bugs 2026-04-15 15:58:34 -06:00
c24d49fa5b fix auth and logic bugs 2026-04-15 15:54:46 -06:00
df5510224b Merge branch 'loyalty-points' 2026-04-15 15:52:13 -06:00
3a38affdff rebuild stripe key 2026-04-15 15:51:06 -06:00
748a4ff866 fix loyalty points display 2026-04-15 15:49:47 -06:00
d7179665d9 lock refunds against duplicates 2026-04-15 15:48:17 -06:00
1ab4df0b81 fix sale and adoption bugs 2026-04-15 15:46:46 -06:00
06da74e193 center navbar links (#311) 2026-04-15 15:44:45 -06:00
59a83e81de Merge pull request #310 from RecentRunner/worktree-fix-cart-sessions
restore cart across devices
2026-04-15 15:43:42 -06:00
7730c0f80a rebuild stripe key 2026-04-15 15:42:59 -06:00
f8f2a4b05e restore cart across devices 2026-04-15 15:42:55 -06:00
5ff5995bcf rebuild stripe key 2026-04-15 15:38:28 -06:00
9c1cbb0ed8 fix stripe payment intent 2026-04-15 15:31:52 -06:00
e975d1d2b0 rebuild frontend 2026-04-15 15:10:22 -06:00
1bea2f808d disable validate on migrate 2026-04-15 14:58:19 -06:00
49685a75f7 fix flyway failed migration 2026-04-15 14:52:05 -06:00
0bfd709f1f update actions node 24 2026-04-15 14:24:39 -06:00
362da2fc06 fix test compilation 2026-04-15 14:20:47 -06:00
77b697ac83 fix coupon analytics 2026-04-15 14:10:24 -06:00
405fa60d61 ignore log files 2026-04-15 14:10:24 -06:00
045701d848 remove log artifacts 2026-04-15 14:10:24 -06:00
3b17f47efe activity logs to files 2026-04-15 14:10:24 -06:00
7926df8987 trim seed data 2026-04-15 14:10:14 -06:00
92660414c9 add seed data 2026-04-15 14:10:14 -06:00
f226e335c2 fix scroll sorting 2026-04-15 14:10:14 -06:00
89fb7554e0 fix deploy commands 2026-04-15 12:40:15 -06:00
076c36bc85 fix CI azure login 2026-04-15 12:36:07 -06:00
08cdb941a4 Merge pull request #309 from RecentRunner/websitefinal
merge websitefinal
2026-04-15 12:26:47 -06:00
d723f3e3cc merge main into websitefinal 2026-04-15 12:26:31 -06:00
15e08834a0 Merge pull request #308 from RecentRunner/azure-deploy
merge azure-deploy
2026-04-15 12:25:46 -06:00
db8499e18d point android app at azure backend 2026-04-15 08:13:53 -06:00
Nikitha
8e1ab89e6c Chat Widget, changes in nav bar
Auto Scroll chat and changes in Nav bar
2026-04-15 08:12:16 -06:00
6046d8720f point desktop app at azure backend 2026-04-15 08:10:17 -06:00
fc0712cc78 fix chat scroll jumps and move about us to home page 2026-04-15 08:07:17 -06:00
3d0e05daf2 fix proxy origin header and cors allowed origins config 2026-04-15 07:37:43 -06:00
839963049d Merge pull request #307 from RecentRunner/web-v2
merge web-v2
2026-04-15 07:07:34 -06:00
ecea09afc6 Merge pull request #306 from RecentRunner/fix-appointment-history
fix appointment history
2026-04-15 07:04:46 -06:00
d40904b5cd fix appointment history 2026-04-15 07:04:22 -06:00
b782e4e62b Merge pull request #305 from RecentRunner/web-fixes
fix appointments and pagination
2026-04-15 06:53:09 -06:00
fb2b070e32 fix appointments and pagination 2026-04-15 06:52:49 -06:00
augmentedpotato
85653af39e Small corrections 2026-04-15 06:39:41 -06:00
augmentedpotato
cdcc50e93a Minor change to appointments page 2026-04-15 02:49:26 -06:00
augmentedpotato
914e0bceda Points now subtract from costs 2026-04-15 02:44:14 -06:00
Alex
7ad35bd2dc Merge branch 'AttachmentsToChat' 2026-04-15 02:15:16 -06:00
Alex
1163961b90 fixed product supplier android 2026-04-15 02:14:20 -06:00
Alex
a2c8df16b7 added filtering to activity logs for desktop 2026-04-15 02:10:20 -06:00
Alex
a23171359f turned logs to laymen terms and added to android 2026-04-15 01:52:35 -06:00
404f162728 Merge pull request #304 from RecentRunner/web-coupons
web coupons
2026-04-15 01:39:43 -06:00
bd7368f0cf Merge pull request #303 from RecentRunner/chat-ui-updates
chat UI updates
2026-04-15 01:37:52 -06:00
fb7d71c86e unify ai and live chat 2026-04-15 01:35:54 -06:00
augmentedpotato
2c1871b6e2 loyalty points 2026-04-15 01:34:49 -06:00
f22a187148 always show chat sidebar 2026-04-15 01:31:29 -06:00
28ccf8b96d Merge pull request #302 from RecentRunner/web-features
fix web chat features
2026-04-15 01:25:47 -06:00
377439495c fix web chat features 2026-04-15 01:25:28 -06:00
Alex
c8a1c29cc3 removed status on purchase order android 2026-04-15 01:09:56 -06:00
3214d3893b fix chat scroll and button 2026-04-15 01:09:09 -06:00
5817f82b77 fix contact form style 2026-04-15 00:56:24 -06:00
ad63a314c9 add store images 2026-04-15 00:55:08 -06:00
augmentedpotato
1636ba14b6 Coupon system working properly 2026-04-15 00:54:46 -06:00
0315920b0d Merge pull request #301 from RecentRunner/web-fixes
web fixes
2026-04-15 00:49:04 -06:00
Alex
4643feb868 added pagenation to android for each fragment 2026-04-15 00:46:32 -06:00
a3eff2e738 contact form with email 2026-04-15 00:46:32 -06:00
2e13c0cea0 web issue fixes 2026-04-15 00:44:07 -06:00
023fdf5ee9 web fixes 2026-04-15 00:38:04 -06:00
7d3cc51c8f Merge branch 'main' of github.com:RecentRunner/group-2-threaded-project-petshop 2026-04-15 00:27:49 -06:00
fe8fdafdd8 Merge pull request #300 from RecentRunner/web-pwreset
web password reset
2026-04-15 00:27:40 -06:00
Alex
024a618473 fixed issue for desktop when sending a file too large 2026-04-15 00:27:06 -06:00
f194d7ca78 Merge branch 'main' of github.com:RecentRunner/group-2-threaded-project-petshop 2026-04-15 00:18:43 -06:00
augmentedpotato
755fa092c2 password reset 2026-04-15 00:17:26 -06:00
8cf12d32d3 merge azure-deploy 2026-04-15 00:14:09 -06:00
Alex
017ef65b5a desktop chat now shows images 2026-04-15 00:00:17 -06:00
934a857cdf document BACKEND_URL swap 2026-04-14 23:46:38 -06:00
6c4fc20870 add desktop backend config 2026-04-14 23:46:21 -06:00
Alex
be697f080e Merge branch 'AttachmentsToChat' 2026-04-14 23:43:39 -06:00
Alex
6235f96def added time stamp and sender name to android chat 2026-04-14 23:42:23 -06:00
Alex
4f6a6f71ed fixed chat messaging on same account with different devices 2026-04-14 23:34:07 -06:00
de37b27fcc Merge remote-tracking branch 'origin/main' into azure-deploy 2026-04-14 23:32:16 -06:00
0b244938cf Merge pull request #299 from RecentRunner/web-v1
Merge web-v1 into main
2026-04-14 23:29:51 -06:00
9cc2da6b92 merge main 2026-04-14 23:29:46 -06:00
Alex
08c8e54b2d Merge branch 'AttachmentsToChat' 2026-04-14 23:20:16 -06:00
3ee259abfd lazy init 2026-04-14 23:19:34 -06:00
Alex
42a4bcd104 made admin analyics able to select store 2026-04-14 23:17:12 -06:00
Alex
ec0d2d1ec7 added personal and store analytics 2026-04-14 23:10:03 -06:00
6aac0c6366 Merge remote-tracking branch 'origin/main' into azure-deploy 2026-04-14 23:06:48 -06:00
1a51f16ee0 show error details 2026-04-14 22:56:54 -06:00
Alex
7340a5616e Changed android phone validation 2026-04-14 22:53:42 -06:00
dea1caafd6 runtime backend proxy 2026-04-14 22:52:01 -06:00
d35c820967 use middleware for runtime backend proxy 2026-04-14 22:48:00 -06:00
Alex
9c47f5ac76 added staff and customer images to desktop 2026-04-14 22:43:24 -06:00
Alex
aca52efc44 maade it so sales display points earned 2026-04-14 22:33:10 -06:00
Alex
6848ab3586 implemented forget password for desktop 2026-04-14 22:15:15 -06:00
Alex
b3547b2971 fixed chat loading issue andriod 2026-04-14 22:08:14 -06:00
Nikitha
c5c5461167 Chat Saving
saving chat history
2026-04-14 22:02:05 -06:00
5b88ee242e fix lowercase image name in workflow 2026-04-14 21:37:16 -06:00
06a6eeff9c trigger CI on azure-deploy branch 2026-04-14 21:35:36 -06:00
9cafc305fd Azure deployment setup 2026-04-14 21:29:00 -06:00
Alex
6382c87d67 Merge branch 'AttachmentsToChat' 2026-04-14 21:15:19 -06:00
Alex
44b7fcbba2 added forget password 2026-04-14 21:13:57 -06:00
77d106cb06 Merge pull request #296 from RecentRunner/easy-fixes
Logs folder and activity log defaults
2026-04-14 20:50:14 -06:00
f8cf68eb1a Logs folder and activity log date default 2026-04-14 20:50:03 -06:00
67d3cb2c7f Merge pull request #280 from RecentRunner/chat-fixes
Fix chat attachments and avatars
2026-04-14 20:26:03 -06:00
8127b539e8 Fix chat attachments and avatars 2026-04-14 20:25:54 -06:00
ea1237942d Merge pull request #278 from RecentRunner/desktop-backend-fixes
Activity log, staff role, chat
2026-04-14 20:17:21 -06:00
9d6d7d885d Activity log filters, staff role, chat fix 2026-04-14 20:17:03 -06:00
86061e2733 Merge pull request #275 from RecentRunner/backend-fixes
Backend bug fixes
2026-04-14 20:03:32 -06:00
923d323808 Block chat injection 2026-04-14 20:03:11 -06:00
933bd6b7fd Add species filtering 2026-04-14 20:03:11 -06:00
0c63963ddf Drop status column 2026-04-14 20:03:11 -06:00
51a48e07eb Fix stuck pet status 2026-04-14 20:03:11 -06:00
3a7621678d Fix backend issues 2026-04-14 20:03:11 -06:00
0a8b96bc3b Merge pull request #274 from RecentRunner/desktop-notifications
desktop chat notifications
2026-04-14 19:59:57 -06:00
d3e203b575 add desktop chat notifications 2026-04-14 19:59:42 -06:00
02768f940a fix backend issues 2026-04-14 19:59:39 -06:00
711c018014 Merge pull request #268 from RecentRunner/fix/admin-account-guard
Harden admin guards
2026-04-14 16:10:10 -06:00
758b44d6d6 Harden admin guards 2026-04-14 16:09:39 -06:00
5964528524 use openrouter/free model 2026-04-14 15:46:42 -06:00
7c3e34e83d Merge pull request #267 from RecentRunner/nullable-appointment-pet
nullable appointment pet
2026-04-14 15:41:18 -06:00
060ecf1ef2 nullable petId in appointment 2026-04-14 15:39:30 -06:00
f0ce6d8c2d Merge pull request #266 from RecentRunner/resend-email
resend email
2026-04-14 15:23:49 -06:00
52b97f435a add rate limiting 2026-04-14 15:23:26 -06:00
39312c8698 add email flows 2026-04-14 15:23:07 -06:00
augmentedpotato
917d318566 Profile image works, editing profile works, now uses first/last name 2026-04-14 15:02:57 -06:00
augmentedpotato
fbcab5d097 Age input when editing/adding pet profile 2026-04-14 13:56:21 -06:00
augmentedpotato
113c8c61be Navbar fixed 2026-04-14 13:44:13 -06:00
augmentedpotato
a4a4831615 Removed addresses, adjusted contact page 2026-04-14 13:31:56 -06:00
augmentedpotato
c2f39c40f0 Fixes for appointments and My Pets fields. 2026-04-14 12:20:48 -06:00
67aadfa66f Updated the example environment file 2026-04-14 10:02:02 -06:00
augmentedpotato
208372c782 Favicon updated 2026-04-14 07:35:06 -06:00
augmentedpotato
1f389ca25c Pet adoption appointments (currently has a small issue) 2026-04-14 07:14:54 -06:00
ceafee2a80 Merge pull request #254 from RecentRunner/web-v1
Web v1
2026-04-14 07:06:21 -06:00
augmentedpotato
7303c22cd3 Chat now present in the bottom right. 2026-04-14 05:59:33 -06:00
augmentedpotato
7bddd74a6e Cart fixes (backend), adjusted header, added footer, mobile formatting updates 2026-04-14 05:24:40 -06:00
Alex
f3fc93d6e5 added filter to analytics desktop 2026-04-14 04:39:38 -06:00
Alex
eefb8de460 added filters to desktop 2026-04-14 04:29:28 -06:00
Alex
f623c17071 added filter by customer for sales to backend and android 2026-04-14 04:03:15 -06:00
Alex
4594139f8e added clendar to adoptions and appointments on desktop 2026-04-14 03:48:46 -06:00
Alex
a860a1c247 updated sales on desktop, and fixed sales with points again on back end 2026-04-14 03:32:29 -06:00
Alex
a3fcebfa15 Merge branch 'main' into AttachmentsToChat 2026-04-14 01:10:14 -06:00
Alex
6c4b3ef120 Merge branch 'main' into AttachmentsToChat 2026-04-14 00:57:16 -06:00
Alex
d4958ec914 fixes to desktop part 1 2026-04-14 00:55:51 -06:00
8e205ebca2 add webp support desktop 2026-04-14 00:17:43 -06:00
cf274f9013 consolidate migrations 2026-04-14 00:11:13 -06:00
3c4743fa70 fix image paths 2026-04-14 00:11:07 -06:00
168ebe94fc add cart points fields 2026-04-14 00:11:02 -06:00
Alex
94344b146f Merge branch 'AttachmentsToChat' 2026-04-13 22:36:53 -06:00
Alex
d898732a17 seperated staff and customer on desktop 2026-04-13 22:15:41 -06:00
2757bc66da Merge pull request #253 from RecentRunner/images
seed images
2026-04-13 21:58:01 -06:00
b115a4b66c localize seed image URLs to upload paths 2026-04-13 21:57:29 -06:00
Alex
c38bb24e94 fixed phone validation desktop 2026-04-13 21:52:53 -06:00
Alex
98584f1324 added dropdowns for breed desktop 2026-04-13 21:40:34 -06:00
Alex
3ef6604884 fixed pet busniss logic desktop 2026-04-13 21:10:35 -06:00
Alex
044e9ba7b2 made it so staff cannot change the status of pets for desktop for adopted or owned 2026-04-13 20:49:22 -06:00
f93f4f576b Merge pull request #252 from RecentRunner/merge-migration
merge migration
2026-04-13 19:48:37 -06:00
1b00366a1e make ActivityLog entity immutable 2026-04-13 19:47:45 -06:00
Alex
572895efa9 added correct refund logic and points for sales 2026-04-13 19:46:52 -06:00
Alex
c244e5742a added points to sale and logic backend 2026-04-13 19:46:28 -06:00
Alex
6efa440bbc added loyaltypoint usage to sales unfinished still needs to work with the backend 2026-04-13 18:52:07 -06:00
0e53b16d6c seed normalization 2026-04-13 18:29:49 -06:00
Alex
884f56c9a7 Can now edit loyalty points for customer on andriod, and pets now have breed dropdown 2026-04-13 18:25:11 -06:00
0e997071c3 Merge pull request #251 from RecentRunner/payment-fixes
Payment safety fixes
2026-04-13 17:52:21 -06:00
68893b1318 Unique sale constraint 2026-04-13 17:49:59 -06:00
a221f2a91b Add payment features 2026-04-13 17:49:59 -06:00
1577ed41cd Add checkout snapshot 2026-04-13 17:49:59 -06:00
Alex
1b4069e7e4 made sales readonly for andriod 2026-04-13 17:25:28 -06:00
Alex
a6f90f8477 Fixed phone validation for andriod 2026-04-13 17:18:46 -06:00
Alex
aefa00f95d added pending status on pets andiord, also made pets automatically switch to pending when an adoption is in pending 2026-04-13 17:12:41 -06:00
Alex
c145b3e552 Merge branch 'AttachmentsToChat' 2026-04-13 15:07:28 -06:00
Alex
c5de2fdd87 added store column to desktop and display only logged in data 2026-04-13 15:06:04 -06:00
ef3318a4ff Merge pull request #172 from RecentRunner/web-adopt-filter
Adopt page filter
2026-04-13 11:08:41 -06:00
39e30aa8d5 Remove XML declaration from misc.xml 2026-04-13 11:08:23 -06:00
augmentedpotato
de5bbbf3f7 Adopt page filter added 2026-04-13 10:34:26 -06:00
Alex
fba042d2b9 fixed rotated image for pets and product as well 2026-04-13 00:30:26 -06:00
Alex
d85530dd2c Fix profile image squish and rotate isusse 2026-04-13 00:07:58 -06:00
Alex
326182aeef added coupons to desktop app 2026-04-12 23:47:22 -06:00
Alex
2172bede74 desktop: added confirmation to change onwer and only admins can change this 2026-04-12 20:01:06 -06:00
Alex
f497251873 added closed chat section and fixed closed chat bug for desktop 2026-04-12 19:49:12 -06:00
Alex
e4e04940a9 Fixed minor bugs
- in sales we can no longer select 0 product for a sale
- ActivityLogFragment is locked to admin
- Spinners are loaded aftrer a selection if the spinner depends on a parent spinner
2026-04-12 18:34:43 -06:00
Alex
b493357f31 adjusted so only available pets for the selected store is displayed when adopting 2026-04-12 18:00:24 -06:00
Alex
4eaf98834d Modified project to use our utils on areas we arnt to manage code 2026-04-12 17:49:24 -06:00
Alex
7cdab36f5d Fixed Log filters and fixed chat attachment download 2026-04-12 17:28:40 -06:00
Alex
6831123aed added Activitylogs andriod 2026-04-12 16:58:12 -06:00
Alex
f7b8648778 Merge branch 'AttachmentsToChat' 2026-04-12 01:09:25 -06:00
Alex
870fa5488a added Forgetpassword page with no logic yet 2026-04-12 01:08:25 -06:00
Alex
3472b4bcd7 Faded text for disabled spinners in staff 2026-04-12 00:51:49 -06:00
Alex
d166ec1b4d staff cant change status or store for pets 2026-04-12 00:49:15 -06:00
Alex
f023077715 Made it so only admins can change pet owners 2026-04-12 00:37:15 -06:00
Alex
96e6cd6dc7 Fixed Coupon to use in sales 2026-04-12 00:25:33 -06:00
Alex
0311887185 Used the wrong endpoint for populating species, changed to to the correct one
also added coupon option to sales
2026-04-11 23:50:22 -06:00
27b15893a7 Merge pull request #171 from RecentRunner/admin-activity-logs
Add activity logging
2026-04-11 23:32:38 -06:00
9b59c5bfe0 restrict activity logging to admin and staff only 2026-04-11 23:29:23 -06:00
62094f2f4f Harden startup config 2026-04-11 23:10:18 -06:00
Alex
8ae47ef056 added petspecies spinner 2026-04-11 23:04:14 -06:00
Alex
d15940a5f2 Made it so staffs can only manage their own store and they cannot see other branches data 2026-04-11 23:02:52 -06:00
a7f2fc5b92 Consolidate log migrations 2026-04-11 22:54:43 -06:00
50c344091f Add log viewer 2026-04-11 22:54:27 -06:00
c69820241f Add activity logging 2026-04-11 22:54:23 -06:00
Alex
26bfd3973c added sort so appointment and adoption display the most recent created first 2026-04-11 22:18:39 -06:00
Alex
c0ebef7e96 fixed bug on appointments where spinners was not populating the correct data 2026-04-11 22:02:06 -06:00
Alex
cd74e5f06f replaced observer in viewmodels to observe only once to fix memleek 2026-04-11 15:46:30 -06:00
Alex
bb62b5d352 Added null checks for Appointment and Adoptions to make sure the spinner can load 2026-04-10 19:49:54 -06:00
Alex
f26c795d46 Added Customer CRUD 2026-04-10 19:34:28 -06:00
d6c4be3acf Merge pull request #170 from RecentRunner/fix-web-pr
Fix web
2026-04-10 12:52:44 -06:00
b888307ce7 Fix web 2026-04-10 09:17:31 -06:00
6b15207ad0 Update postman tests 2026-04-10 09:01:09 -06:00
ce04f16f97 Fix collection 2026-04-10 08:58:44 -06:00
6d7524c859 Ignore .env files 2026-04-10 08:58:44 -06:00
2f705b87f7 Add close chat and closed status 2026-04-10 08:58:44 -06:00
37410b2f7a Sync postman 2026-04-10 08:58:44 -06:00
b043577da0 Merge pull request #168 from RecentRunner/ai-chat-merge
Add AI chat
2026-04-10 08:58:23 -06:00
4cfed522eb Fix duplicate openrouter config 2026-04-10 08:28:43 -06:00
289a404c0a Fix sales UI 2026-04-10 08:20:25 -06:00
08e074607e Clean up OpenRouterService 2026-04-10 08:19:24 -06:00
8de2612b05 Update bot model 2026-04-10 08:18:54 -06:00
fb36a00fbf Fix bot runtime 2026-04-10 08:18:54 -06:00
bb28a8f31d OpenRouter bot fixes 2026-04-10 08:18:54 -06:00
33bf63cb1e Add OpenRouter bot 2026-04-10 08:18:54 -06:00
augmentedpotato
a50fa82a50 Web and AI chat 2026-04-10 08:18:54 -06:00
Alex
b9635cae68 Merge branch 'AttachmentsToChat' 2026-04-10 07:43:24 -06:00
Alex
5ecf322f0c added close chat option to chat 2026-04-10 07:36:54 -06:00
Alex
1e7d56499b fixed staff accounts and added coupons andriod 2026-04-10 07:17:19 -06:00
Alex
dff379c99d Sales bug fix 2026-04-10 05:56:05 -06:00
Alex
79261274f6 added Analytics filter 2026-04-10 05:03:36 -06:00
Alex
3a78021b98 Added so adoption status can be missed and fixed adoption bugs for andriod 2026-04-10 04:31:10 -06:00
Alex
5b1e0ea115 fixed spinners to populate the correct pets in edit mode for adoptions 2026-04-10 02:58:14 -06:00
Alex
57ad824d67 added viewstates to Supplier and Product 2026-04-10 00:28:01 -06:00
Alex
6ece516cc6 Fixed profile issue with camera and added viewstate to pet and service 2026-04-09 23:39:34 -06:00
b1f574b5a4 Merge pull request #164 from RecentRunner/implement-chat-notifications
implement chat notifications
2026-04-09 23:36:36 -06:00
31398fdcac Restore Main Attachments 2026-04-09 23:28:14 -06:00
12aa06f953 Defer Chat Attachments 2026-04-09 23:28:14 -06:00
6b055c4364 Implement chat features 2026-04-09 23:28:14 -06:00
4bd44727a5 Merge pull request #160 from RecentRunner/gui-fixes
Refactor user management
2026-04-09 23:23:57 -06:00
6be0099048 resolve merge conflict 2026-04-09 23:23:39 -06:00
9bfd3a48dc fix table column bindings using lambdas 2026-04-09 23:18:14 -06:00
92b66d7995 fix duplicate refresh in StaffAccountsController 2026-04-09 23:16:24 -06:00
d28fced5b3 Merge pull request #163 from RecentRunner/stripe-payment
Stripe Payments
2026-04-09 23:12:14 -06:00
fa01645d31 move stripe keys to .env 2026-04-09 23:01:41 -06:00
666c7e0ca6 fix user id getter in completeCheckout 2026-04-09 22:57:00 -06:00
39e4a3896e fix stripe payment flow 2026-04-09 22:52:57 -06:00
67b5da131e Merge pull request #162 from RecentRunner/AttachmentsToChat
Attachments to Chat
2026-04-09 22:34:59 -06:00
augmentedpotato
1010d57b79 Stripe Payment 2026-04-09 22:27:03 -06:00
3270971c7f Merge pull request #161 from RecentRunner/table-fixes
Merge Table Fixes
2026-04-09 21:49:18 -06:00
Alex
a8b5ee361e cleaning code 2026-04-09 21:16:11 -06:00
e68e39426b Restore Table Layout 2026-04-09 21:12:04 -06:00
Alex
fea01ba8ec made chat more user frendly 2026-04-09 19:47:43 -06:00
Alex
b9b74d2447 fixed sending message with attachments 2026-04-09 18:55:12 -06:00
56111f3ac7 Space Out Tables 2026-04-09 17:57:01 -06:00
da0c819da5 Add Sales Scroll 2026-04-09 17:39:52 -06:00
Alex
872e3a27f1 added attachments to chat 2026-04-09 17:39:45 -06:00
5cb32114c8 Shrink Sales View 2026-04-09 17:36:31 -06:00
3631201435 Format Appointment Times 2026-04-09 17:29:18 -06:00
fe7e81986d Refine Desktop Pricing 2026-04-09 17:29:03 -06:00
6e21e4fd6c Refine GUI Behavior 2026-04-09 15:51:21 -06:00
Alex
83eda83671 fixed bug again 2026-04-09 15:37:24 -06:00
Alex
01be4a7620 bug fix 2026-04-09 15:24:20 -06:00
Alex
872042de5a deleted unused viewmodels 2026-04-09 15:17:11 -06:00
Alex
6a3730ca04 refactored viewmodels for listfragments 2026-04-09 14:44:04 -06:00
Alex
8559a46cb9 created viewmodels for detailFragments 2026-04-09 14:17:51 -06:00
Alex
67cb178f46 Merge branch 'main' into AttachmentsToChat 2026-04-09 13:44:24 -06:00
54ff97448d Refactor user management 2026-04-09 12:28:33 -06:00
dd1502d2ce Adjust Sales Layout 2026-04-09 11:49:55 -06:00
c82a0efa93 Improve Desktop Tables 2026-04-09 11:49:14 -06:00
43715e05a5 Unify Table Behavior 2026-04-09 11:48:30 -06:00
4dce16f067 Merge pull request #158 from RecentRunner/remove-sidebar-emojis
Remove sidebar emojis
2026-04-09 11:11:28 -06:00
d1ff46844a Remove sidebar emojis 2026-04-09 11:10:59 -06:00
ab0dbec1af Merge pull request #156 from RecentRunner/early-fixes
Merge Early Fixes
2026-04-09 10:39:53 -06:00
Alex
4b8e0b2868 fixed spinner infinite loop in appointments 2026-04-09 02:48:55 -06:00
Alex
8fa74240bc split viewmodels for appointments 2026-04-09 02:07:35 -06:00
Alex
f98abf19ef Moved appointments businiss logic to modelview andriod 2026-04-09 00:55:00 -06:00
Alex
6ebec31f09 small change 2026-04-08 21:30:40 -06:00
Alex
f06f98a657 Appointments should be fully user frendly now 2026-04-08 20:14:52 -06:00
Alex
b6bee250df helper class added to enable and disable fields 2026-04-08 19:57:04 -06:00
Alex
5f9d7a848c updated backend so booked appointment automatically changes to completed 2026-04-08 19:16:18 -06:00
Alex
271314f990 fixing dropdowns 2026-04-08 18:10:18 -06:00
Alex
2bc0ffd47a update dropdowns to use backend dropdown endpoints part 1 2026-04-08 17:34:33 -06:00
Alex
6f11f4ebbb making appointment userfrendly part1 andriod 2026-04-08 16:53:42 -06:00
5734ceca6e Update early fixes 2026-04-08 16:43:50 -06:00
fb7c4c66ef Merge pull request #155 from RecentRunner/AttachmentsToChat
Merged attachments branch
2026-04-08 13:44:13 -06:00
75aec048ae Merge main branch 2026-04-08 13:43:20 -06:00
aa42a53c74 Fix adoption dialog 2026-04-08 11:23:02 -06:00
02184d9cfd Show store dropdown 2026-04-08 11:23:02 -06:00
0695f1d120 Update pet dialog 2026-04-08 11:23:02 -06:00
0f2b94a277 Create adoption sale 2026-04-08 11:23:02 -06:00
b80ffff296 Fix dialog issues 2026-04-08 11:23:02 -06:00
cbdec1882c Fix desktop chat 2026-04-08 11:23:02 -06:00
a7ba0fb4b4 fix empty desktop lists 2026-04-08 08:18:01 -06:00
4845aeb479 restrict adoption pets 2026-04-08 08:11:20 -06:00
55b61d3908 fix desktop chat 2026-04-08 08:07:17 -06:00
808b6e3d2b remove debit payments 2026-04-08 07:57:39 -06:00
56ffecba92 fix desktop forms 2026-04-08 07:55:55 -06:00
548d7629d3 fix web appointments 2026-04-08 07:17:48 -06:00
1fc675a138 Merge branch 'main' into web-more-fixes-for-wednesday 2026-04-08 07:17:26 -06:00
Alex
aeca5e4341 updated sales to have new backend data 2026-04-08 03:19:16 -06:00
Alex
811edf842b converted new fragments to use hilt, MVVM and jetpack nav 2026-04-08 02:22:34 -06:00
Alex
a35b432b54 Converted merged fragments to viewbinding 2026-04-08 02:06:07 -06:00
Alex
627ce7a987 Added hamburger menu helperfunction 2026-04-08 01:50:18 -06:00
Alex
57ac8ad2ce added filtering for Sales and added helper method for setting up filtertoggle andriod 2026-04-08 01:32:34 -06:00
Alex
526650bd98 fix minor bugs and UI inconsistancy 2026-04-08 00:22:25 -06:00
augmentedpotato
21d086a816 Can now add pets in the appointments page. 2026-04-07 23:53:48 -06:00
augmentedpotato
a6b188e0d6 Feature parity with admins and users (also a minor backend change) 2026-04-07 23:23:05 -06:00
augmentedpotato
bd46968b90 Fixed(?) being unable to create appointments on today's date 2026-04-07 22:44:50 -06:00
ef1b7ac716 fix image error responses 2026-04-07 22:41:32 -06:00
b3ff789f1b revert adoption fragment 2026-04-07 22:17:15 -06:00
3baef0e1ab Merge branch 'morefiles' 2026-04-07 21:43:20 -06:00
65140d77ac finish android merge wiring 2026-04-07 21:15:31 -06:00
1de6c981dc fix backend merge conflicts 2026-04-07 21:13:47 -06:00
9ca647f0cc merge origin/main into morefiles, resolve all conflicts 2026-04-07 20:29:54 -06:00
Alex
332f38db57 added filter by date for adoptions to backend 2026-04-07 18:34:08 -06:00
Alex
54ab737e2d added helper method for filter spinners to maintain code 2026-04-07 18:12:02 -06:00
Alex
155c64a729 added adoption search and filter andriod and backend 2026-04-07 18:06:07 -06:00
Alex
538b4440d8 fixed purchase order for android on new backend 2026-04-07 17:31:43 -06:00
Alex
5a3229eb19 Merge branch 'main' into AttachmentsToChat 2026-04-07 16:24:17 -06:00
Alex
c232f193d1 added bulk delete for ProductSupplier, appointments, and adoptions 2026-04-07 16:20:51 -06:00
f17ad44155 fix alias folder ordering and request bodies in Postman collection 2026-04-07 16:16:11 -06:00
4500b213c6 fix audit report mismatches across backend and android 2026-04-07 16:06:44 -06:00
Alex
4ccfe55174 bluk delete added for Service and suppliers on andriod 2026-04-07 15:24:25 -06:00
0173123898 update postman collection 2026-04-07 15:19:25 -06:00
Alex
9eaf64c7a9 added helper class for bulk delete and mad pets have bulk delete 2026-04-07 15:13:15 -06:00
Alex
aa30efd3b6 Did the same to inventory 2026-04-07 14:55:43 -06:00
852a8a0eb2 update web packages 2026-04-07 14:36:20 -06:00
Alex
4e887c1a73 updard Adoptions in andriod for new backend 2026-04-07 14:35:57 -06:00
Alex
108de589bc edited adapters in andriod to use viewbinding 2026-04-07 14:17:24 -06:00
27871d96f5 stabilize desktop chat 2026-04-07 09:38:17 -06:00
c776c579ab fix desktop chat 2026-04-07 09:34:31 -06:00
9096f4fd29 fix desktop user inventory crud 2026-04-07 09:31:26 -06:00
858d13cadc fix desktop adoption save 2026-04-07 09:27:34 -06:00
4d7f452a97 add my pets api 2026-04-07 09:15:01 -06:00
ee2cab953d fix web registration 2026-04-07 09:10:11 -06:00
0655dfdfea fix desktop appointments 2026-04-07 09:05:08 -06:00
Nikitha
c61f71d226 loading employee in appointments and adoptions
changes in backend and android
2026-04-07 08:43:49 -06:00
de654e487b Merge pull request #146 from RecentRunner/AttachmentsToChat
AttachmentsToChat
2026-04-07 08:23:47 -06:00
6d77a98d92 merge main 2026-04-07 08:23:23 -06:00
7e3d094217 update postman collection 2026-04-07 08:17:41 -06:00
Alex
7c0ab0bfae added an api connection to Users in Andriod
NOTE Will have to change backend so staffs can access other staffs
2026-04-07 07:46:22 -06:00
Alex
8c5348dbb6 fixed creating adoption for the backend and implemented adoption to andriod for changes 2026-04-07 07:27:37 -06:00
3c35e05e47 Merge pull request #145 from RecentRunner/AttachmentsToChat
AttachmentsToChat
2026-04-07 06:53:07 -06:00
Alex
77e93a38b7 added my appointments button for logged in user on andriod 2026-04-07 06:48:36 -06:00
Alex
6f646a7cf0 added filter options to appointments in the backend and andriod 2026-04-07 06:34:28 -06:00
Alex
b7f97c45a5 updated Appointments on andriod for new backend 2026-04-07 06:14:17 -06:00
Alex
5c9d04fc88 changed backend so can sortBy productName and added search to productSupplier 2026-04-07 05:48:24 -06:00
Alex
cbd038d8fb Added filter by store for inventory in back end and added search to inventory 2026-04-07 05:24:25 -06:00
Alex
6c832e01f3 updated inventory backend to have filter by store and added more search features to andriod 2026-04-07 05:09:48 -06:00
Alex
1d00a3b55c updated search to call api for supplier 2026-04-07 04:19:51 -06:00
Alex
5823152c56 updated search for service to call api 2026-04-07 04:12:29 -06:00
Alex
3d403ee35e Updated Filterdropdown design for pets 2026-04-07 03:51:09 -06:00
Alex
230f2715ca added more filter options to pets 2026-04-07 03:24:55 -06:00
Alex
6b3979c68f changed filtering and search in pets to use api calls 2026-04-07 02:46:00 -06:00
Alex
003c7ec58a changed petDetailFragment to support new backend 2026-04-07 02:23:58 -06:00
Alex
5cb625a710 fixed pet DTO and how it interacts with new backend 2026-04-07 00:27:17 -06:00
Alex
5a1ff67db4 Fixed backend missing file issue 2026-04-07 00:10:42 -06:00
Alex
870920f67e Merge branch 'main' into AttachmentsToChat 2026-04-06 23:27:47 -06:00
Alex
d9db1f778e Fixed seeding for backend 2026-04-06 23:27:23 -06:00
Nikitha
bb7fbf9f78 Employee files
Add, edit employee (staff and admin)
2026-04-06 22:56:40 -06:00
Nikitha
53246a78c6 Sale, refund and analytics documents
view sale history  and refund for sales
analytics of sale , employee and store performances
2026-04-06 22:55:45 -06:00
5a691a5ddf seed stores and suppliers before products on fresh DB 2026-04-06 21:18:30 -06:00
7a70d163c7 Merge pull request #144 from RecentRunner/backend-refactor
backend-refactor
2026-04-06 21:13:48 -06:00
24a2af0bee fix local seed: add missing categories and storeId in inventory insert 2026-04-06 21:13:17 -06:00
67116d2c89 Merge pull request #143 from RecentRunner/backend-refactor
Fix sale inventory and switch to port 3306
2026-04-06 21:06:08 -06:00
1029e48f42 point to port 3306 Petstoredb 2026-04-06 21:06:01 -06:00
3088573b0b scope inventory lookup by store on sale 2026-04-06 21:01:20 -06:00
ae653027c4 Merge pull request #142 from RecentRunner/backend-refactor
Fix lazy loading
2026-04-06 20:57:44 -06:00
386deff6b8 fix lazy loading on me, services, refunds 2026-04-06 20:56:14 -06:00
2057d5695b Merge pull request #141 from RecentRunner/backend-refactor
Backend refactor
2026-04-06 20:51:44 -06:00
f646c18035 enable Hibernate validation 2026-04-06 20:46:27 -06:00
e291a0e999 add activityLog store FK 2026-04-06 20:39:08 -06:00
3760d61e2e add message attachment fields 2026-04-06 20:38:29 -06:00
c96c3b1dab add service species collection 2026-04-06 20:34:03 -06:00
9f465b25bb add sale channel coupon cart columns 2026-04-06 20:32:05 -06:00
fd3f49255a add coupon cart cartItem entities 2026-04-06 20:30:11 -06:00
88dfdb05a8 add adoption sourceStore FK 2026-04-06 20:28:31 -06:00
c15313ea5f add purchaseOrder store FK 2026-04-06 20:26:42 -06:00
b2ddea8794 add storeLocation imageUrl 2026-04-06 20:25:33 -06:00
6682cdc047 add store dimension to inventory 2026-04-06 20:24:23 -06:00
beff4c5297 simplify appointment to single pet 2026-04-06 20:22:26 -06:00
0570758b07 merge customer/employee into users 2026-04-06 20:17:27 -06:00
a36dee75af expand User entity fields 2026-04-06 19:49:38 -06:00
48f1a9c1ba switch to target DB config 2026-04-06 19:47:18 -06:00
f3a611ad60 Add target DB setup 2026-04-06 19:37:15 -06:00
Alex
4725e11b89 Merge branch 'main' into AttachmentsToChat 2026-04-06 16:29:10 -06:00
d8704c38f1 Fix brittle migrations by replacing hardcoded IDs with robust subqueries 2026-04-06 16:25:45 -06:00
Alex
90628b37d9 Merge branch 'main' into AttachmentsToChat 2026-04-06 16:13:50 -06:00
d839081112 Merge pull request #139 from RecentRunner/pet-owner-store
Pet owner store
2026-04-06 16:12:09 -06:00
9e3e8c835e Seed pets and appointments 2026-04-06 15:50:52 -06:00
35640a1a39 Update pet desktop 2026-04-06 15:50:49 -06:00
9655a77972 Pet owner and store 2026-04-06 15:50:45 -06:00
Alex
6a515f0edc Merge branch 'main' into AttachmentsToChat 2026-04-06 15:39:38 -06:00
d3754c8018 Merge pull request #138 from RecentRunner/employee-phase
Employee phase
2026-04-06 13:37:38 -06:00
6f8c0674c2 Update Postman collection 2026-04-06 13:35:01 -06:00
18407f8328 Fix Flyway migration 2026-04-06 13:35:01 -06:00
Alex
4f0abd2f26 changed detailed fragment to fill data from the backend 2026-04-06 03:12:42 -06:00
d51b1b0ab7 Fix availability checks 2026-04-06 01:51:58 -06:00
d8e2c8c95d Add Missed status 2026-04-06 00:39:37 -06:00
6d4c9a5e65 Fix employee time conflicts 2026-04-06 00:18:49 -06:00
706cd94d14 Allow cross-store staff selection 2026-04-05 23:58:21 -06:00
8d3430bd75 Enforce pet ownership rules 2026-04-05 23:35:05 -06:00
Alex
4b9bf4dff4 Implemented View Binding to reduce code
- project uses view binding now so we don have to do
getViewbyId to refer to the xml
2026-04-05 22:50:25 -06:00
Alex
124a10c619 Edited RetrofitUtils to also call enqueue to reduce code in repository 2026-04-05 21:57:53 -06:00
Alex
eecb695b87 Fixed bug where it navigates back to petprofile after deleting the pet 2026-04-05 21:40:43 -06:00
Alex
38a0242264 Refactored more of the project to MVVM and created helper class RetrofitUtil to reduce redundent code 2026-04-05 21:27:32 -06:00
Alex
693829ce42 Created help class for displaying diolog and removed redundent code 2026-04-05 18:38:46 -06:00
Alex
defd851dbd Created Spinner Helper class and removed reducdent code 2026-04-05 18:15:36 -06:00
Alex
8b2d406433 remove dead code 2026-04-05 17:48:14 -06:00
Alex
65a8475e47 Added Helper class and commented most fragments 2026-04-05 17:16:40 -06:00
bdd5566493 Enforce staff-only assignments 2026-04-05 16:17:58 -06:00
8f635116df Restrict assignments to staff 2026-04-05 16:03:29 -06:00
c84d817810 Allow admin ownership bypass 2026-04-05 16:01:46 -06:00
30b5041ae5 Harden assignment rules 2026-04-05 15:51:11 -06:00
0294f078f9 Harden staff assignment 2026-04-05 12:17:37 -06:00
Alex
0c75ffbf35 Refactored Andriod project to use MVVM structure (Need to apply this so sales too after merge)
- Used MVVM structure so fragments are not doing all the operation from views to data and calls
- organized the structure of our proejct
2026-04-04 23:35:38 -06:00
Alex
47bd755e72 fix photo loading issue on pets and products 2026-04-04 21:21:25 -06:00
Alex
be79de7c82 integrated Jetpack navigation to project so we dont have to manually code the functionallities of loading to different fragments 2026-04-04 20:08:40 -06:00
Alex
e25a02fe1f fixed retrofit client, but will delete this file after merges
- kept class so nothing will break when merge
- then delete after merge buy making other files use Hilt
2026-04-04 18:23:15 -06:00
Alex
ddc8e98c19 integrated hilt so we dont have to manually pass context and inject retrofit in andriod 2026-04-04 18:15:05 -06:00
Alex
4ea76ddab5 Added calendar view to adoptions in andriod 2026-04-04 17:16:44 -06:00
Alex
7bb2d98639 Added enter send message and login for andriod feilds 2026-04-04 16:54:29 -06:00
b7d85053bc Merge pull request #135 from RecentRunner/clean-demo-branch
Protect appointment visibility
2026-04-04 16:28:34 -06:00
dd373d3800 Harden appointment dialog 2026-04-04 16:24:09 -06:00
259770ce69 Fix appointment ownership 2026-04-04 16:24:09 -06:00
ca06f6c8b3 Hide adopted pets 2026-04-04 16:24:09 -06:00
83a0448219 Merge pull request #134 from RecentRunner/main
Update branch
2026-04-04 16:11:26 -06:00
86267ddddf Merge pull request #133 from RecentRunner/AttachmentsToChat
Attachments to chat
2026-04-04 16:10:39 -06:00
Alex
3cc1e93c5b added calendar view to appointments
- NOTE: may have to change appointments abit after backend is updated
2026-04-03 19:37:43 -06:00
cfaee96c3e Merge pull request #108 from RecentRunner/web-more-fixes
Web more fixes
2026-04-03 18:13:18 -06:00
augmentedpotato
cfd5a7c8cd Fix profile images 2026-04-03 15:30:43 -06:00
augmentedpotato
a3e5e6701e Fix pet sorting 2026-04-03 15:21:15 -06:00
augmentedpotato
2a871a4d41 Fix item loading 2026-04-03 15:07:41 -06:00
augmentedpotato
4bd98ef06f Improve auth flows 2026-04-03 14:52:32 -06:00
augmentedpotato
82935303ba Fix web routing 2026-04-03 14:48:24 -06:00
Alex
b7681499ae Added images to products for android
- also added the option to delete the images to profile and pets
2026-04-02 19:21:55 -06:00
Alex
877e0cf0de made it so we can put attachments to chat
- Sending not implemented until backend is complete
2026-04-02 18:23:49 -06:00
3d6b87a7d2 Automate reset cleanup 2026-04-01 20:17:30 -06:00
5ef045165d Remove duplicate migration 2026-04-01 20:08:39 -06:00
488f67289d Integrate refund logic 2026-04-01 19:59:03 -06:00
38a71a8b59 Merge pull request #75 from RecentRunner/fix-features-icons-v2
Finalize feature fixes
2026-04-01 19:31:28 -06:00
054094a61e Add desktop icons 2026-04-01 18:10:20 -06:00
c48039eeb4 Fix phone formatting 2026-04-01 18:08:37 -06:00
e580ed3251 Apply service logic 2026-04-01 16:57:27 -06:00
695c80ba9d Merge migration fixes 2026-04-01 16:55:36 -06:00
bdbcd75042 Revert "Merge pull request #55 from RecentRunner/backend-normalize-users-payments"
This reverts commit aa0950df9b, reversing
changes made to fdd4af6746.
2026-03-30 09:58:02 -06:00
563f8ee691 Fix migration versions 2026-03-30 09:57:46 -06:00
352d7beec1 Merge pull request #60 from RecentRunner/web-products
Web products
2026-03-30 09:51:34 -06:00
d078af520d Merge remote-tracking branch 'origin/main' into web-products 2026-03-30 09:50:57 -06:00
77c5f53532 Merge pull request #59 from RecentRunner/web-index
Web index
2026-03-30 09:48:50 -06:00
06908ffacb Merge pull request #58 from RecentRunner/refund-layout-spacing
Refund polish
2026-03-30 09:41:16 -06:00
9c3f5d4d50 Fix refund display 2026-03-30 09:40:22 -06:00
31a11656c4 Stabilize refunds 2026-03-30 09:17:22 -06:00
07cd4f6c0e Polish sales tables 2026-03-30 09:16:52 -06:00
augmentedpotato
3b8b1e9b97 Appointments, account stuff, adopt a pet changes 2026-03-30 05:38:15 -06:00
8f05f22b23 Fix android backend url 2026-03-30 00:03:27 -06:00
467daa35f0 Merge pull request #57 from RecentRunner/staff-self-analytics
Staff analytics
2026-03-29 23:55:33 -06:00
0f20523b3c Fix staff analytics 2026-03-29 23:50:31 -06:00
a9fc3e3227 Show staff analytics 2026-03-29 23:34:52 -06:00
061275ba30 Scope staff analytics 2026-03-29 23:34:43 -06:00
edea9ef315 Merge remote-tracking branch 'origin/FixedUIConsistancy' 2026-03-29 23:22:43 -06:00
a7162682c4 Merge pull request #56 from RecentRunner/expand-pets-products-data
Expand catalog
2026-03-29 23:08:37 -06:00
5791ddc47d Tighten seed filters 2026-03-29 23:07:16 -06:00
e572d9f3cf Add pet product filters 2026-03-29 22:54:25 -06:00
c48e3b8a95 Expand pet product data 2026-03-29 22:54:16 -06:00
aa0950df9b Merge pull request #55 from RecentRunner/backend-normalize-users-payments
Normalize users
2026-03-29 22:40:13 -06:00
5d3efa0af5 Clean up customer accounts 2026-03-29 22:37:18 -06:00
3fbb108646 Preserve backfill emails 2026-03-29 22:14:53 -06:00
1a0fe7f95d Disable generated user accounts 2026-03-29 22:09:39 -06:00
f6147aa810 Tighten backfill migration 2026-03-29 22:02:44 -06:00
277d1dce8f Tighten user linking 2026-03-29 21:59:43 -06:00
24041f4242 Fix user linking 2026-03-29 21:52:45 -06:00
Alex
26c437261f added pet images to petfragment and changed other views to look consistant 2026-03-29 21:47:49 -06:00
1bab36f727 Remove debit payment data 2026-03-29 21:44:10 -06:00
a727878b0c Backfill user accounts 2026-03-29 21:44:06 -06:00
fdd4af6746 Merge pull request #54 from RecentRunner/backend-fixes-41-49
Fix backend appointments and chat
2026-03-29 21:24:36 -06:00
54b3b1d457 Remove chat close wrapper 2026-03-29 21:14:53 -06:00
9dba49e141 Add appointment tests 2026-03-29 21:07:14 -06:00
df42f68c04 Update chat conversation status 2026-03-29 21:07:10 -06:00
3cb3faa073 Fix appointment overlap rules 2026-03-29 19:02:19 -06:00
c5e9baad5e Add chat close endpoint 2026-03-29 19:02:12 -06:00
32ce987fbc Merge pull request #53 from RecentRunner/nomorebreaking
Merging in Nikitha's work
2026-03-29 18:21:56 -06:00
b8fa6b730f Fix status bar and navigation bar layout 2026-03-29 18:20:10 -06:00
8d419b0dd9 Fix Android app connection and timeout issues
Add proper timeout configuration to OkHttpClient (30s connect/read/write)
Update OkHttp logging-interceptor to 4.12.0 to match OkHttp version
Improve error messages to show server URL for debugging
Configure backend to listen on all interfaces (0.0.0.0)
Remove EdgeToEdge calls that interfered with layout
2026-03-29 17:54:23 -06:00
augmentedpotato
cce97b7509 Merge branch 'web-index' 2026-03-29 17:47:33 -06:00
d3723b8ae5 Merge main into nomorebreaking 2026-03-29 17:07:35 -06:00
a254d88775 Merge pull request #52 from RecentRunner/desktop-pet-product-pictures
Add desktop pet and product images
2026-03-29 16:58:06 -06:00
Nikitha
324e5d4e17 sales files
basic sale view and builtfiles
2026-03-29 16:54:41 -06:00
b3a30e10d1 fix desktop pet and product dialogs 2026-03-29 16:54:01 -06:00
Nikitha
c4775bcbfa Backend a small change
small change in appointment service to validate time for appointments.
2026-03-29 16:35:18 -06:00
Nikitha
862ece691a purchaseorder view
Purchase Order view only so changes only to view
2026-03-29 16:33:27 -06:00
Nikitha
13916236eb connected files
files connected to load
2026-03-29 16:32:25 -06:00
Nikitha
2ad097413b Product files
loads details of product, categories and cost of it
2026-03-29 16:29:40 -06:00
Nikitha
653ae3f233 ProductSupplier
prodcutSupllier -loads all product supplier details and  with cost details
2026-03-29 16:28:19 -06:00
Nikitha
87a4404c20 Inventory
Inventory- details of product loads with id and described with filter, and categories selection
2026-03-29 16:26:21 -06:00
Nikitha
55f40572de Adoption files
adoption
2026-03-29 16:24:14 -06:00
Nikitha
7e832a139f Appointments
validation, time slots for booking appointment , date, store. connected to service Type, pet name, customer name. Loads from database
2026-03-29 16:20:38 -06:00
8a278cd6e2 add desktop pet and product images 2026-03-27 10:07:37 -06:00
Alex
f5e031484e Changed android app icon 2026-03-26 22:45:28 -06:00
Alex
1360fa1592 Added petprofile images and uploads in petprofilefragment 2026-03-26 22:07:51 -06:00
Alex
998390c063 Merge branch 'MorePushNotification' 2026-03-26 21:39:38 -06:00
Alex
9f38ca1bcc Make chat notification display messengers name and disable notifying if already in chat view 2026-03-26 21:31:36 -06:00
a3ad1dab8c add pet and product images 2026-03-26 20:36:04 -06:00
Alex
177ac844ee Added push notifications when reciving any message and added filter status on pets in Andriod 2026-03-26 20:13:27 -06:00
Alex
4f02825b96 Added profile photo loading and uploading
- profile photos now load from backend
- profile photos can be uploaded to the backend
- RetrofitClient now automatically determines if the device is an emulator or hardware so we dont have to comment and uncomment everytime we test with a different device
2026-03-26 16:50:02 -06:00
Alex
d31db865cf Added role based access to android login
- Admin has access to everything
- Staff has limited access to what they can edit in listfragment
- Customers cannot login to app
- added validations to pets, supplier and services in their detailed view
2026-03-26 16:50:02 -06:00
5c94880733 use swing picker on wayland 2026-03-25 23:55:40 -06:00
3c9a3bf1b3 readd secure avatar endpoints 2026-03-25 22:58:04 -06:00
46d7b58238 Merge pull request #30 from RecentRunner/WorkingOnProfileAndPushNotification
Working on profile and push notification
2026-03-25 09:18:48 -06:00
Alex
d3a69b7aea added push notification when a new conversation is made 2026-03-24 22:41:54 -06:00
29153cb6ee Merge pull request #29 from RecentRunner/desktop---validator-fixes
added null checks to validator, created a bunch of junit tests
2026-03-24 20:43:54 -06:00
augmentedpotato
4736b8bd3f added null checks to validator, created a bunch of junit tests 2026-03-24 20:42:45 -06:00
Alex
b46705396d Profile now loads from backend
- can update profile from app
- loads profile details from backend to display
- changed inputValidator to use andriod phone pattern
- added ErrorResponse so we can fetch error messages from the backend
- Added UserDTO to get profile info

TODO:
Still need to get profile images from the backend and beable to load and update them using the backend
2026-03-24 18:31:12 -06:00
5b7c19f334 Merge pull request #28 from RecentRunner/web-index
uploading index to repo
2026-03-24 16:59:39 -06:00
1084 changed files with 77185 additions and 11674 deletions

114
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: Build and Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-backend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
- name: Set image name (lowercase)
run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push backend image
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ env.BACKEND_IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-frontend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
- name: Set image name (lowercase)
run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push frontend image
uses: docker/build-push-action@v6
with:
context: ./web
push: true
tags: ${{ env.FRONTEND_IMAGE }}:latest
no-cache: true
deploy:
runs-on: ubuntu-latest
needs: [build-backend, build-frontend]
permissions:
contents: read
id-token: write
steps:
- name: Set image names (lowercase)
run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV
echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV
- name: Log in to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy backend
run: |
az containerapp update \
--name ${{ secrets.AZURE_BACKEND_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
--image ${{ env.BACKEND_IMAGE }}:latest \
--revision-suffix r${{ github.run_number }}
- name: Deploy frontend
run: |
az containerapp update \
--name ${{ secrets.AZURE_FRONTEND_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
--image ${{ env.FRONTEND_IMAGE }}:latest \
--revision-suffix r${{ github.run_number }}

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*.zip
.local/
commit-patches/
temp_photos/
.env

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

1570
.idea/caches/deviceStreaming.xml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/markdown.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

5
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<project version="4">
<component name="ProjectRootManager" version="2">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/group-2-threaded-project-petshop.iml" filepath="$PROJECT_DIR$/.idea/group-2-threaded-project-petshop.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

245
README.md
View File

@@ -1 +1,244 @@
# group-2-threaded-project-petshop
# PetShop
A pet store management application with a Spring Boot API serving three clients: a Next.js web app, a JavaFX desktop app, and an Android app.
Handles product sales, pet adoption, appointment booking, real-time chat, AI assistance, payments (Stripe), email notifications (Resend), and file storage (Azure Blob).
## Tech Stack
| Layer | Technology |
|-------|------------|
| API | Java 25, Spring Boot 4, Spring Security (JWT), Hibernate |
| Database | MySQL 8.0, Flyway migrations |
| Web | Next.js 16, React 19, Tailwind CSS 4 |
| Desktop | JavaFX, Maven |
| Android | Kotlin, Hilt, Retrofit, CameraX |
| Infra | Docker, Azure Container Apps |
## Project Structure
```
main/
backend/ Spring Boot REST API
web/ Next.js frontend
desktop/ JavaFX desktop client
android/ Android mobile app
```
## Prerequisites
- Java 25
- Node.js 18+
- Docker
- Maven
- Android Studio (for mobile)
## Getting Started
### 1. Start the database
```sh
cd backend
docker compose -f docker-compose.dev.yml up -d
```
### 2. Configure the backend
```sh
cd backend
cp .env.example .env
```
Fill in `.env` with your keys:
```
JWT_SECRET=<openssl rand -base64 32>
STRIPE_SECRET_KEY=sk_test_...
OPENROUTER_API_KEY=sk-or-v1-...
RESEND_API_KEY=re_...
RESEND_FROM=PetShop <no-reply@yourdomain.com>
```
### 3. Run the backend
```sh
cd backend
mvn spring-boot:run
```
The API starts at `http://localhost:8080`. Flyway runs migrations and seeds data automatically on first boot.
### 4. Run the web frontend
```sh
cd web
cp .env.example .env.local
npm install
npm run dev
```
The web app starts at `http://localhost:3000`.
### 5. Run the desktop client
```sh
cd desktop
cp connectionpetstore.properties.example connectionpetstore.properties
mvn javafx:run
```
### 6. Run the Android app
Open `android/` in Android Studio and run on an emulator or device.
## Switching Between Azure and Local Backend
Each client reads the backend URL from a config file. To point a client at the
hosted Azure backend versus a local one, flip the commented lines.
### Web
Edit `web/.env.local`:
```
# Local
BACKEND_URL=http://localhost:8080
#BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io
# Azure
#BACKEND_URL=http://localhost:8080
BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io
```
Restart the dev server after changing.
### Desktop
Edit `desktop/src/main/resources/connectionpetstore.properties`:
```
# Local
api.baseUrl=http://localhost:8080
#api.baseUrl=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io
# Azure
#api.baseUrl=http://localhost:8080
api.baseUrl=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io
```
### Android
Edit `android/local.properties`:
```properties
# Local (emulator — 10.0.2.2 maps to host's localhost)
petstore.backend.emulatorUrl=http\://10.0.2.2\:8080/
petstore.backend.deviceUrl=http\://192.168.x.x\:8080/
# Azure
petstore.backend.emulatorUrl=https\://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io/
petstore.backend.deviceUrl=https\://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io/
```
Sync Gradle and re-run the app.
## API
A Postman collection is available at `backend/postman/`. Key endpoint groups:
- `/api/auth` -- registration, login, password reset
- `/api/products` -- catalog and inventory
- `/api/pets` -- listings and adoption
- `/api/appointments` -- booking
- `/api/cart`, `/api/sales`, `/api/refunds` -- transactions
- `/api/chat` -- messaging and AI assistant
- `/ws` -- WebSocket (STOMP) for real-time updates
## Docker (full stack)
```sh
cd backend
docker compose up --build -d
```
Starts the API and MySQL together. The web frontend has its own Dockerfile for independent deployment.
## Running the Web App
Requires Node.js 18+.
```sh
cd web
cp .env.example .env.local
npm install
npm run dev
```
Open `http://localhost:3000`. The app proxies API calls to the backend at `http://localhost:8080` by default.
To point at a different backend, edit `BACKEND_URL` and `NEXT_PUBLIC_BACKEND_URL` in `.env.local`.
For a production build:
```sh
npm run build
npm run start
```
## Running the Desktop App (JavaFX)
Requires IntelliJ IDEA and Java 25+.
1. Open the `desktop/` directory in IntelliJ.
2. Copy `connectionpetstore.properties.example` to `connectionpetstore.properties` and edit it to match your database. The defaults expect the dev Docker database:
```
url=jdbc:mysql://127.0.0.1:3306/Petstoredb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
user=petshop
password=petshop
```
3. Open **View > Tool Windows > Maven** and click **Reload All Maven Projects**.
4. Expand **Plugins > javafx** and double-click **javafx:run**.
Default accounts seeded on first run:
| Role | Username | Password |
|------|----------|----------|
| Admin | `admin` | `admin123` |
| Staff | `staff` | `staff123` |
## Running the Android App
Requires Android Studio and the Android SDK (min API 24).
1. Copy `local.properties.template` to `local.properties` and set `sdk.dir` to your Android SDK path.
2. Configure the backend URLs in `local.properties`:
```properties
# Emulator — 10.0.2.2 maps to the host machine's localhost
petstore.backend.emulatorUrl=http\://10.0.2.2\:8080/
# Physical device — use the host machine's LAN IP
petstore.backend.deviceUrl=http\://192.168.x.x\:8080/
```
3. Open the `android/` directory in Android Studio.
4. Sync Gradle, then run on an emulator or connected device.
## Running the Backend
Requires IntelliJ IDEA and Java 25+.
1. Open the `backend/` directory in IntelliJ.
2. Copy `.env.example` to `.env` and fill in your API keys.
3. Start the database using Docker from IntelliJ's **Services** panel, or from a terminal:
```sh
cd backend
docker compose -f docker-compose.dev.yml up -d
```
4. Run the `BackendApplication` main class from IntelliJ.
The API starts at `http://localhost:8080`. Flyway runs migrations and seeds data automatically on first boot.

3
android/.gitignore vendored
View File

@@ -1,4 +1,5 @@
*.iml
nohup.out
.gradle
/local.properties
/.idea/*
@@ -16,6 +17,8 @@
/app/src/androidTest/
/app/src/test/
.DS_Store
/.project
/.settings/
/build
/captures
.externalNativeBuild

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

6
android/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -1,3 +1,7 @@
/build
/nohup.out
/.classpath
/.project
/.settings/
/src/test/
/src/androidTest/

View File

@@ -1,7 +1,25 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.hilt)
alias(libs.plugins.navigation.safeargs)
}
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
fun quoted(value: String): String = "\"$value\""
val emulatorBackendUrl =
(localProperties.getProperty("petstore.backend.emulatorUrl") ?: "https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io/").trim()
val deviceBackendUrl =
(localProperties.getProperty("petstore.backend.deviceUrl") ?: "https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io/").trim()
android {
namespace = "com.example.petstoremobile"
compileSdk = 36
@@ -14,6 +32,14 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "EMULATOR_BACKEND_URL", quoted(emulatorBackendUrl))
buildConfigField("String", "DEVICE_BACKEND_URL", quoted(deviceBackendUrl))
}
buildFeatures {
buildConfig = true
viewBinding = true
}
buildTypes {
@@ -32,31 +58,46 @@ android {
}
dependencies {
// Core AndroidX & UI
implementation(libs.appcompat)
implementation(libs.material)
implementation(libs.activity)
implementation(libs.constraintlayout)
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.9.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.viewpager2:viewpager2:1.1.0")
implementation("androidx.camera:camera-core:1.4.0")
implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0")
implementation("androidx.camera:camera-view:1.4.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation(libs.swiperefreshlayout)
implementation(libs.viewpager2)
// Hilt Dependency Injection
implementation(libs.hilt.android)
annotationProcessor(libs.hilt.compiler)
// Navigation Component
implementation(libs.navigation.fragment)
implementation(libs.navigation.ui)
// Networking
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// CameraX
implementation(libs.camera.core)
implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle)
implementation(libs.camera.view)
// Image Loading
implementation(libs.glide)
annotationProcessor(libs.glide.compiler)
// Other Third-party Libraries
implementation("com.github.NaikSoftware:StompProtocolAndroid:1.6.6")
implementation("io.reactivex.rxjava2:rxjava:2.2.21")
implementation("io.reactivex.rxjava2:rxandroid:2.1.1")
implementation("com.github.prolificinteractive:material-calendarview:2.0.1")
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
}
}

View File

@@ -8,6 +8,7 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature
android:name="android.hardware.camera"
@@ -24,10 +25,18 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.PetStoreMobile">
<service
android:name=".services.ChatNotificationService"
android:exported="false" />
<activity
android:name=".activities.HomeActivity"
android:windowSoftInputMode="adjustResize"
android:exported="false" />
<activity
android:name=".activities.ForgotPasswordActivity"
android:exported="false" />
<activity
android:name=".activities.MainActivity"
android:exported="true">

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -1,13 +1,18 @@
/*
* Application entry point, sets up Hilt dependency injection.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile;
import android.app.Application;
import com.example.petstoremobile.api.auth.TokenManager;
import dagger.hilt.android.HiltAndroidApp;
@HiltAndroidApp
public class PetStoreApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// Clear login data on app so when the application closes, the user is logged out and have to re-login
TokenManager.getInstance(this).clearLoginData();
}
}

View File

@@ -0,0 +1,97 @@
/*
* Screen where the user can request a password reset email.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.activities;
import android.os.Bundle;
import android.view.View;
import android.widget.Toast;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.databinding.ActivityForgotPasswordBinding;
import com.example.petstoremobile.utils.InputValidator;
import java.util.HashMap;
import java.util.Map;
import javax.inject.Inject;
import dagger.hilt.android.AndroidEntryPoint;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
@AndroidEntryPoint
public class ForgotPasswordActivity extends AppCompatActivity {
@Inject
AuthApi authApi;
private ActivityForgotPasswordBinding binding;
/**
* Set the content view for forget password page
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
EdgeToEdge.enable(this);
super.onCreate(savedInstanceState);
binding = ActivityForgotPasswordBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ViewCompat.setOnApplyWindowInsetsListener(binding.forgotPasswordRoot, (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
binding.btnSubmit.setOnClickListener(v -> {
if (!InputValidator.isValidEmail(binding.etEmail)) return;
String email = binding.etEmail.getText().toString().trim();
sendResetLink(email);
});
binding.btnBackToLogin.setOnClickListener(v -> finish());
}
/**
* A function to send a reset link to the given email address.
* Calls the forgotPassword endpoint. To send the reset link
* */
private void sendResetLink(String email) {
binding.btnSubmit.setEnabled(false);
Map<String, String> body = new HashMap<>();
body.put("usernameOrEmail", email);
authApi.forgotPassword(body).enqueue(new Callback<Void>() {
@Override
public void onResponse(Call<Void> call, Response<Void> response) {
if (binding == null) return;
binding.btnSubmit.setEnabled(true);
Toast.makeText(ForgotPasswordActivity.this,
"If this email is registered, a reset link will be sent.",
Toast.LENGTH_LONG).show();
finish();
}
@Override
public void onFailure(Call<Void> call, Throwable t) {
if (binding == null) return;
binding.btnSubmit.setEnabled(true);
Toast.makeText(ForgotPasswordActivity.this,
"Could not send reset link. Please try again.",
Toast.LENGTH_LONG).show();
}
});
}
}

View File

@@ -1,69 +1,147 @@
/*
* Main home screen that shows the dashboard after the user logs in.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.activities;
import android.Manifest;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import androidx.activity.EdgeToEdge;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.fragment.app.Fragment;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.NavigationUI;
import com.example.petstoremobile.R;
import com.example.petstoremobile.fragments.ChatFragment;
import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.ProfileFragment;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.databinding.ActivityHomeBinding;
import com.example.petstoremobile.services.ChatNotificationService;
import dagger.hilt.android.AndroidEntryPoint;
@AndroidEntryPoint
public class HomeActivity extends AppCompatActivity {
private ActivityHomeBinding binding;
private NavController navController;
private final BroadcastReceiver forceLogoutReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Intent loginIntent = new Intent(HomeActivity.this, MainActivity.class);
loginIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(loginIntent);
finish();
}
};
// Launcher to ask for notification permission
private final ActivityResultLauncher<String> requestPermissionLauncher =
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
if (!isGranted) {
Log.w("HomeActivity", "Notification permission denied");
}
});
/**
* Sets up the home screen, initializes bottom navigation, and handles incoming navigation intents.
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_home);
super.onCreate(savedInstanceState);
binding = ActivityHomeBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
//get the bottom navbar from the layout
BottomNavigationView bottomNav = findViewById(R.id.bottom_navigation);
// Load ListFragment by default only if this is a fresh start
if (savedInstanceState == null) {
loadFragment(new ListFragment());
bottomNav.setSelectedItemId(R.id.nav_list);
// Initialize Navigation Component
NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager()
.findFragmentById(R.id.nav_host_fragment);
if (navHostFragment != null) {
navController = navHostFragment.getNavController();
NavigationUI.setupWithNavController(binding.bottomNavigation, navController);
}
//when an item in the bar is selected, load the corresponding fragment
bottomNav.setOnItemSelectedListener(item -> {
//load the list fragment by default if it's a fresh start
if (savedInstanceState == null) {
handleIntent(getIntent());
}
if (item.getItemId() == R.id.nav_list) {
loadFragment(new ListFragment());
return true;
} else if (item.getItemId() == R.id.nav_chat) {
loadFragment(new ChatFragment());
return true;
} else if (item.getItemId() == R.id.nav_profile) {
loadFragment(new ProfileFragment());
return true;
IntentFilter filter = new IntentFilter(TokenManager.ACTION_FORCE_LOGOUT);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(forceLogoutReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(forceLogoutReceiver, filter);
}
startNotificationService();
requestNotificationPermission();
}
@Override
protected void onDestroy() {
super.onDestroy();
unregisterReceiver(forceLogoutReceiver);
}
/**
* Handles new intents received while the activity is already running (like notifications).
*/
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleIntent(intent);
}
/**
* Processes the intent to determine if specific navigation (like opening a chat) is required.
*/
private void handleIntent(Intent intent) {
if (intent != null && "chat".equals(intent.getStringExtra("navigate_to"))) {
if (binding.bottomNavigation != null) {
// Navigate by selecting the bottom nav item.
binding.bottomNavigation.setSelectedItemId(R.id.nav_chat);
}
return false;
});
}
}
//helper function to load a fragment
private void loadFragment(Fragment fragment) {
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit();
/**
* Starts the background service responsible for monitoring chat notifications.
*/
private void startNotificationService() {
Intent serviceIntent = new Intent(this, ChatNotificationService.class);
startService(serviceIntent);
}
}
/**
* Requests for notification permission from the user if running on Android 13 and above.
*/
private void requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
}
}
}
}

View File

@@ -1,11 +1,14 @@
/*
* Entry point of the app that shows the login and registration screen.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.activities;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.view.inputmethod.EditorInfo;
import android.widget.Toast;
import androidx.activity.EdgeToEdge;
@@ -13,120 +16,142 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.lifecycle.ViewModelProvider;
import com.example.petstoremobile.R;
import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.dtos.AuthDTO;
import com.example.petstoremobile.databinding.ActivityMainBinding;
import com.example.petstoremobile.viewmodels.AuthViewModel;
import com.example.petstoremobile.utils.Resource;
import com.example.petstoremobile.utils.UIUtils;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import javax.inject.Inject;
import javax.inject.Named;
import dagger.hilt.android.AndroidEntryPoint;
//The login screen activity
@AndroidEntryPoint
public class MainActivity extends AppCompatActivity {
private EditText etUser;
private EditText etPassword;
private Button btnLogin;
private TextView tvLoginStatus;
private ActivityMainBinding binding;
private AuthViewModel viewModel;
@Inject TokenManager tokenManager;
@Inject @Named("baseUrl") String baseUrl;
/**
* Initializes the activity, sets up the UI, and checks for an existing login session.
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
EdgeToEdge.enable(this);
super.onCreate(savedInstanceState);
// Check if user is already logged in
if (TokenManager.getInstance(this).isLoggedIn()) {
Intent intent = new Intent(this, HomeActivity.class);
startActivity(intent);
finish();
return;
if (tokenManager.isLoggedIn()) {
if ("CUSTOMER".equalsIgnoreCase(tokenManager.getRole())) {
// If a customer somehow remained logged in, clear them out
tokenManager.clearLoginData();
} else {
startActivity(new Intent(this, HomeActivity.class));
finish();
return;
}
}
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
viewModel = new ViewModelProvider(this).get(AuthViewModel.class);
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
//get all controls from layout
tvLoginStatus = findViewById(R.id.tvLoginStatus);
etUser = findViewById(R.id.etUser);
etPassword = findViewById(R.id.etPassword);
btnLogin = findViewById(R.id.btnLogin);
//clear login status
tvLoginStatus.setText("");
binding.tvLoginStatus.setText("");
// Set editor action listener for password field to login on when enter is pressed
binding.etPassword.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_NULL) {
binding.btnLogin.performClick();
return true;
}
return false;
});
//Set click listener for login button
btnLogin.setOnClickListener(v -> {
binding.btnLogin.setOnClickListener(v -> {
//Get username and password from text fields
String username = etUser.getText().toString();
String password = etPassword.getText().toString();
String username = binding.etUser.getText().toString();
String password = binding.etPassword.getText().toString();
//check if fields are empty
if (username.isEmpty() || password.isEmpty()) {
Toast.makeText(this, "Please enter username and password", Toast.LENGTH_SHORT).show();
tvLoginStatus.setText("Please enter username and password");
binding.tvLoginStatus.setText("Please enter username and password");
return;
}
AuthApi authApi = RetrofitClient.getAuthApi(this);
performLogin(username, password);
});
//Call login from api and get response
authApi.login(new AuthDTO.LoginRequest(username,password)).enqueue(new Callback<AuthDTO.LoginResponse>() {
@Override
public void onResponse(Call<AuthDTO.LoginResponse> call, Response<AuthDTO.LoginResponse> response) {
if (response.isSuccessful() && response.body() != null) {
//save login data in shared preferences
TokenManager.getInstance(MainActivity.this).saveLoginData(
response.body().getToken(),
response.body().getUsername(),
response.body().getRole()
);
//fetch user id from api then login to home activity
RetrofitClient.getAuthApi(MainActivity.this).getCurrentUser()
.enqueue(new Callback<AuthDTO.UserResponse>() {
@Override
public void onResponse(Call<AuthDTO.UserResponse> call,
Response<AuthDTO.UserResponse> response) {
if (response.isSuccessful() && response.body() != null) {
TokenManager.getInstance(MainActivity.this)
.saveUserId(response.body().getId());
}
Toast.makeText(MainActivity.this, "Login successful", Toast.LENGTH_SHORT).show();
startActivity(new Intent(MainActivity.this, HomeActivity.class));
finish();
}
@Override
public void onFailure(Call<AuthDTO.UserResponse> call,
Throwable t) {
Log.e("MainActivity", "Failed to fetch userId", t);
Toast.makeText(MainActivity.this, "Login successful", Toast.LENGTH_SHORT).show();
startActivity(new Intent(MainActivity.this, HomeActivity.class));
finish();
}
});
} else {
Toast.makeText(MainActivity.this, "Login failed", Toast.LENGTH_SHORT).show();
tvLoginStatus.setText("Login failed");
}
}
@Override
public void onFailure(Call<AuthDTO.LoginResponse> call, Throwable t) {
Toast.makeText(MainActivity.this, "Login failed", Toast.LENGTH_SHORT).show();
tvLoginStatus.setText("Login failed");
}
});
// Set click listener for forgot password link
binding.tvForgotPassword.setOnClickListener(v -> {
startActivity(new Intent(this, ForgotPasswordActivity.class));
});
}
}
/**
* Perform login process using the AuthViewModel and handles the authentication response.
*/
private void performLogin(String username, String password) {
viewModel.login(username, password).observe(this, resource -> {
if (resource == null) return;
switch (resource.status) {
case LOADING:
UIUtils.setViewsEnabled(false, binding.btnLogin);
binding.tvLoginStatus.setText("Logging in...");
break;
case SUCCESS:
if (resource.data != null) {
String role = resource.data.getRole();
//Check if role is staff/admin or customer
if ("CUSTOMER".equalsIgnoreCase(role)) {
UIUtils.setViewsEnabled(true, binding.btnLogin);
binding.tvLoginStatus.setText("Customers are not allowed to log in");
Toast.makeText(this, "Access denied: Customers are not allowed to log in.", Toast.LENGTH_LONG).show();
} else {
tokenManager.saveLoginData(resource.data.getToken(), resource.data.getUsername(), role);
fetchUserIdAndNavigate();
}
}
break;
case ERROR:
UIUtils.setViewsEnabled(true, binding.btnLogin);
binding.tvLoginStatus.setText(resource.message);
Toast.makeText(this, resource.message, Toast.LENGTH_LONG).show();
break;
}
});
}
/**
* Retrieves the user's profile information to save their ID before navigating to the home screen.
*/
private void fetchUserIdAndNavigate() {
viewModel.getMe().observe(this, resource -> {
if (resource != null && resource.status != Resource.Status.LOADING) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
tokenManager.saveUserId(resource.data.getId());
tokenManager.savePrimaryStoreId(resource.data.getStoreId());
}
Toast.makeText(this, "Login successful", Toast.LENGTH_SHORT).show();
startActivity(new Intent(this, HomeActivity.class));
finish();
}
});
}
}

View File

@@ -0,0 +1,151 @@
/*
* Adapter for displaying activity log entries in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.dtos.ActivityLogDTO;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class ActivityLogAdapter extends RecyclerView.Adapter<ActivityLogAdapter.ViewHolder> {
private static final String SEPARATOR = " | ";
private static final SimpleDateFormat INPUT_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault());
private static final SimpleDateFormat OUTPUT_FORMAT = new SimpleDateFormat("MMM d, HH:mm", Locale.getDefault());
private final List<ActivityLogDTO> items;
/**
* Constructor for the ActivityLogAdapter.
*/
public ActivityLogAdapter(List<ActivityLogDTO> items) {
this.items = items;
}
/**
* Inflates the layout for an activity log item and creates a new ViewHolder.
*/
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_activity_log, parent, false);
return new ViewHolder(view);
}
/**
* Binds the data from ActivityLogDTO to the items in the ViewHolder.
*/
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
ActivityLogDTO log = items.get(position);
String activity = log.getActivity() != null ? log.getActivity() : "";
int separatorIndex = activity.indexOf(SEPARATOR);
if (separatorIndex >= 0) {
holder.tvActivity.setText(activity.substring(0, separatorIndex).trim());
holder.tvTechnical.setText(activity.substring(separatorIndex + SEPARATOR.length()).trim());
holder.tvTechnical.setVisibility(View.VISIBLE);
} else {
holder.tvActivity.setText(activity);
holder.tvTechnical.setVisibility(View.GONE);
}
String fullName = firstNonBlank(log.getFullName(), log.getFullNameSnapshot(), "Unknown");
String username = firstNonBlank(log.getUsername(), log.getUsernameSnapshot(), "");
holder.tvUser.setText(username.isEmpty() ? fullName : fullName + " (" + username + ")");
String role = firstNonBlank(log.getRole(), log.getRoleSnapshot(), "");
String store = firstNonBlank(log.getStoreName(), log.getStoreNameSnapshot(), "");
if (!role.isEmpty() && !store.isEmpty()) {
holder.tvMeta.setText(store + " · " + formatRole(role));
} else if (!role.isEmpty()) {
holder.tvMeta.setText(formatRole(role));
} else if (!store.isEmpty()) {
holder.tvMeta.setText(store);
} else {
holder.tvMeta.setText("");
}
holder.tvTimestamp.setText(formatTimestamp(log.getLogTimestamp()));
}
/**
* Returns the total number of items in the list.
*/
@Override
public int getItemCount() { return items.size(); }
/**
* Formats the timestamp string from the API to a readable date/time format.
*/
private String formatTimestamp(String raw) {
if (raw == null) return "";
try {
String normalized = raw.length() > 19 ? raw.substring(0, 19) : raw;
Date date = INPUT_FORMAT.parse(normalized);
return date != null ? OUTPUT_FORMAT.format(date) : raw.substring(0, Math.min(16, raw.length())).replace("T", " ");
} catch (ParseException e) {
return raw.length() >= 16 ? raw.substring(0, 16).replace("T", " ") : raw;
}
}
/**
* Format the Role string to be consistent
*/
private String formatRole(String role) {
if (role == null) return "";
switch (role.toUpperCase(Locale.ROOT)) {
case "ADMIN": return "Admin";
case "STAFF": return "Staff";
case "CUSTOMER": return "Customer";
default: return role;
}
}
/**
* Returns the first non-null and non-blank string from the provided arguments.
*/
private String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) return v;
}
return "";
}
/**
* ViewHolder class that holds references to the UI components for an activity log item.
*/
public static class ViewHolder extends RecyclerView.ViewHolder {
TextView tvActivity, tvTechnical, tvUser, tvMeta, tvTimestamp;
/**
* Initializes the ViewHolder by finding the views within the item layout.
*/
public ViewHolder(@NonNull View itemView) {
super(itemView);
tvActivity = itemView.findViewById(R.id.tvLogActivity);
tvTechnical = itemView.findViewById(R.id.tvLogTechnical);
tvUser = itemView.findViewById(R.id.tvLogUser);
tvMeta = itemView.findViewById(R.id.tvLogMeta);
tvTimestamp = itemView.findViewById(R.id.tvLogTimestamp);
}
}
}

View File

@@ -1,79 +1,153 @@
package com.example.petstoremobile.adapters;
/*
* Adapter for showing adoption records in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.graphics.Color;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.models.Adoption;
import com.example.petstoremobile.databinding.ItemAdoptionBinding;
import com.example.petstoremobile.dtos.AdoptionDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.SelectionHelper;
import java.util.List;
public class AdoptionAdapter extends RecyclerView.Adapter<AdoptionAdapter.AdoptionViewHolder> {
public class AdoptionAdapter extends RecyclerView.Adapter<AdoptionAdapter.AdoptionViewHolder> implements BulkDeleteHandler.SelectableAdapter {
private List<Adoption> adoptionList;
private OnAdoptionClickListener adoptionClickListener;
private List<AdoptionDTO> adoptionList;
private OnAdoptionClickListener listener;
private final SelectionHelper selectionHelper;
// Interface for adoption click on recycler view
public interface OnAdoptionClickListener {
void onAdoptionClick(int position);
void onSelectionChanged(int count);
}
// Constructor
public AdoptionAdapter(List<Adoption> adoptionList, OnAdoptionClickListener adoptionClickListener) {
/**
* Constructor for AdoptionAdapter.
*/
public AdoptionAdapter(List<AdoptionDTO> adoptionList, OnAdoptionClickListener listener) {
this.adoptionList = adoptionList;
this.adoptionClickListener = adoptionClickListener;
this.listener = listener;
this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() {
@Override
public void onSelectionChanged(int count) {
listener.onSelectionChanged(count);
}
@Override
public void onSelectionModeToggle(boolean selectionMode) {
notifyDataSetChanged();
}
});
}
// Get the controls of each row in recycler view
public static class AdoptionViewHolder extends RecyclerView.ViewHolder {
TextView tvAdopterName, tvPetName, tvAdoptionDate, tvAdoptionStatus;
/**
* Returns a list of IDs for the currently selected adoption items.
*/
@Override
public List<String> getSelectedKeys() {
return selectionHelper.getSelectedKeys();
}
public AdoptionViewHolder(@NonNull View v) {
super(v);
tvAdopterName = v.findViewById(R.id.tvAdopterName);
tvPetName = v.findViewById(R.id.tvAdoptionPetName);
tvAdoptionDate = v.findViewById(R.id.tvAdoptionDate);
tvAdoptionStatus = v.findViewById(R.id.tvAdoptionStatus);
/**
* Resets the selection state, deselecting all items.
*/
@Override
public void clearSelection() {
selectionHelper.clearSelection();
}
public static class AdoptionViewHolder extends RecyclerView.ViewHolder {
final ItemAdoptionBinding binding;
public AdoptionViewHolder(@NonNull ItemAdoptionBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
// Create a new row view
/**
* Inflates the layout for an adoption item and creates the ViewHolder.
*/
@NonNull
@Override
public AdoptionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_adoption, parent, false);
return new AdoptionViewHolder(v);
ItemAdoptionBinding binding = ItemAdoptionBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new AdoptionViewHolder(binding);
}
// Populate the row with adoption data
/**
* Binds adoption data to the UI components and handles click/long-click logic.
*/
@Override
public void onBindViewHolder(@NonNull AdoptionViewHolder holder, int position) {
Adoption adoption = adoptionList.get(position);
AdoptionDTO a = adoptionList.get(position);
ItemAdoptionBinding binding = holder.binding;
holder.tvAdopterName.setText(adoption.getAdopterName());
holder.tvPetName.setText("Pet: " + adoption.getPetName());
holder.tvAdoptionDate.setText("Date: " + adoption.getAdoptionDate());
holder.tvAdoptionStatus.setText(adoption.getStatus());
binding.tvAdoptionCustomerName.setText(a.getCustomerName() != null ? a.getCustomerName() : "");
binding.tvAdoptionPetName.setText("Pet: " + (a.getPetName() != null ? a.getPetName() : ""));
binding.tvAdoptionStaffName.setText("Staff: " + (a.getEmployeeName() != null ? a.getEmployeeName() : "N/A"));
binding.tvAdoptionDate.setText("Date: " + (a.getAdoptionDate() != null ? a.getAdoptionDate() : ""));
binding.tvAdoptionFee.setText(a.getAdoptionFee() != null ? "$" + a.getAdoptionFee() : "");
// Set the status color depending on adoption status
if (adoption.getStatus().equals("Approved")) {
holder.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#4CAF50"));
} else if (adoption.getStatus().equals("Pending")) {
holder.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#FF9800"));
} else {
holder.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#F44336"));
String status = a.getAdoptionStatus() != null ? a.getAdoptionStatus() : "";
binding.tvAdoptionStatus.setText(status);
switch (status) {
case "Completed":
binding.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#4CAF50"));
break;
case "Pending":
binding.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#FF9800"));
break;
case "Cancelled":
binding.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#F44336"));
break;
default:
binding.tvAdoptionStatus.setBackgroundColor(Color.parseColor("#9E9E9E"));
break;
}
// When a row is clicked, open the detail view
holder.itemView.setOnClickListener(v -> adoptionClickListener.onAdoptionClick(position));
String key = String.valueOf(a.getAdoptionId());
// Bulk delete selection mode
if (selectionHelper.isInSelectionMode()) {
binding.cbSelectAdoption.setVisibility(View.VISIBLE);
binding.cbSelectAdoption.setChecked(selectionHelper.isSelected(key));
} else {
binding.cbSelectAdoption.setVisibility(View.GONE);
binding.cbSelectAdoption.setChecked(false);
}
holder.itemView.setOnClickListener(v -> {
if (selectionHelper.isInSelectionMode()) {
selectionHelper.toggleSelection(key);
notifyItemChanged(position);
} else {
listener.onAdoptionClick(position);
}
});
holder.itemView.setOnLongClickListener(v -> {
if (!selectionHelper.isInSelectionMode()) {
selectionHelper.startSelection(key);
}
return true;
});
}
/**
* Returns the total number of adoption items in the list.
*/
@Override
public int getItemCount() {
return adoptionList.size();
}
public int getItemCount() { return adoptionList.size(); }
}

View File

@@ -1,84 +1,160 @@
/*
* Adapter for showing appointments in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.graphics.Color;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.models.Appointment;
import com.example.petstoremobile.databinding.ItemAppointmentBinding;
import com.example.petstoremobile.dtos.AppointmentDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.SelectionHelper;
import java.util.List;
public class AppointmentAdapter extends RecyclerView.Adapter<AppointmentAdapter.AppointmentViewHolder> {
public class AppointmentAdapter extends RecyclerView.Adapter<AppointmentAdapter.AppointmentViewHolder> implements BulkDeleteHandler.SelectableAdapter {
private List<Appointment> appointmentList;
private List<AppointmentDTO> appointmentList;
private OnAppointmentClickListener appointmentClickListener;
private final SelectionHelper selectionHelper;
// Interface for appointment click on recycler view
public interface OnAppointmentClickListener {
void onAppointmentClick(int position);
void onSelectionChanged(int count);
}
// Constructor
public AppointmentAdapter(List<Appointment> appointmentList, OnAppointmentClickListener appointmentClickListener) {
/**
* Constructor for AppointmentAdapter.
*/
public AppointmentAdapter(List<AppointmentDTO> appointmentList,
OnAppointmentClickListener appointmentClickListener) {
this.appointmentList = appointmentList;
this.appointmentClickListener = appointmentClickListener;
this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() {
@Override
public void onSelectionChanged(int count) {
appointmentClickListener.onSelectionChanged(count);
}
@Override
public void onSelectionModeToggle(boolean selectionMode) {
notifyDataSetChanged();
}
});
}
// Get the controls of each row in recycler view
public static class AppointmentViewHolder extends RecyclerView.ViewHolder {
TextView tvCustomerName, tvPetName, tvServiceType, tvDateTime, tvAppointmentStatus;
/**
* Returns a list of IDs for the currently selected appointment items.
*/
@Override
public List<String> getSelectedKeys() {
return selectionHelper.getSelectedKeys();
}
public AppointmentViewHolder(@NonNull View v) {
super(v);
tvCustomerName = v.findViewById(R.id.tvCustomerName);
tvPetName = v.findViewById(R.id.tvApptPetName);
tvServiceType = v.findViewById(R.id.tvServiceType);
tvDateTime = v.findViewById(R.id.tvDateTime);
tvAppointmentStatus = v.findViewById(R.id.tvAppointmentStatus);
/**
* Resets the selection state, deselecting all items.
*/
@Override
public void clearSelection() {
selectionHelper.clearSelection();
}
/**
* ViewHolder class that holds references to the UI components for an appointment item.
*/
public static class AppointmentViewHolder extends RecyclerView.ViewHolder {
private final ItemAppointmentBinding binding;
/**
* Initializes the ViewHolder by finding the views within the item layout.
*/
public AppointmentViewHolder(@NonNull ItemAppointmentBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
// Create a new row view
/**
* Inflates the layout for an appointment item and creates the ViewHolder.
*/
@NonNull
@Override
public AppointmentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_appointment, parent, false);
return new AppointmentViewHolder(v);
ItemAppointmentBinding binding = ItemAppointmentBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new AppointmentViewHolder(binding);
}
// Populate the row with appointment data
/**
* Binds appointment data to the UI components and handles click/long-click logic.
*/
@Override
public void onBindViewHolder(@NonNull AppointmentViewHolder holder, int position) {
Appointment appointment = appointmentList.get(position);
AppointmentDTO a = appointmentList.get(position);
ItemAppointmentBinding binding = holder.binding;
holder.tvCustomerName.setText(appointment.getCustomerName());
holder.tvPetName.setText("Pet: " + appointment.getPetName());
holder.tvServiceType.setText(appointment.getServiceType());
holder.tvDateTime.setText(appointment.getAppointmentDate() + " at " + appointment.getAppointmentTime());
holder.tvAppointmentStatus.setText(appointment.getStatus());
binding.tvCustomerName.setText(a.getCustomerName() != null ? a.getCustomerName() : "");
binding.tvApptPetName.setText("Pet: " + (a.getPetName() != null ? a.getPetName() : ""));
binding.tvServiceType.setText(a.getServiceType() != null ? a.getServiceType() : "");
binding.tvStaffName.setText("Staff: " + (a.getEmployeeName() != null ? a.getEmployeeName() : "Unassigned"));
binding.tvDateTime.setText((a.getAppointmentDate() != null ? a.getAppointmentDate() : "") +
" at " + (a.getAppointmentTime() != null ? a.getAppointmentTime() : ""));
// Set the status color depending on appointment status
switch (appointment.getStatus()) {
case "Confirmed":
holder.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#4CAF50"));
String status = a.getStatus() != null ? a.getStatus() : "";
binding.tvAppointmentStatus.setText(status);
switch (status.toUpperCase()) {
case "BOOKED":
binding.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#2196F3")); // blue
break;
case "Pending":
holder.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#FF9800"));
case "COMPLETED":
binding.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#4CAF50")); // green
break;
case "CANCELLED":
binding.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#F44336")); // red
break;
default:
holder.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#F44336"));
binding.tvAppointmentStatus.setBackgroundColor(Color.parseColor("#9E9E9E")); // gray
break;
}
// When a row is clicked, open the detail view
holder.itemView.setOnClickListener(v -> appointmentClickListener.onAppointmentClick(position));
String key = String.valueOf(a.getAppointmentId());
// Bulk delete selection mode
if (selectionHelper.isInSelectionMode()) {
binding.cbSelectAppointment.setVisibility(View.VISIBLE);
binding.cbSelectAppointment.setChecked(selectionHelper.isSelected(key));
} else {
binding.cbSelectAppointment.setVisibility(View.GONE);
binding.cbSelectAppointment.setChecked(false);
}
holder.itemView.setOnClickListener(v -> {
if (selectionHelper.isInSelectionMode()) {
selectionHelper.toggleSelection(key);
notifyItemChanged(position);
} else {
appointmentClickListener.onAppointmentClick(position);
}
});
holder.itemView.setOnLongClickListener(v -> {
if (!selectionHelper.isInSelectionMode()) {
selectionHelper.startSelection(key);
}
return true;
});
}
/**
* Returns the total number of appointment items in the list.
*/
@Override
public int getItemCount() {
return appointmentList.size();

View File

@@ -0,0 +1,52 @@
/*
* Custom array adapter that displays items with black text.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import com.example.petstoremobile.R;
import java.util.List;
// A class that overrides the arrayAdapter so the text color changes based on theme
// Used to make spinners have black text on white background no matter the theme
public class BlackTextArrayAdapter<T> extends ArrayAdapter<T> {
public BlackTextArrayAdapter(@NonNull Context context, int resource, @NonNull T[] objects) {
super(context, resource, objects);
}
public BlackTextArrayAdapter(@NonNull Context context, int resource, @NonNull List<T> objects) {
super(context, resource, objects);
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = super.getView(position, convertView, parent);
view.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.white));
if (view instanceof TextView) {
((TextView) view).setTextColor(ContextCompat.getColor(getContext(), R.color.spinner_text));
}
return view;
}
@Override
public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = super.getDropDownView(position, convertView, parent);
view.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.white));
if (view instanceof TextView) {
((TextView) view).setTextColor(ContextCompat.getColor(getContext(), R.color.spinner_text));
}
return view;
}
}

View File

@@ -1,14 +1,19 @@
/*
* Adapter for displaying chat conversations in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.databinding.ItemChatBinding;
import com.example.petstoremobile.models.Chat;
import java.util.List;
@@ -22,38 +27,55 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.ChatViewHolder
void onChatClick(Chat chat);
}
/**
* Constructor for ChatAdapter.
*/
public ChatAdapter(List<Chat> chatList, OnChatClickListener listener) {
this.chatList = chatList;
this.listener = listener;
}
/**
* Inflates the layout for a chat item and creates the ViewHolder.
*/
@NonNull
@Override
public ChatViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_chat, parent, false);
return new ChatViewHolder(view);
ItemChatBinding binding = ItemChatBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new ChatViewHolder(binding);
}
/**
* Binds chat data to the UI components for a specific conversation.
*/
@Override
public void onBindViewHolder(@NonNull ChatViewHolder holder, int position) {
Chat chat = chatList.get(position);
holder.tvCustomerName.setText(chat.getCustomerName());
holder.tvLastMessage.setText(chat.getLastMessage());
holder.binding.tvCustomerName.setText(chat.getCustomerName());
holder.binding.tvLastMessage.setText(chat.getLastMessage());
holder.itemView.setOnClickListener(v -> listener.onChatClick(chat));
}
/**
* Returns the total number of chat items in the list.
*/
@Override
public int getItemCount() {
return chatList.size();
}
/**
* ViewHolder class that holds references to the UI components for a chat item.
*/
public static class ChatViewHolder extends RecyclerView.ViewHolder {
TextView tvCustomerName, tvLastMessage;
final ItemChatBinding binding;
public ChatViewHolder(@NonNull View itemView) {
super(itemView);
tvCustomerName = itemView.findViewById(R.id.tvCustomerName);
tvLastMessage = itemView.findViewById(R.id.tvLastMessage);
/**
* Initializes the ViewHolder with the chat item's view binding.
*/
public ChatViewHolder(@NonNull ItemChatBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
}

View File

@@ -0,0 +1,169 @@
/*
* Adapter for showing coupons in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.dtos.CouponDTO;
import com.example.petstoremobile.utils.DateTimeUtils;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class CouponAdapter extends RecyclerView.Adapter<CouponAdapter.ViewHolder> {
private final List<CouponDTO> coupons;
private final OnCouponClickListener listener;
private boolean selectionMode = false;
private final Set<Long> selectedIds = new HashSet<>();
public interface OnCouponClickListener {
void onCouponClick(CouponDTO coupon);
void onSelectionChanged(int count);
}
/**
* Constructor for CouponAdapter.
*/
public CouponAdapter(List<CouponDTO> coupons, OnCouponClickListener listener) {
this.coupons = coupons;
this.listener = listener;
}
/**
* Inflates the layout for a coupon item and creates the ViewHolder.
*/
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_coupon, parent, false);
return new ViewHolder(view);
}
/**
* Binds coupon data to the UI components and handles interaction logic.
*/
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
CouponDTO coupon = coupons.get(position);
holder.tvCouponCode.setText(coupon.getCouponCode());
String discountText = "";
if ("PERCENT".equals(coupon.getDiscountType())) {
discountText = coupon.getDiscountValue().stripTrailingZeros().toPlainString() + "% OFF";
} else {
discountText = "$" + coupon.getDiscountValue().stripTrailingZeros().toPlainString() + " OFF";
}
holder.tvCouponDiscount.setText(discountText);
holder.tvCouponMinOrder.setText("Min order: $" + coupon.getMinOrderAmount().stripTrailingZeros().toPlainString());
if (coupon.getEndsAt() != null) {
holder.tvCouponExpiry.setText("Expires: " + DateTimeUtils.extractDate(coupon.getEndsAt()));
holder.tvCouponExpiry.setVisibility(View.VISIBLE);
} else {
holder.tvCouponExpiry.setVisibility(View.GONE);
}
if (Boolean.TRUE.equals(coupon.getActive())) {
holder.tvCouponStatus.setText("ACTIVE");
holder.tvCouponStatus.setBackgroundTintList(ContextCompat.getColorStateList(holder.itemView.getContext(), R.color.primary_dark));
} else {
holder.tvCouponStatus.setText("INACTIVE");
holder.tvCouponStatus.setBackgroundTintList(ContextCompat.getColorStateList(holder.itemView.getContext(), R.color.accent_coral));
}
holder.cbSelectCoupon.setVisibility(selectionMode ? View.VISIBLE : View.GONE);
holder.cbSelectCoupon.setChecked(selectedIds.contains(coupon.getCouponId()));
holder.itemView.setOnClickListener(v -> {
if (selectionMode) {
toggleSelection(coupon.getCouponId());
} else {
listener.onCouponClick(coupon);
}
});
holder.itemView.setOnLongClickListener(v -> {
if (!selectionMode) {
setSelectionMode(true);
toggleSelection(coupon.getCouponId());
return true;
}
return false;
});
holder.cbSelectCoupon.setOnClickListener(v -> toggleSelection(coupon.getCouponId()));
}
/**
* Toggles the selection state of a specific coupon by its ID.
*/
private void toggleSelection(Long id) {
if (selectedIds.contains(id)) {
selectedIds.remove(id);
} else {
selectedIds.add(id);
}
notifyDataSetChanged();
listener.onSelectionChanged(selectedIds.size());
}
/**
* Enables or disables bulk selection mode.
*/
public void setSelectionMode(boolean selectionMode) {
this.selectionMode = selectionMode;
if (!selectionMode) selectedIds.clear();
notifyDataSetChanged();
listener.onSelectionChanged(selectedIds.size());
}
/**
* Returns the set of IDs for the currently selected coupons.
*/
public Set<Long> getSelectedIds() {
return selectedIds;
}
/**
* Returns the total number of coupons in the list.
*/
@Override
public int getItemCount() {
return coupons.size();
}
public static class ViewHolder extends RecyclerView.ViewHolder {
TextView tvCouponCode, tvCouponDiscount, tvCouponMinOrder, tvCouponExpiry, tvCouponStatus;
CheckBox cbSelectCoupon;
/**
* Initializes the ViewHolder by finding the views within the item layout.
*/
public ViewHolder(@NonNull View itemView) {
super(itemView);
tvCouponCode = itemView.findViewById(R.id.tvCouponCode);
tvCouponDiscount = itemView.findViewById(R.id.tvCouponDiscount);
tvCouponMinOrder = itemView.findViewById(R.id.tvCouponMinOrder);
tvCouponExpiry = itemView.findViewById(R.id.tvCouponExpiry);
tvCouponStatus = itemView.findViewById(R.id.tvCouponStatus);
cbSelectCoupon = itemView.findViewById(R.id.cbSelectCoupon);
}
}
}

View File

@@ -0,0 +1,112 @@
/*
* Adapter for displaying customer entries in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.graphics.Color;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.api.UserApi;
import com.example.petstoremobile.databinding.ItemCustomerBinding;
import com.example.petstoremobile.dtos.CustomerDTO;
import com.example.petstoremobile.utils.GlideUtils;
import java.util.List;
public class CustomerAdapter extends RecyclerView.Adapter<CustomerAdapter.CustomerViewHolder> {
private List<CustomerDTO> list;
private OnCustomerClickListener listener;
private String baseUrl;
private String token;
public interface OnCustomerClickListener {
void onCustomerClick(int position);
}
/**
* Constructor for CustomerAdapter.
*/
public CustomerAdapter(List<CustomerDTO> list, OnCustomerClickListener listener) {
this.list = list;
this.listener = listener;
}
/**
* Sets the base URL for fetching customer profile images.
*/
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
/**
* Sets the authentication token for fetching images.
*/
public void setToken(String token) { this.token = token; }
/**
* ViewHolder class for customer items.
*/
public static class CustomerViewHolder extends RecyclerView.ViewHolder {
final ItemCustomerBinding binding;
/**
* Initializes the ViewHolder with view binding.
*/
public CustomerViewHolder(@NonNull ItemCustomerBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
/**
* Inflates the layout for a customer item and creates the ViewHolder.
*/
@NonNull
@Override
public CustomerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemCustomerBinding binding = ItemCustomerBinding.inflate(
LayoutInflater.from(parent.getContext()), parent, false);
return new CustomerViewHolder(binding);
}
/**
* Binds customer data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull CustomerViewHolder holder, int position) {
CustomerDTO c = list.get(position);
ItemCustomerBinding b = holder.binding;
b.tvCustomerFullName.setText(c.getFullName() != null ? c.getFullName() : "");
b.tvCustomerUsername.setText("@" + (c.getUsername() != null ? c.getUsername() : ""));
b.tvCustomerEmail.setText(c.getEmail() != null ? c.getEmail() : "");
int points = c.getLoyaltyPoints() != null ? c.getLoyaltyPoints() : 0;
b.tvCustomerLoyalty.setText(points + " pts");
boolean active = Boolean.TRUE.equals(c.getActive());
b.tvCustomerStatus.setText(active ? "Active" : "Inactive");
b.tvCustomerStatus.setTextColor(active ? Color.parseColor("#4CAF50") : Color.parseColor("#F44336"));
if (baseUrl != null && c.getCustomerId() != null) {
String imageUrl = baseUrl + String.format(UserApi.AVATAR_PATH, c.getCustomerId());
GlideUtils.loadImageWithTokenCircle(holder.itemView.getContext(), b.ivCustomerProfile, imageUrl, token, R.drawable.placeholder);
} else {
b.ivCustomerProfile.setImageResource(R.drawable.placeholder);
}
holder.itemView.setOnClickListener(v -> listener.onCustomerClick(position));
}
/**
* Returns the total number of customers in the list.
*/
@Override
public int getItemCount() { return list.size(); }
}

View File

@@ -0,0 +1,126 @@
/*
* Adapter for displaying employee entries in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.graphics.Color;
import android.view.*;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.api.UserApi;
import com.example.petstoremobile.databinding.ItemEmployeeBinding;
import com.example.petstoremobile.dtos.EmployeeDTO;
import com.example.petstoremobile.utils.GlideUtils;
import java.util.List;
public class EmployeeAdapter extends RecyclerView.Adapter<EmployeeAdapter.EmployeeViewHolder> {
private List<EmployeeDTO> list;
private OnEmployeeClickListener listener;
private String baseUrl;
private String token;
public interface OnEmployeeClickListener {
void onEmployeeClick(int position);
}
/**
* Constructor for EmployeeAdapter.
*/
public EmployeeAdapter(List<EmployeeDTO> list, OnEmployeeClickListener listener) {
this.list = list;
this.listener = listener;
}
/**
* Sets the base URL for fetching employee profile images.
*/
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
/**
* Sets the authentication token for fetching images.
*/
public void setToken(String token) {
this.token = token;
}
/**
* ViewHolder class for employee items.
*/
public static class EmployeeViewHolder extends RecyclerView.ViewHolder {
private final ItemEmployeeBinding binding;
/**
* Initializes the ViewHolder with view binding.
*/
public EmployeeViewHolder(@NonNull ItemEmployeeBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
/**
* Inflates the layout for an employee item and creates the ViewHolder.
*/
@NonNull
@Override
public EmployeeViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemEmployeeBinding binding = ItemEmployeeBinding.inflate(
LayoutInflater.from(parent.getContext()), parent, false);
return new EmployeeViewHolder(binding);
}
/**
* Binds employee data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull EmployeeViewHolder holder, int position) {
EmployeeDTO e = list.get(position);
ItemEmployeeBinding binding = holder.binding;
binding.tvEmployeeFullName.setText(e.getFullName() != null ? e.getFullName() : "");
binding.tvEmployeeUsername.setText("@" + (e.getUsername() != null ? e.getUsername() : ""));
binding.tvEmployeeEmail.setText(e.getEmail() != null ? e.getEmail() : "");
// Role badge
String role = e.getRole() != null ? e.getRole() : "STAFF";
binding.tvEmployeeRole.setText(role);
if ("ADMIN".equalsIgnoreCase(role)) {
binding.tvEmployeeRole.setBackgroundColor(Color.parseColor("#1a759f"));
} else {
binding.tvEmployeeRole.setBackgroundColor(Color.parseColor("#577590"));
}
// Status text and color
boolean active = Boolean.TRUE.equals(e.getActive());
binding.tvEmployeeStatus.setText(active ? "Active" : "Inactive");
binding.tvEmployeeStatus.setTextColor(active ? Color.parseColor("#4CAF50") : Color.parseColor("#F44336"));
// Profile image
if (baseUrl != null && e.getId() != null) {
String imageUrl = baseUrl + String.format(UserApi.AVATAR_PATH, e.getId());
GlideUtils.loadImageWithTokenCircle(holder.itemView.getContext(), binding.ivEmployeeProfile, imageUrl, token, R.drawable.placeholder);
} else {
binding.ivEmployeeProfile.setImageResource(R.drawable.placeholder);
}
holder.itemView.setOnClickListener(v -> listener.onEmployeeClick(position));
}
/**
* Returns the total number of employees in the list.
*/
@Override
public int getItemCount() { return list.size(); }
}

View File

@@ -1,77 +1,154 @@
package com.example.petstoremobile.adapters;
/*
* Adapter for showing inventory items in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.graphics.Color;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.models.Inventory;
import com.example.petstoremobile.databinding.ItemInventoryBinding;
import com.example.petstoremobile.dtos.InventoryDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.SelectionHelper;
import java.util.List;
public class InventoryAdapter extends RecyclerView.Adapter<InventoryAdapter.InventoryViewHolder> {
public class InventoryAdapter extends RecyclerView.Adapter<InventoryAdapter.InventoryViewHolder> implements BulkDeleteHandler.SelectableAdapter {
private List<Inventory> inventoryList;
private OnInventoryClickListener inventoryClickListener;
private final List<InventoryDTO> inventoryList;
private final OnInventoryClickListener clickListener;
private final SelectionHelper selectionHelper;
// Interface for inventory click on recycler view
public interface OnInventoryClickListener {
void onInventoryClick(int position);
void onSelectionChanged(int selectedCount);
}
// Constructor
public InventoryAdapter(List<Inventory> inventoryList, OnInventoryClickListener inventoryClickListener) {
/**
* Constructor for InventoryAdapter.
*/
public InventoryAdapter(List<InventoryDTO> inventoryList, OnInventoryClickListener clickListener) {
this.inventoryList = inventoryList;
this.inventoryClickListener = inventoryClickListener;
this.clickListener = clickListener;
this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() {
@Override
public void onSelectionChanged(int count) {
clickListener.onSelectionChanged(count);
}
@Override
public void onSelectionModeToggle(boolean selectionMode) {
notifyDataSetChanged();
}
});
}
// Get the controls of each row in recycler view
public static class InventoryViewHolder extends RecyclerView.ViewHolder {
TextView tvItemName, tvCategory, tvQuantity, tvUnitPrice, tvSupplier;
/**
* Returns a list of IDs for the currently selected inventory items.
*/
@Override
public List<String> getSelectedKeys() {
return selectionHelper.getSelectedKeys();
}
public InventoryViewHolder(@NonNull View v) {
super(v);
tvItemName = v.findViewById(R.id.tvItemName);
tvCategory = v.findViewById(R.id.tvCategory);
tvQuantity = v.findViewById(R.id.tvQuantity);
tvUnitPrice = v.findViewById(R.id.tvUnitPrice);
tvSupplier = v.findViewById(R.id.tvInvSupplier);
/**
* Resets the selection state, deselecting all items.
*/
@Override
public void clearSelection() {
selectionHelper.clearSelection();
}
/**
* ViewHolder class that holds references to the UI components for an inventory item.
*/
public static class InventoryViewHolder extends RecyclerView.ViewHolder {
final ItemInventoryBinding binding;
/**
* Initializes the ViewHolder with view binding.
*/
public InventoryViewHolder(@NonNull ItemInventoryBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
// Create a new row view
/**
* Inflates the layout for an inventory item and creates the ViewHolder.
*/
@NonNull
@Override
public InventoryViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_inventory, parent, false);
return new InventoryViewHolder(v);
ItemInventoryBinding binding = ItemInventoryBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new InventoryViewHolder(binding);
}
// Populate the row with inventory data
/**
* Binds inventory data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull InventoryViewHolder holder, int position) {
Inventory inventory = inventoryList.get(position);
InventoryDTO inv = inventoryList.get(position);
ItemInventoryBinding binding = holder.binding;
holder.tvItemName.setText(inventory.getItemName());
holder.tvCategory.setText(inventory.getCategory());
holder.tvQuantity.setText("Qty: " + inventory.getQuantity());
holder.tvUnitPrice.setText("$" + String.format("%.2f", inventory.getUnitPrice()));
holder.tvSupplier.setText("Supplier: " + inventory.getSupplier());
// Column: Product Name
binding.tvProductName.setText(inv.getProductName() != null ? inv.getProductName() : "");
// Highlight low stock items in red
if (inventory.getQuantity() <= 5) {
holder.tvQuantity.setTextColor(Color.parseColor("#F44336"));
// Column: Store Name
binding.tvInventoryStore.setText("Store: " + (inv.getStoreName() != null ? inv.getStoreName() : ""));
// Column: Quantity
int qty = inv.getQuantity() != null ? inv.getQuantity() : 0;
binding.tvQuantity.setText("Stock: " + qty);
if (qty <= 5) {
binding.tvQuantity.setTextColor(Color.parseColor("#F44336"));
} else {
holder.tvQuantity.setTextColor(Color.parseColor("#4CAF50"));
binding.tvQuantity.setTextColor(Color.parseColor("#4CAF50"));
}
// When a row is clicked, open the detail view
holder.itemView.setOnClickListener(v -> inventoryClickListener.onInventoryClick(position));
String key = String.valueOf(inv.getInventoryId());
// Bulk delete selection mode
if (selectionHelper.isInSelectionMode()) {
binding.cbSelectInventory.setVisibility(View.VISIBLE);
binding.cbSelectInventory.setChecked(selectionHelper.isSelected(key));
} else {
binding.cbSelectInventory.setVisibility(View.GONE);
binding.cbSelectInventory.setChecked(false);
}
holder.itemView.setOnClickListener(v -> {
if (selectionHelper.isInSelectionMode()) {
selectionHelper.toggleSelection(key);
notifyItemChanged(position);
} else {
clickListener.onInventoryClick(holder.getAdapterPosition());
}
});
holder.itemView.setOnLongClickListener(v -> {
if (!selectionHelper.isInSelectionMode()) {
selectionHelper.startSelection(key);
}
return true;
});
}
/**
* Returns the total number of inventory items in the list.
*/
@Override
public int getItemCount() {
return inventoryList.size();

View File

@@ -1,33 +1,107 @@
/*
* Adapter for displaying chat messages in a conversation.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.model.GlideUrl;
import com.bumptech.glide.load.model.LazyHeaders;
import com.bumptech.glide.signature.ObjectKey;
import com.example.petstoremobile.R;
import com.example.petstoremobile.databinding.ItemMessageReceivedBinding;
import com.example.petstoremobile.databinding.ItemMessageSentBinding;
import com.example.petstoremobile.models.Message;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final int TYPE_SENT = 1;
private static final int TYPE_RECEIVED = 2;
public interface OnAttachmentClickListener {
void onAttachmentClick(Message message);
}
private final List<Message> messages;
private Long currentUserId;
private Long staffId;
private String token;
private String baseUrl;
private OnAttachmentClickListener attachmentClickListener;
/**
* Constructor for MessageAdapter.
*/
public MessageAdapter(List<Message> messages, Long currentUserId) {
this.messages = messages;
this.currentUserId = currentUserId;
setHasStableIds(true);
}
/**
* Returns an ID for each message.
*/
@Override
public long getItemId(int position) {
Message m = messages.get(position);
return m.getId() != null ? m.getId() : position;
}
/**
* Updates the current user's ID and refreshes the list.
*/
public void setCurrentUserId(Long id) {
this.currentUserId = id;
notifyDataSetChanged();
}
/**
* Updates the staff ID to identify staff messages in the UI.
*/
public void setStaffId(Long id) {
this.staffId = id;
notifyDataSetChanged();
}
/**
* Sets the authentication token.
*/
public void setToken(String token) {
this.token = token;
}
/**
* Sets the base API URL.
*/
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
/**
* Sets a listener for clicks on message attachments.
*/
public void setOnAttachmentClickListener(OnAttachmentClickListener listener) {
this.attachmentClickListener = listener;
}
/**
* Determines if a message is 'sent' or 'received' based on the sender's ID.
*/
@Override
public int getItemViewType(int position) {
Message m = messages.get(position);
@@ -37,42 +111,191 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
return TYPE_RECEIVED;
}
/**
* Inflates the chat layout for a message.
*/
@NonNull @Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inf = LayoutInflater.from(parent.getContext());
if (viewType == TYPE_SENT) {
View v = inf.inflate(R.layout.item_message_sent, parent, false);
return new SentHolder(v);
ItemMessageSentBinding binding = ItemMessageSentBinding.inflate(inf, parent, false);
return new SentHolder(binding);
} else {
View v = inf.inflate(R.layout.item_message_received, parent, false);
return new ReceivedHolder(v);
ItemMessageReceivedBinding binding = ItemMessageReceivedBinding.inflate(inf, parent, false);
return new ReceivedHolder(binding);
}
}
/**
* Binds message data to the appropriate ViewHolder.
*/
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
Message m = messages.get(position);
if (holder instanceof SentHolder) ((SentHolder) holder).bind(m);
if (holder instanceof ReceivedHolder) ((ReceivedHolder) holder).bind(m);
if (holder instanceof SentHolder) ((SentHolder) holder).bind(m, token, baseUrl, attachmentClickListener);
if (holder instanceof ReceivedHolder) ((ReceivedHolder) holder).bind(m, token, baseUrl, attachmentClickListener, staffId);
}
/**
* Returns the total number of messages.
*/
@Override public int getItemCount() { return messages.size(); }
/**
* ViewHolder for messages sent by the user.
*/
static class SentHolder extends RecyclerView.ViewHolder {
TextView tvMessage;
SentHolder(View v) {
super(v);
tvMessage = v.findViewById(R.id.tvMessageContent); // updated
final ItemMessageSentBinding binding;
/**
* Initializes the SentHolder with view binding.
*/
SentHolder(ItemMessageSentBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
/**
* Binds sent message data to the bubble UI.
*/
void bind(Message m, String token, String baseUrl, OnAttachmentClickListener listener) {
binding.tvSenderName.setText("You");
binding.tvTimestamp.setText(formatTimestamp(m.getTimestamp()));
if (m.getContent() != null && !m.getContent().isEmpty()) {
binding.tvMessageContent.setVisibility(View.VISIBLE);
binding.tvMessageContent.setText(m.getContent());
} else {
binding.tvMessageContent.setVisibility(View.GONE);
}
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token, baseUrl);
View.OnClickListener click = v -> {
if (listener != null) listener.onAttachmentClick(m);
};
binding.ivAttachment.setOnClickListener(click);
binding.tvAttachmentName.setOnClickListener(click);
}
void bind(Message m) { tvMessage.setText(m.getContent()); }
}
/**
* ViewHolder for messages received from others.
*/
static class ReceivedHolder extends RecyclerView.ViewHolder {
TextView tvMessage;
ReceivedHolder(View v) {
super(v);
tvMessage = v.findViewById(R.id.tvMessageContent); // updated
final ItemMessageReceivedBinding binding;
/**
* Initializes the ReceivedHolder with view binding.
*/
ReceivedHolder(ItemMessageReceivedBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
/**
* Binds received message data to the bubble UI.
*/
void bind(Message m, String token, String baseUrl, OnAttachmentClickListener listener, Long staffId) {
binding.tvSenderName.setText(resolveSenderName(m, staffId));
binding.tvTimestamp.setText(formatTimestamp(m.getTimestamp()));
if (m.getContent() != null && !m.getContent().isEmpty()) {
binding.tvMessageContent.setVisibility(View.VISIBLE);
binding.tvMessageContent.setText(m.getContent());
} else {
binding.tvMessageContent.setVisibility(View.GONE);
}
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token, baseUrl);
View.OnClickListener click = v -> {
if (listener != null) listener.onAttachmentClick(m);
};
binding.ivAttachment.setOnClickListener(click);
binding.tvAttachmentName.setOnClickListener(click);
}
}
/**
* Resolves the display name of the sender.
*/
private static String resolveSenderName(Message m, Long staffId) {
if ("BOT".equalsIgnoreCase(m.getSenderRole())) {
return (m.getSenderDisplayName() != null && !m.getSenderDisplayName().isEmpty())
? m.getSenderDisplayName() : "AI Bot";
}
if (staffId != null && staffId.equals(m.getSenderId())) {
return "Staff";
}
return "Customer";
}
/**
* Formats the timestamp string into readable format.
*/
private static String formatTimestamp(String timestamp) {
if (timestamp == null || timestamp.isEmpty()) return "";
try {
String normalized = timestamp.length() > 19 ? timestamp.substring(0, 19) : timestamp;
SimpleDateFormat input = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault());
Date date = input.parse(normalized);
return new SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()).format(date);
} catch (Exception e) {
return "";
}
}
/**
* Logic for displaying an attachment in the chat.
*/
private static void displayAttachment(Message m, ImageView iv, TextView tvName, String token, String baseUrl) {
// Check if there's an attachment
if (m.getAttachmentName() != null || m.getAttachmentMimeType() != null) {
// Construct the download URL using the message ID
String url;
if (baseUrl != null) {
String cleanBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
url = cleanBase + "/api/v1/chat/messages/" + m.getId() + "/attachment";
} else {
url = m.getAttachmentUrl();
}
if (url == null) {
Glide.with(iv.getContext()).clear(iv);
iv.setVisibility(View.GONE);
tvName.setVisibility(View.GONE);
return;
}
if (m.getAttachmentMimeType() != null && m.getAttachmentMimeType().startsWith("image/")) {
iv.setVisibility(View.VISIBLE);
tvName.setVisibility(View.GONE);
Object loadTarget = url;
if (token != null) {
loadTarget = new GlideUrl(url, new LazyHeaders.Builder()
.addHeader("Authorization", "Bearer " + token)
.build());
}
// Use a signature to prevent Glide from showing cached images instead of loading new ones
String signatureKey = (m.getTimestamp() != null ? m.getTimestamp() : "") + m.getId();
Glide.with(iv.getContext()).clear(iv);
Glide.with(iv.getContext())
.load(loadTarget)
.signature(new ObjectKey(signatureKey))
.diskCacheStrategy(DiskCacheStrategy.ALL)
.placeholder(R.drawable.placeholder)
.error(R.drawable.placeholder)
.into(iv);
} else {
Glide.with(iv.getContext()).clear(iv);
iv.setVisibility(View.GONE);
tvName.setVisibility(View.VISIBLE);
tvName.setText(m.getAttachmentName() != null ? m.getAttachmentName() : "Attachment");
}
} else {
Glide.with(iv.getContext()).clear(iv);
iv.setVisibility(View.GONE);
tvName.setVisibility(View.GONE);
}
void bind(Message m) { tvMessage.setText(m.getContent()); }
}
}

View File

@@ -1,85 +1,197 @@
/*
* Adapter for showing pets in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.graphics.Color;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.api.PetApi;
import com.example.petstoremobile.databinding.ItemPetBinding;
import com.example.petstoremobile.dtos.PetDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.GlideUtils;
import com.example.petstoremobile.utils.SelectionHelper;
import java.util.List;
public class PetAdapter extends RecyclerView.Adapter<PetAdapter.PetViewHolder> {
public class PetAdapter extends RecyclerView.Adapter<PetAdapter.PetViewHolder> implements BulkDeleteHandler.SelectableAdapter {
private List<PetDTO> petList;
private OnPetClickListener petClickListener;
private String baseUrl;
private String token;
private final SelectionHelper selectionHelper;
// Interface for pet click on recycler view
public interface OnPetClickListener {
void onPetClick(int position);
void onSelectionChanged(int selectedCount);
}
//Constructor
/**
* Constructor for PetAdapter.
*/
public PetAdapter(List<PetDTO> petList, OnPetClickListener petClickListener) {
this.petList = petList;
this.petClickListener = petClickListener;
this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() {
@Override
public void onSelectionChanged(int count) {
petClickListener.onSelectionChanged(count);
}
@Override
public void onSelectionModeToggle(boolean selectionMode) {
notifyDataSetChanged();
}
});
}
// Get the controls of each row in recycler view
public static class PetViewHolder extends RecyclerView.ViewHolder {
TextView tvPetName, tvPetSpeciesBreed, tvPetAge, tvPetPrice, tvPetStatus;
/**
* Sets the base URL for fetching pet images from the server.
*/
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public PetViewHolder(@NonNull View v) {
super(v);
tvPetName = v.findViewById(R.id.tvPetName);
tvPetSpeciesBreed = v.findViewById(R.id.tvPetSpeciesBreed);
tvPetAge = v.findViewById(R.id.tvPetAge);
tvPetPrice = v.findViewById(R.id.tvPetPrice);
tvPetStatus = v.findViewById(R.id.tvPetStatus);
/**
* Sets the authentication token
*/
public void setToken(String token) {
this.token = token;
}
/**
* Returns a list of IDs for the currently selected pet items.
*/
@Override
public List<String> getSelectedKeys() {
return selectionHelper.getSelectedKeys();
}
/**
* Resets the selection state, deselecting all items.
*/
@Override
public void clearSelection() {
selectionHelper.clearSelection();
}
/**
* ViewHolder class that holds references to the UI components for a pet item.
*/
public static class PetViewHolder extends RecyclerView.ViewHolder {
private final ItemPetBinding binding;
/**
* Initializes the ViewHolder with view binding.
*/
public PetViewHolder(@NonNull ItemPetBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
// Create a new row view
/**
* Inflates the layout for a pet item and creates the ViewHolder.
*/
@NonNull
@Override
public PetViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_pet, parent, false);
return new PetViewHolder(v);
ItemPetBinding binding = ItemPetBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new PetViewHolder(binding);
}
//populate the row with pet data
/**
* Binds pet data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull PetViewHolder holder, int position) {
PetDTO pet = petList.get(position);
ItemPetBinding binding = holder.binding;
holder.tvPetName.setText(pet.getPetName());
holder.tvPetSpeciesBreed.setText(pet.getPetSpecies() + " - " + pet.getPetBreed());
holder.tvPetAge.setText("Age: " + pet.getPetAge() + " yr(s)");
binding.tvPetName.setText(pet.getPetName());
binding.tvPetSpeciesBreed.setText(pet.getPetSpecies() + " - " + pet.getPetBreed());
binding.tvPetAge.setText("Age: " + pet.getPetAge() + " yr(s)");
try {
double price = Double.parseDouble(pet.getPetPrice());
holder.tvPetPrice.setText("$" + String.format("%.2f", price));
} catch (Exception e) {
holder.tvPetPrice.setText("$" + pet.getPetPrice());
Double price = pet.getPetPrice();
if (price != null) {
binding.tvPetPrice.setText("$" + String.format("%.2f", price));
} else {
binding.tvPetPrice.setText("$0.00");
}
holder.tvPetStatus.setText(pet.getPetStatus());
binding.tvPetStatus.setText(pet.getPetStatus());
//Set the status color depending on availability. If available, green, otherwise red
if (pet.getPetStatus() != null && pet.getPetStatus().equals("Available")) {
holder.tvPetStatus.setBackgroundColor(Color.parseColor("#4CAF50"));
//Set the status color depending on availability
if (pet.getPetStatus() != null) {
switch (pet.getPetStatus()) {
case "Available":
binding.tvPetStatus.setBackgroundColor(Color.parseColor("#4CAF50"));
break;
case "Pending":
binding.tvPetStatus.setBackgroundColor(Color.parseColor("#FF9800"));
break;
default:
binding.tvPetStatus.setBackgroundColor(Color.parseColor("#F44336"));
break;
}
} else {
holder.tvPetStatus.setBackgroundColor(Color.parseColor("#F44336"));
binding.tvPetStatus.setBackgroundColor(Color.parseColor("#9E9E9E"));
}
// Load pet image using Glide
if (baseUrl != null) {
String imageUrl = baseUrl + String.format(PetApi.PET_IMAGE_PATH, pet.getPetId());
GlideUtils.loadImageWithTokenCircle(holder.itemView.getContext(), binding.ivPetProfile, imageUrl, token, R.drawable.placeholder);
} else {
binding.ivPetProfile.setImageResource(R.drawable.placeholder);
}
String key = String.valueOf(pet.getPetId());
// Bulk delete selection mode
if (selectionHelper.isInSelectionMode()) {
binding.cbSelectPet.setVisibility(View.VISIBLE);
binding.cbSelectPet.setChecked(selectionHelper.isSelected(key));
} else {
binding.cbSelectPet.setVisibility(View.GONE);
binding.cbSelectPet.setChecked(false);
}
//when a row is clicked, open the detail view
holder.itemView.setOnClickListener(v -> petClickListener.onPetClick(position));
holder.itemView.setOnClickListener(v -> {
if (selectionHelper.isInSelectionMode()) {
selectionHelper.toggleSelection(key);
notifyItemChanged(position);
} else {
petClickListener.onPetClick(position);
}
});
holder.itemView.setOnLongClickListener(v -> {
if (!selectionHelper.isInSelectionMode()) {
selectionHelper.startSelection(key);
}
return true;
});
}
/**
* Returns the total number of pet items in the list.
*/
@Override
public int getItemCount() {
return petList.size();
}
}
}

View File

@@ -1,72 +1,108 @@
/*
* Adapter for displaying products in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.view.*;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.models.Product;
import com.example.petstoremobile.api.ProductApi;
import com.example.petstoremobile.databinding.ItemProductBinding;
import com.example.petstoremobile.dtos.ProductDTO;
import com.example.petstoremobile.utils.GlideUtils;
import java.util.List;
public class ProductAdapter extends RecyclerView.Adapter<ProductAdapter.ProductViewHolder> {
private List<Product> productList;
private OnProductClickListener productClickListener;
private List<ProductDTO> productList;
private OnProductClickListener listener;
private String baseUrl;
private String token;
// Interface for product click on recycler view
public interface OnProductClickListener {
void onProductClick(int position);
}
// Constructor
public ProductAdapter(List<Product> productList, OnProductClickListener productClickListener) {
/**
* Constructor for ProductAdapter.
*/
public ProductAdapter(List<ProductDTO> productList, OnProductClickListener listener) {
this.productList = productList;
this.productClickListener = productClickListener;
this.listener = listener;
}
// Get the controls of each row in recycler view
public static class ProductViewHolder extends RecyclerView.ViewHolder {
TextView tvProductName, tvProductDesc, tvCategory, tvProductPrice, tvStockQuantity;
/**
* Sets the base URL for fetching product images.
*/
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public ProductViewHolder(@NonNull View v) {
super(v);
tvProductName = v.findViewById(R.id.tvProductName);
tvProductDesc = v.findViewById(R.id.tvProductDesc);
tvCategory = v.findViewById(R.id.tvProductCategory);
tvProductPrice = v.findViewById(R.id.tvProductPrice);
tvStockQuantity = v.findViewById(R.id.tvStockQuantity);
/**
* Sets the authentication token
*/
public void setToken(String token) {
this.token = token;
}
/**
* ViewHolder class for product items.
*/
public static class ProductViewHolder extends RecyclerView.ViewHolder {
final ItemProductBinding binding;
/**
* Initializes the ViewHolder with view binding.
*/
public ProductViewHolder(@NonNull ItemProductBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
// Create a new row view
/**
* Inflates the layout for a product item and creates the ViewHolder.
*/
@NonNull
@Override
public ProductViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_product, parent, false);
return new ProductViewHolder(v);
ItemProductBinding binding = ItemProductBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new ProductViewHolder(binding);
}
// Populate the row with product data
/**
* Binds product data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull ProductViewHolder holder, int position) {
Product product = productList.get(position);
ProductDTO p = productList.get(position);
ItemProductBinding binding = holder.binding;
holder.tvProductName.setText(product.getProductName());
holder.tvProductDesc.setText(product.getProductDesc());
holder.tvCategory.setText(product.getCategory());
holder.tvProductPrice.setText("$" + String.format("%.2f", product.getProductPrice()));
holder.tvStockQuantity.setText("Stock: " + product.getStockQuantity());
binding.tvProductName.setText(p.getProdName() != null ? p.getProdName() : "");
binding.tvProductCategory.setText("Category: " + (p.getCategoryName() != null ? p.getCategoryName() : ""));
binding.tvProductDesc.setText(p.getProdDesc() != null ? p.getProdDesc() : "");
binding.tvProductPrice.setText(p.getProdPrice() != null ? "$" + p.getProdPrice() : "");
// When a row is clicked, open the detail view
holder.itemView.setOnClickListener(v -> productClickListener.onProductClick(position));
// Load product image using Glide
if (baseUrl != null) {
String imageUrl = baseUrl + String.format(ProductApi.PRODUCT_IMAGE_PATH, p.getProdId());
GlideUtils.loadImageWithTokenCircle(holder.itemView.getContext(), binding.ivProductImage, imageUrl, token, R.drawable.placeholder);
} else {
binding.ivProductImage.setImageResource(R.drawable.placeholder);
}
holder.itemView.setOnClickListener(v -> listener.onProductClick(position));
}
/**
* Returns the total number of product items in the list.
*/
@Override
public int getItemCount() {
return productList.size();
}
}
public int getItemCount() { return productList.size(); }
}

View File

@@ -0,0 +1,137 @@
/*
* Adapter for showing product-supplier links in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.databinding.ItemProductSupplierBinding;
import com.example.petstoremobile.dtos.ProductSupplierDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.SelectionHelper;
import java.util.List;
public class ProductSupplierAdapter extends RecyclerView.Adapter<ProductSupplierAdapter.PSViewHolder> implements BulkDeleteHandler.SelectableAdapter {
private final List<ProductSupplierDTO> list;
private final OnProductSupplierClickListener listener;
private final SelectionHelper selectionHelper;
public interface OnProductSupplierClickListener {
void onProductSupplierClick(int position);
void onSelectionChanged(int count);
}
/**
* Constructor for ProductSupplierAdapter.
*/
public ProductSupplierAdapter(List<ProductSupplierDTO> list, OnProductSupplierClickListener listener) {
this.list = list;
this.listener = listener;
this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() {
@Override
public void onSelectionChanged(int count) {
listener.onSelectionChanged(count);
}
@Override
public void onSelectionModeToggle(boolean selectionMode) {
notifyDataSetChanged();
}
});
}
/**
* Returns the list of selected keys for bulk deletion.
*/
@Override
public List<String> getSelectedKeys() {
return selectionHelper.getSelectedKeys();
}
/**
* Clears all selected items and exits selection mode.
*/
@Override
public void clearSelection() {
selectionHelper.clearSelection();
}
/**
* ViewHolder for Product-Supplier relationship items.
*/
public static class PSViewHolder extends RecyclerView.ViewHolder {
final ItemProductSupplierBinding binding;
/**
* Initializes the ViewHolder with view binding.
*/
public PSViewHolder(@NonNull ItemProductSupplierBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
/**
* Inflates the layout for a Product-Supplier item.
*/
@NonNull
@Override
public PSViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemProductSupplierBinding binding = ItemProductSupplierBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new PSViewHolder(binding);
}
/**
* Binds product-supplier data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull PSViewHolder holder, int position) {
ProductSupplierDTO ps = list.get(position);
ItemProductSupplierBinding binding = holder.binding;
binding.tvPSProductName.setText(ps.getProductName() != null ? ps.getProductName() : "");
binding.tvPSSupplierName.setText("Supplier: " + (ps.getSupplierName() != null ? ps.getSupplierName() : ""));
binding.tvPSCost.setText(ps.getCost() != null ? "Cost: $" + ps.getCost() : "");
String key = ps.getProductId() + "-" + ps.getSupplierId();
// Bulk delete selection mode
if (selectionHelper.isInSelectionMode()) {
binding.cbSelectProductSupplier.setVisibility(View.VISIBLE);
binding.cbSelectProductSupplier.setChecked(selectionHelper.isSelected(key));
} else {
binding.cbSelectProductSupplier.setVisibility(View.GONE);
binding.cbSelectProductSupplier.setChecked(false);
}
holder.itemView.setOnClickListener(v -> {
if (selectionHelper.isInSelectionMode()) {
selectionHelper.toggleSelection(key);
notifyItemChanged(position);
} else {
listener.onProductSupplierClick(position);
}
});
holder.itemView.setOnLongClickListener(v -> {
if (!selectionHelper.isInSelectionMode()) {
selectionHelper.startSelection(key);
}
return true;
});
}
@Override
public int getItemCount() { return list.size(); }
}

View File

@@ -0,0 +1,80 @@
/*
* Adapter for displaying purchase orders in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.databinding.ItemPurchaseOrderBinding;
import com.example.petstoremobile.dtos.PurchaseOrderDTO;
import java.util.List;
public class PurchaseOrderAdapter extends RecyclerView.Adapter<PurchaseOrderAdapter.POViewHolder> {
private List<PurchaseOrderDTO> list;
private OnPurchaseOrderClickListener listener;
public interface OnPurchaseOrderClickListener {
void onPurchaseOrderClick(int position);
}
/**
* Constructor for PurchaseOrderAdapter.
*/
public PurchaseOrderAdapter(List<PurchaseOrderDTO> list, OnPurchaseOrderClickListener listener) {
this.list = list;
this.listener = listener;
}
/**
* ViewHolder for Purchase Order items.
*/
public static class POViewHolder extends RecyclerView.ViewHolder {
final ItemPurchaseOrderBinding binding;
/**
* Initializes the ViewHolder with view binding.
*/
public POViewHolder(@NonNull ItemPurchaseOrderBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
/**
* Inflates the layout for a Purchase Order item.
*/
@NonNull
@Override
public POViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemPurchaseOrderBinding binding = ItemPurchaseOrderBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new POViewHolder(binding);
}
/**
* Binds purchase order data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull POViewHolder holder, int position) {
PurchaseOrderDTO po = list.get(position);
ItemPurchaseOrderBinding binding = holder.binding;
binding.tvPOId.setText("PO #" + (po.getPurchaseOrderId() != null ? po.getPurchaseOrderId() : ""));
binding.tvPOSupplier.setText("Supplier: " + (po.getSupplierName() != null ? po.getSupplierName() : ""));
binding.tvPOStore.setText("Store: " + (po.getStoreName() != null ? po.getStoreName() : ""));
binding.tvPODate.setText("Date: " + (po.getOrderDate() != null ? po.getOrderDate() : ""));
holder.itemView.setOnClickListener(v -> listener.onPurchaseOrderClick(position));
}
@Override
public int getItemCount() {
return list.size();
}
}

View File

@@ -0,0 +1,98 @@
/*
* Adapter for showing sales in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.databinding.ItemSaleBinding;
import com.example.petstoremobile.dtos.SaleDTO;
import java.util.List;
public class SaleAdapter extends RecyclerView.Adapter<SaleAdapter.SaleViewHolder> {
private List<SaleDTO> saleList;
private OnSaleClickListener listener;
public interface OnSaleClickListener {
void onSaleClick(int position);
}
/**
* Constructor for SaleAdapter.
*/
public SaleAdapter(List<SaleDTO> saleList, OnSaleClickListener listener) {
this.saleList = saleList;
this.listener = listener;
}
/**
* ViewHolder for Sale record items.
*/
public static class SaleViewHolder extends RecyclerView.ViewHolder {
final ItemSaleBinding binding;
/**
* Initializes the ViewHolder with view binding.
*/
public SaleViewHolder(@NonNull ItemSaleBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
/**
* Inflates the layout for a Sale record item.
*/
@NonNull
@Override
public SaleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemSaleBinding binding = ItemSaleBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new SaleViewHolder(binding);
}
/**
* Binds sale transaction data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull SaleViewHolder holder, int position) {
SaleDTO s = saleList.get(position);
ItemSaleBinding binding = holder.binding;
binding.tvSaleId.setText("Sale #" + (s.getSaleId() != null ? s.getSaleId() : ""));
binding.tvSaleEmployee.setText("By: " + (s.getEmployeeName() != null ? s.getEmployeeName() : ""));
if (s.getCustomerName() != null && !s.getCustomerName().isEmpty()) {
binding.tvSaleCustomer.setText("Customer: " + s.getCustomerName());
binding.tvSaleCustomer.setVisibility(View.VISIBLE);
} else {
binding.tvSaleCustomer.setVisibility(View.GONE);
}
binding.tvSaleDate.setText(s.getSaleDate() != null ? s.getSaleDate().substring(0, Math.min(10, s.getSaleDate().length())) : "");
binding.tvSalePayment.setText(s.getPaymentMethod() != null ? s.getPaymentMethod() : "");
binding.tvSaleTotal.setText(s.getTotalAmount() != null ? "$" + s.getTotalAmount() : "");
if (Boolean.TRUE.equals(s.getIsRefund())) {
binding.tvSaleRefundBadge.setVisibility(View.VISIBLE);
binding.tvSaleTotal.setTextColor(ContextCompat.getColor(holder.itemView.getContext(), R.color.status_adopted));
} else {
binding.tvSaleRefundBadge.setVisibility(View.GONE);
binding.tvSaleTotal.setTextColor(ContextCompat.getColor(holder.itemView.getContext(), R.color.status_available));
}
holder.itemView.setOnClickListener(v -> listener.onSaleClick(position));
}
@Override
public int getItemCount() {
return saleList.size();
}
}

View File

@@ -1,68 +1,146 @@
/*
* Adapter for displaying services in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.databinding.ItemServiceBinding;
import com.example.petstoremobile.dtos.ServiceDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.SelectionHelper;
import java.util.List;
public class ServiceAdapter extends RecyclerView.Adapter<ServiceAdapter.ServiceViewHolder> {
/**
* Adapter class for displaying a list of services in a RecyclerView.
*/
public class ServiceAdapter extends RecyclerView.Adapter<ServiceAdapter.ServiceViewHolder> implements BulkDeleteHandler.SelectableAdapter {
private List<ServiceDTO> serviceList;
private OnServiceClickListener serviceClickListener;
private final List<ServiceDTO> serviceList;
private final OnServiceClickListener clickListener;
private final SelectionHelper selectionHelper;
// Interface for service click on recycler view
/**
* Interface for handling clicks on service items.
*/
public interface OnServiceClickListener {
void onServiceClick(int position);
void onSelectionChanged(int count);
}
//Constructor
public ServiceAdapter(List<ServiceDTO> serviceList, OnServiceClickListener serviceClickListener) {
this.serviceList = serviceList;
this.serviceClickListener = serviceClickListener;
/**
* Constructor for ServiceAdapter.
*/
public ServiceAdapter(List<ServiceDTO> serviceList, OnServiceClickListener clickListener) {
this.serviceList = serviceList;
this.clickListener = clickListener;
this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() {
@Override
public void onSelectionChanged(int count) {
clickListener.onSelectionChanged(count);
}
@Override
public void onSelectionModeToggle(boolean selectionMode) {
notifyDataSetChanged();
}
});
}
// Get the controls of each row in recycler view
/**
* Returns the list of selected keys for bulk deletion.
*/
@Override
public List<String> getSelectedKeys() {
return selectionHelper.getSelectedKeys();
}
/**
* Clears all selected items and exits selection mode.
*/
@Override
public void clearSelection() {
selectionHelper.clearSelection();
}
/**
* ViewHolder for Service items.
*/
public static class ServiceViewHolder extends RecyclerView.ViewHolder {
TextView tvServiceName, tvServiceDesc, tvServiceDuration, tvServicePrice;
final ItemServiceBinding binding;
public ServiceViewHolder(@NonNull View v) {
super(v);
tvServiceName = v.findViewById(R.id.tvServiceName);
tvServiceDesc = v.findViewById(R.id.tvServiceDesc);
tvServiceDuration = v.findViewById(R.id.tvServiceDuration);
tvServicePrice = v.findViewById(R.id.tvServicePrice);
/**
* Initializes the ViewHolder with view binding.
*/
public ServiceViewHolder(@NonNull ItemServiceBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
// Create a new row view
/**
* Inflates the layout for a Service item.
*/
@NonNull
@Override
public ServiceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_service, parent, false);
return new ServiceViewHolder(v);
ItemServiceBinding binding = ItemServiceBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new ServiceViewHolder(binding);
}
//populate the row with service data
/**
* Binds service data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull ServiceViewHolder holder, int position) {
ServiceDTO service = serviceList.get(position);
ItemServiceBinding binding = holder.binding;
holder.tvServiceName.setText(service.getServiceName());
holder.tvServiceDesc.setText(service.getServiceDesc());
holder.tvServiceDuration.setText("Duration: " + service.getServiceDuration() + " min");
holder.tvServicePrice.setText("$" + String.format("%.2f", service.getServicePrice()));
binding.tvServiceName.setText(service.getServiceName());
binding.tvServiceDesc.setText(service.getServiceDesc());
binding.tvServiceDuration.setText(service.getServiceDuration() != null ? service.getServiceDuration() + " mins" : "0 mins");
binding.tvServicePrice.setText(service.getServicePrice() != null ? "$" + String.format("%.2f", service.getServicePrice()) : "$0.00");
//when a row is clicked, open the detail view
holder.itemView.setOnClickListener(v -> serviceClickListener.onServiceClick(position));
String key = String.valueOf(service.getServiceId());
// Bulk delete selection mode
if (selectionHelper.isInSelectionMode()) {
binding.cbSelectService.setVisibility(View.VISIBLE);
binding.cbSelectService.setChecked(selectionHelper.isSelected(key));
} else {
binding.cbSelectService.setVisibility(View.GONE);
binding.cbSelectService.setChecked(false);
}
holder.itemView.setOnClickListener(v -> {
if (selectionHelper.isInSelectionMode()) {
selectionHelper.toggleSelection(key);
notifyItemChanged(position);
} else {
clickListener.onServiceClick(position);
}
});
holder.itemView.setOnLongClickListener(v -> {
if (!selectionHelper.isInSelectionMode()) {
selectionHelper.startSelection(key);
}
return true;
});
}
@Override
public int getItemCount() {
return serviceList.size();
}
}
}

View File

@@ -1,68 +1,142 @@
/*
* Adapter for showing suppliers in a list.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.databinding.ItemSupplierBinding;
import com.example.petstoremobile.dtos.SupplierDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.SelectionHelper;
import java.util.List;
public class SupplierAdapter extends RecyclerView.Adapter<SupplierAdapter.SupplierViewHolder> {
public class SupplierAdapter extends RecyclerView.Adapter<SupplierAdapter.SupplierViewHolder> implements BulkDeleteHandler.SelectableAdapter {
private List<SupplierDTO> supplierList;
private OnSupplierClickListener supplierClickListener;
private final List<SupplierDTO> supplierList;
private final OnSupplierClickListener supplierClickListener;
private final SelectionHelper selectionHelper;
// Interface for supplier click on recycler view
public interface OnSupplierClickListener {
void onSupplierClick(int position);
void onSelectionChanged(int count);
}
//Constructor
/**
* Constructor for SupplierAdapter.
*/
public SupplierAdapter(List<SupplierDTO> supplierList, OnSupplierClickListener supplierClickListener) {
this.supplierList = supplierList;
this.supplierClickListener = supplierClickListener;
this.selectionHelper = new SelectionHelper(new SelectionHelper.SelectionListener() {
@Override
public void onSelectionChanged(int count) {
supplierClickListener.onSelectionChanged(count);
}
@Override
public void onSelectionModeToggle(boolean selectionMode) {
notifyDataSetChanged();
}
});
}
// Get the controls of each row in recycler view
public static class SupplierViewHolder extends RecyclerView.ViewHolder {
TextView tvSupCompany, tvSupContactName, tvSupEmail, tvSupPhone;
/**
* Returns the list of selected keys for bulk deletion.
*/
@Override
public List<String> getSelectedKeys() {
return selectionHelper.getSelectedKeys();
}
public SupplierViewHolder(@NonNull View v) {
super(v);
tvSupCompany = v.findViewById(R.id.tvSupCompany);
tvSupContactName = v.findViewById(R.id.tvSupContactName);
tvSupEmail = v.findViewById(R.id.tvSupEmail);
tvSupPhone = v.findViewById(R.id.tvSupPhone);
/**
* Clears all selected items and exits selection mode.
*/
@Override
public void clearSelection() {
selectionHelper.clearSelection();
}
/**
* ViewHolder for Supplier items.
*/
public static class SupplierViewHolder extends RecyclerView.ViewHolder {
final ItemSupplierBinding binding;
/**
* Initializes the ViewHolder with view binding.
*/
public SupplierViewHolder(@NonNull ItemSupplierBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
// Create a new row view
/**
* Inflates the layout for a Supplier item.
*/
@NonNull
@Override
public SupplierViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_supplier, parent, false);
return new SupplierViewHolder(v);
ItemSupplierBinding binding = ItemSupplierBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new SupplierViewHolder(binding);
}
//populate the row with supplier data
/**
* Binds supplier data to the UI components.
*/
@Override
public void onBindViewHolder(@NonNull SupplierViewHolder holder, int position) {
SupplierDTO supplier = supplierList.get(position);
ItemSupplierBinding binding = holder.binding;
holder.tvSupCompany.setText(supplier.getSupCompany());
holder.tvSupContactName.setText(supplier.getSupContactFirstName() + " " + supplier.getSupContactLastName());
holder.tvSupEmail.setText(supplier.getSupEmail());
holder.tvSupPhone.setText(supplier.getSupPhone());
binding.tvSupCompany.setText(supplier.getSupCompany());
binding.tvSupContactName.setText(supplier.getSupContactFirstName() + " " + supplier.getSupContactLastName());
binding.tvSupEmail.setText(supplier.getSupEmail());
binding.tvSupPhone.setText(supplier.getSupPhone());
String key = String.valueOf(supplier.getSupId());
// Bulk delete selection mode
if (selectionHelper.isInSelectionMode()) {
binding.cbSelectSupplier.setVisibility(View.VISIBLE);
binding.cbSelectSupplier.setChecked(selectionHelper.isSelected(key));
} else {
binding.cbSelectSupplier.setVisibility(View.GONE);
binding.cbSelectSupplier.setChecked(false);
}
//when a row is clicked, open the detail view
holder.itemView.setOnClickListener(v -> supplierClickListener.onSupplierClick(position));
holder.itemView.setOnClickListener(v -> {
if (selectionHelper.isInSelectionMode()) {
selectionHelper.toggleSelection(key);
notifyItemChanged(position);
} else {
supplierClickListener.onSupplierClick(position);
}
});
holder.itemView.setOnLongClickListener(v -> {
if (!selectionHelper.isInSelectionMode()) {
selectionHelper.startSelection(key);
}
return true;
});
}
@Override
public int getItemCount() {
return supplierList.size();
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* Custom array adapter that displays items with white text.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.adapters;
import android.content.Context;
import android.graphics.Color;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import com.example.petstoremobile.R;
import java.util.List;
// A class that overrides the arrayAdapter so the text color changes based on theme
// Used to make spinners have white text on dark background no matter the theme
public class WhiteTextArrayAdapter<T> extends ArrayAdapter<T> {
public WhiteTextArrayAdapter(@NonNull Context context, int resource, @NonNull T[] objects) {
super(context, resource, objects);
}
public WhiteTextArrayAdapter(@NonNull Context context, int resource, @NonNull List<T> objects) {
super(context, resource, objects);
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = super.getView(position, convertView, parent);
view.setBackgroundColor(Color.TRANSPARENT);
if (view instanceof TextView) {
((TextView) view).setTextColor(Color.WHITE);
}
return view;
}
@Override
public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = super.getDropDownView(position, convertView, parent);
view.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.primary_dark));
if (view instanceof TextView) {
((TextView) view).setTextColor(Color.WHITE);
}
return view;
}
}

View File

@@ -0,0 +1,31 @@
/*
* Retrofit interface for activity log endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.ActivityLogDTO;
import java.util.List;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Query;
// api calls to get activity logs
public interface ActivityLogApi {
// Get activity logs with filters
@GET("api/v1/activity-logs")
Call<List<ActivityLogDTO>> getActivityLogs(
@Query("limit") int limit,
@Query("storeId") Long storeId,
@Query("role") String role,
@Query("search") String search,
@Query("startDate") String startDate,
@Query("endDate") String endDate
);
}

View File

@@ -0,0 +1,58 @@
/*
* Retrofit interface for adoption endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.AdoptionDTO;
import com.example.petstoremobile.dtos.BulkDeleteRequest;
import com.example.petstoremobile.dtos.PageResponse;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.HTTP;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
import retrofit2.http.Query;
// api calls for adoptions
public interface AdoptionApi {
// Get all adoptions with filters
@GET("api/v1/adoptions")
Call<PageResponse<AdoptionDTO>> getAllAdoptions(
@Query("page") int page,
@Query("size") int size,
@Query("q") String query,
@Query("status") String status,
@Query("storeId") Long storeId,
@Query("date") String date,
@Query("employeeId") Long employeeId,
@Query("sort") String sort);
// Get adoption by id
@GET("api/v1/adoptions/{id}")
Call<AdoptionDTO> getAdoptionById(@Path("id") Long id);
// Create adoption
@POST("api/v1/adoptions")
Call<AdoptionDTO> createAdoption(@Body AdoptionDTO adoption);
// Update adoption
@PUT("api/v1/adoptions/{id}")
Call<AdoptionDTO> updateAdoption(@Path("id") Long id, @Body AdoptionDTO adoption);
// Delete adoption
@DELETE("api/v1/adoptions/{id}")
Call<Void> deleteAdoption(@Path("id") Long id);
// Bulk delete adoptions
@HTTP(method = "DELETE", path = "api/v1/adoptions", hasBody = true)
Call<Void> bulkDeleteAdoptions(@Body BulkDeleteRequest request);
}

View File

@@ -0,0 +1,58 @@
/*
* Retrofit interface for appointment endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.AppointmentDTO;
import com.example.petstoremobile.dtos.BulkDeleteRequest;
import com.example.petstoremobile.dtos.PageResponse;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.HTTP;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
import retrofit2.http.Query;
// api calls for appointments
public interface AppointmentApi {
// Get all appointments with filters
@GET("api/v1/appointments")
Call<PageResponse<AppointmentDTO>> getAllAppointments(
@Query("page") int page,
@Query("size") int size,
@Query("q") String query,
@Query("status") String status,
@Query("storeId") Long storeId,
@Query("date") String date,
@Query("employeeId") Long employeeId,
@Query("sort") String sort);
// Get appointment by id
@GET("api/v1/appointments/{id}")
Call<AppointmentDTO> getAppointmentById(@Path("id") Long id);
// Create appointment
@POST("api/v1/appointments")
Call<AppointmentDTO> createAppointment(@Body AppointmentDTO appointment);
// Update appointment
@PUT("api/v1/appointments/{id}")
Call<AppointmentDTO> updateAppointment(@Path("id") Long id, @Body AppointmentDTO appointment);
// Delete appointment
@DELETE("api/v1/appointments/{id}")
Call<Void> deleteAppointment(@Path("id") Long id);
// Bulk delete appointments
@HTTP(method = "DELETE", path = "api/v1/appointments", hasBody = true)
Call<Void> bulkDeleteAppointments(@Body BulkDeleteRequest request);
}

View File

@@ -0,0 +1,24 @@
/*
* Retrofit interface for category endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.CategoryDTO;
import com.example.petstoremobile.dtos.PageResponse;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Query;
// api calls for categories
public interface CategoryApi {
// Get all categories with pagination
@GET("api/v1/categories")
Call<PageResponse<CategoryDTO>> getAllCategories(
@Query("page") int page,
@Query("size") int size);
}

View File

@@ -1,23 +1,37 @@
/*
* Retrofit interface for chat and conversation endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.ConversationDTO;
import com.example.petstoremobile.dtos.MessageDTO;
import com.example.petstoremobile.dtos.UpdateConversationStatusRequest;
import java.util.List;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
//api calls to get conversations
// api calls for chat conversations
public interface ChatApi {
// Get all conversations
@GET("api/v1/chat/conversations")
Call<List<ConversationDTO>> getAllConversations();
// Get conversation by id
@GET("api/v1/chat/conversations/{conversationId}")
Call<ConversationDTO> getConversationById(@Path("conversationId") Long conversationId);
// Update conversation status
@PUT("api/v1/chat/conversations/{conversationId}")
Call<ConversationDTO> updateConversationStatus(@Path("conversationId") Long conversationId, @Body UpdateConversationStatusRequest request);
}

View File

@@ -0,0 +1,54 @@
/*
* Retrofit interface for coupon endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.CouponDTO;
import com.example.petstoremobile.dtos.PageResponse;
import java.util.List;
import retrofit2.Call;
import com.example.petstoremobile.dtos.BulkDeleteRequest;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.HTTP;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
import retrofit2.http.Query;
public interface CouponApi {
@GET("api/v1/coupons")
Call<PageResponse<CouponDTO>> getAllCoupons(
@Query("page") int page,
@Query("size") int size,
@Query("active") Boolean active,
@Query("discountType") String discountType,
@Query("sort") String sort);
@GET("api/v1/coupons/{id}")
Call<CouponDTO> getCouponById(@Path("id") Long id);
@GET("api/v1/coupons/code/{code}")
Call<CouponDTO> getCouponByCode(@Path("code") String code);
@POST("api/v1/coupons")
Call<CouponDTO> createCoupon(@Body CouponDTO coupon);
@PUT("api/v1/coupons/{id}")
Call<CouponDTO> updateCoupon(@Path("id") Long id, @Body CouponDTO coupon);
@DELETE("api/v1/coupons/{id}")
Call<Void> deleteCoupon(@Path("id") Long id);
@HTTP(method = "DELETE", path = "api/v1/coupons", hasBody = true)
Call<Void> bulkDeleteCoupons(@Body BulkDeleteRequest request);
}

View File

@@ -1,21 +1,51 @@
/*
* Retrofit interface for customer endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.CustomerDTO;
import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.dtos.PageResponse;
import java.util.List;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
import retrofit2.http.Query;
//api calls to get customers
// api calls for customers
public interface CustomerApi {
// Get all customers with pagination
@GET("api/v1/customers")
Call<PageResponse<CustomerDTO>> getAllCustomers(@Query("page") int page, @Query("size") int size);
// Get customer by id
@GET("api/v1/customers/{customerId}")
Call<CustomerDTO> getCustomerById(@Path("customerId") Long customerId);
}
// Update customer
@PUT("api/v1/customers/{customerId}")
Call<CustomerDTO> updateCustomer(@Path("customerId") Long customerId, @Body CustomerDTO customer);
// Delete customer
@DELETE("api/v1/customers/{customerId}")
Call<Void> deleteCustomer(@Path("customerId") Long customerId);
// Register customer
@POST("api/v1/auth/register")
Call<CustomerDTO> registerCustomer(@Body CustomerDTO customer);
// Get customer dropdowns
@GET("api/v1/dropdowns/customers")
Call<List<DropdownDTO>> getCustomerDropdowns();
}

View File

@@ -0,0 +1,39 @@
/*
* Retrofit interface for employee endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.EmployeeDTO;
import com.example.petstoremobile.dtos.PageResponse;
import retrofit2.Call;
import retrofit2.http.*;
// api calls for employees
public interface EmployeeApi {
// Get all employees with pagination
@GET("api/v1/employees")
Call<PageResponse<EmployeeDTO>> getAllEmployees(
@Query("page") int page,
@Query("size") int size);
// Get employee by id
@GET("api/v1/employees/{id}")
Call<EmployeeDTO> getEmployeeById(@Path("id") Long id);
// Create employee
@POST("api/v1/employees")
Call<EmployeeDTO> createEmployee(@Body EmployeeDTO employee);
// Update employee
@PUT("api/v1/employees/{id}")
Call<EmployeeDTO> updateEmployee(@Path("id") Long id, @Body EmployeeDTO employee);
// Delete employee
@DELETE("api/v1/employees/{id}")
Call<Void> deleteEmployee(@Path("id") Long id);
}

View File

@@ -0,0 +1,55 @@
/*
* Retrofit interface for inventory endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.BulkDeleteRequest;
import com.example.petstoremobile.dtos.InventoryDTO;
import com.example.petstoremobile.dtos.PageResponse;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.HTTP;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
import retrofit2.http.Query;
// api calls for inventory
public interface InventoryApi {
// Get all inventory with filters
@GET("api/v1/inventory")
Call<PageResponse<InventoryDTO>> getAllInventory(
@Query("page") int page,
@Query("size") int size,
@Query("q") String query,
@Query("storeId") Long storeId,
@Query("sort") String sort);
// Get inventory by id
@GET("api/v1/inventory/{id}")
Call<InventoryDTO> getInventoryById(@Path("id") Long id);
// Create inventory
@POST("api/v1/inventory")
Call<InventoryDTO> createInventory(@Body InventoryDTO request);
// Update inventory
@PUT("api/v1/inventory/{id}")
Call<InventoryDTO> updateInventory(@Path("id") Long id, @Body InventoryDTO request);
// Delete inventory
@DELETE("api/v1/inventory/{id}")
Call<Void> deleteInventory(@Path("id") Long id);
// Bulk delete inventory
@HTTP(method = "DELETE", path = "api/v1/inventory", hasBody = true)
Call<Void> bulkDeleteInventory(@Body BulkDeleteRequest request);
}

View File

@@ -1,20 +1,49 @@
/*
* Retrofit interface for message endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.MessageDTO;
import com.example.petstoremobile.dtos.SendMessageRequest;
import java.util.List;
import okhttp3.MultipartBody;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.Multipart;
import retrofit2.http.POST;
import retrofit2.http.Part;
import retrofit2.http.Path;
import retrofit2.http.Streaming;
//api calls to get and send messages
// api calls for messages
public interface MessageApi {
// Get messages for a conversation
@GET("api/v1/chat/conversations/{id}/messages")
Call<List<MessageDTO>> getMessages(@Path("id") Long conversationId);
// Send a message
@POST("api/v1/chat/conversations/{id}/messages")
Call<MessageDTO> sendMessage(@Path("id") Long conversationId, @Body SendMessageRequest request);
// Send a message with attachment
@Multipart
@POST("api/v1/chat/conversations/{id}/attachments")
Call<MessageDTO> sendMessageWithAttachment(
@Path("id") Long conversationId,
@Part MultipartBody.Part content,
@Part MultipartBody.Part file
);
// Download attachment
@GET("api/v1/chat/messages/{id}/attachment")
@Streaming
Call<ResponseBody> downloadAttachment(@Path("id") Long messageId);
}

View File

@@ -1,26 +1,70 @@
/*
* Retrofit interface for pet endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.BulkDeleteRequest;
import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.PetDTO;
import java.util.List;
import okhttp3.MultipartBody;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.HTTP;
import retrofit2.http.Multipart;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Part;
import retrofit2.http.Path;
import retrofit2.http.Query;
//api calls to CRUD pets
// api calls to CRUD pets
public interface PetApi {
// Get all pets
// endpoint for downloading the pet's image file
String PET_IMAGE_PATH = "api/v1/pets/%d/image";
// Get all pets with filters
@GET("api/v1/pets")
Call<PageResponse<PetDTO>> getAllPets(
@Query("page") int page,
@Query("size") int size
@Query("size") int size,
@Query("q") String query,
@Query("status") String status,
@Query("species") String species,
@Query("storeId") Long storeId,
@Query("customerId") Long customerId,
@Query("sort") String sort
);
// Get pets by customer id
@GET("api/v1/dropdowns/customers/{customerId}/pets")
Call<List<DropdownDTO>> getCustomerPets(@Path("customerId") Long customerId);
// Get adoption pets
@GET("api/v1/dropdowns/adoption-pets")
Call<List<DropdownDTO>> getAdoptionPets(@Query("storeId") Long storeId);
// Get pet dropdowns
@GET("api/v1/dropdowns/pets")
Call<List<DropdownDTO>> getPetDropdowns();
// Get pet species dropdowns
@GET("api/v1/dropdowns/pet-species")
Call<List<DropdownDTO>> getPetSpeciesDropdowns();
// Get pet breeds dropdowns
@GET("api/v1/dropdowns/pet-breeds")
Call<List<DropdownDTO>> getPetBreedsDropdowns(@Query("species") String species);
// Get pet by id
@GET("api/v1/pets/{id}")
Call<PetDTO> getPetById(@Path("id") Long id);
@@ -37,4 +81,16 @@ public interface PetApi {
@DELETE("api/v1/pets/{id}")
Call<Void> deletePet(@Path("id") Long id);
// Bulk delete pets
@HTTP(method = "DELETE", path = "api/v1/pets", hasBody = true)
Call<Void> bulkDeletePets(@Body BulkDeleteRequest request);
// Upload pet image
@Multipart
@POST("api/v1/pets/{id}/image")
Call<Void> uploadPetImage(@Path("id") Long id, @Part MultipartBody.Part image);
// Delete pet image
@DELETE("api/v1/pets/{id}/image")
Call<Void> deletePetImage(@Path("id") Long id);
}

View File

@@ -0,0 +1,65 @@
/*
* Retrofit interface for product endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.ProductDTO;
import okhttp3.MultipartBody;
import retrofit2.Call;
import retrofit2.http.*;
import java.util.List;
// api calls for products
public interface ProductApi {
// endpoint for downloading the product's image file
String PRODUCT_IMAGE_PATH = "api/v1/products/%d/image";
// Get all products with filters
@GET("api/v1/products")
Call<PageResponse<ProductDTO>> getAllProducts(
@Query("q") String query,
@Query("categoryId") Long categoryId,
@Query("page") int page,
@Query("size") int size,
@Query("sort") String sort);
// Get product by id
@GET("api/v1/products/{id}")
Call<ProductDTO> getProductById(@Path("id") Long id);
// Create product
@POST("api/v1/products")
Call<ProductDTO> createProduct(@Body ProductDTO product);
// Update product
@PUT("api/v1/products/{id}")
Call<ProductDTO> updateProduct(@Path("id") Long id, @Body ProductDTO product);
// Delete product
@DELETE("api/v1/products/{id}")
Call<Void> deleteProduct(@Path("id") Long id);
// Upload product image
@Multipart
@POST("api/v1/products/{id}/image")
Call<Void> uploadProductImage(@Path("id") Long id, @Part MultipartBody.Part image);
// Delete product image
@DELETE("api/v1/products/{id}/image")
Call<Void> deleteProductImage(@Path("id") Long id);
// Get product dropdowns
@GET("api/v1/dropdowns/products")
Call<List<DropdownDTO>> getProductDropdowns();
// Get category dropdowns
@GET("api/v1/dropdowns/categories")
Call<List<DropdownDTO>> getCategoryDropdowns();
}

View File

@@ -0,0 +1,55 @@
/*
* Retrofit interface for product-supplier endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.BulkDeleteRequest;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.ProductSupplierDTO;
import retrofit2.Call;
import retrofit2.http.*;
// api calls for product-supplier relationships
public interface ProductSupplierApi {
// Get all product-suppliers with filters
@GET("api/v1/product-suppliers")
Call<PageResponse<ProductSupplierDTO>> getAllProductSuppliers(
@Query("page") int page,
@Query("size") int size,
@Query("q") String query,
@Query("productId") Long productId,
@Query("supplierId") Long supplierId,
@Query("sort") String sort);
// Get product-supplier by composite id
@GET("api/v1/product-suppliers/{productId}/{supplierId}")
Call<ProductSupplierDTO> getProductSupplierById(
@Path("productId") Long productId,
@Path("supplierId") Long supplierId);
// Create product-supplier
@POST("api/v1/product-suppliers")
Call<ProductSupplierDTO> createProductSupplier(@Body ProductSupplierDTO dto);
// Update product-supplier
@PUT("api/v1/product-suppliers/{productId}/{supplierId}")
Call<ProductSupplierDTO> updateProductSupplier(
@Path("productId") Long productId,
@Path("supplierId") Long supplierId,
@Body ProductSupplierDTO dto);
// Delete product-supplier
@DELETE("api/v1/product-suppliers/{productId}/{supplierId}")
Call<Void> deleteProductSupplier(
@Path("productId") Long productId,
@Path("supplierId") Long supplierId);
// Bulk delete product-suppliers
@HTTP(method = "DELETE", path = "api/v1/product-suppliers", hasBody = true)
Call<Void> bulkDeleteProductSuppliers(@Body BulkDeleteRequest request);
}

View File

@@ -0,0 +1,32 @@
/*
* Retrofit interface for purchase order endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.PurchaseOrderDTO;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Path;
import retrofit2.http.Query;
// api calls for purchase orders
public interface PurchaseOrderApi {
// Get all purchase orders with filters
@GET("api/v1/purchase-orders")
Call<PageResponse<PurchaseOrderDTO>> getAllPurchaseOrders(
@Query("page") int page,
@Query("size") int size,
@Query("q") String query,
@Query("storeId") Long storeId,
@Query("sort") String sort);
// Get purchase order by id
@GET("api/v1/purchase-orders/{id}")
Call<PurchaseOrderDTO> getPurchaseOrderById(@Path("id") Long id);
}

View File

@@ -0,0 +1,38 @@
/*
* Retrofit interface for refund endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.RefundDTO;
import retrofit2.Call;
import retrofit2.http.*;
import java.util.List;
// api calls for refunds
public interface RefundApi {
// Get all refunds
@GET("api/v1/refunds")
Call<List<RefundDTO>> getAllRefunds();
// Get refund by id
@GET("api/v1/refunds/{id}")
Call<RefundDTO> getRefundById(@Path("id") Long id);
// Create refund
@POST("api/v1/refunds")
Call<RefundDTO> createRefund(@Body RefundDTO refund);
// Update refund
@PUT("api/v1/refunds/{id}")
Call<RefundDTO> updateRefund(@Path("id") Long id, @Body RefundDTO refund);
// Delete refund
@DELETE("api/v1/refunds/{id}")
Call<Void> deleteRefund(@Path("id") Long id);
}

View File

@@ -1,70 +0,0 @@
package com.example.petstoremobile.api;
import android.content.Context;
import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.auth.AuthInterceptor;
import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
//Retrofit client Used for API calls
public class RetrofitClient {
//base URL
public static final String BASE_URL = "http://10.0.2.2:8080"; //for emulator testing
// public static final String BASE_URL = "http://10.0.0.200:8080/"; //for hardware testing
private static Retrofit retrofit = null;
public static Retrofit getClient(Context context) {
//create an http logging using an interceptor
HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor();
interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(interceptor)
.addInterceptor(new AuthInterceptor(context))
.build();
//build the retrofit object with all needed properties
retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create()) //JSON converter
.client(client) //logging interceptor - OkHttpClient
.build();
return retrofit;
}
//associate the retrofit object with the API interface
public static PetApi getPetApi(Context context) {
return getClient(context).create(PetApi.class);
}
public static ServiceApi getServiceApi(Context context) {
return getClient(context).create(ServiceApi.class);
}
public static SupplierApi getSupplierApi(Context context) {
return getClient(context).create(SupplierApi.class);
}
public static AuthApi getAuthApi(Context context) {
return getClient(context).create(AuthApi.class);
}
public static ChatApi getChatApi(Context context) {
return getClient(context).create(ChatApi.class);
}
public static CustomerApi getCustomerApi(Context context) {
return getClient(context).create(CustomerApi.class);
}
public static MessageApi getMessageApi(Context context) {
return getClient(context).create(MessageApi.class);
}
}

View File

@@ -0,0 +1,42 @@
/*
* Retrofit interface for sale endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.SaleDTO;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.Path;
import retrofit2.http.Query;
// api calls for sales
public interface SaleApi {
// Get all sales with filters
@GET("api/v1/sales")
Call<PageResponse<SaleDTO>> getAllSales(
@Query("page") int page,
@Query("size") int size,
@Query("q") String query,
@Query("paymentMethod") String paymentMethod,
@Query("storeId") Long storeId,
@Query("isRefund") Boolean isRefund,
@Query("customerId") Long customerId,
@Query("sort") String sort);
// Get sale by id
@GET("api/v1/sales/{id}")
Call<SaleDTO> getSaleById(@Path("id") Long id);
// Create sale
@POST("api/v1/sales")
Call<SaleDTO> createSale(@Body SaleDTO sale);
}

View File

@@ -1,5 +1,13 @@
/*
* Retrofit interface for service endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.BulkDeleteRequest;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.ServiceDTO;
@@ -7,18 +15,21 @@ import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.HTTP;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
import retrofit2.http.Query;
//api calls to CRUD services
// api calls to CRUD services
public interface ServiceApi {
// Get all services
@GET("api/v1/services")
Call<PageResponse<ServiceDTO>> getAllServices(
@Query("page") int page,
@Query("size") int size
@Query("size") int size,
@Query("q") String query,
@Query("sort") String sort
);
// Get service by id
@@ -36,4 +47,8 @@ public interface ServiceApi {
// Delete service
@DELETE("api/v1/services/{id}")
Call<Void> deleteService(@Path("id") Long id);
// Bulk delete services
@HTTP(method = "DELETE", path = "api/v1/services", hasBody = true)
Call<Void> bulkDeleteServices(@Body BulkDeleteRequest request);
}

View File

@@ -0,0 +1,37 @@
/*
* Retrofit interface for store endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.StoreDTO;
import java.util.List;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Path;
import retrofit2.http.Query;
// api calls for stores
public interface StoreApi {
// Get all stores with pagination
@GET("api/v1/stores")
Call<PageResponse<StoreDTO>> getAllStores(
@Query("page") int page,
@Query("size") int size);
// Get store dropdowns
@GET("api/v1/dropdowns/stores")
Call<List<DropdownDTO>> getStoreDropdowns();
// Get employees of a specific store
@GET("api/v1/dropdowns/stores/{storeId}/employees")
Call<List<DropdownDTO>> getStoreEmployees(@Path("storeId") Long storeId);
}

View File

@@ -1,5 +1,13 @@
/*
* Retrofit interface for supplier endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.BulkDeleteRequest;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.SupplierDTO;
@@ -7,6 +15,7 @@ import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.HTTP;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
@@ -18,7 +27,9 @@ public interface SupplierApi {
@GET("api/v1/suppliers")
Call<PageResponse<SupplierDTO>> getAllSuppliers(
@Query("page") int page,
@Query("size") int size
@Query("size") int size,
@Query("q") String query,
@Query("sort") String sort
);
// Get supplier by id
@@ -36,4 +47,8 @@ public interface SupplierApi {
// Delete supplier
@DELETE("api/v1/suppliers/{id}")
Call<Void> deleteSupplier(@Path("id") Long id);
// Bulk delete suppliers
@HTTP(method = "DELETE", path = "api/v1/suppliers", hasBody = true)
Call<Void> bulkDeleteSuppliers(@Body BulkDeleteRequest request);
}

View File

@@ -0,0 +1,25 @@
/*
* Retrofit interface for user endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.UserDTO;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Query;
// api calls for users
public interface UserApi {
// endpoint for downloading the user's avatar file
String AVATAR_PATH = "api/v1/users/%d/avatar/file";
// Get all users with filters
@GET("api/v1/users")
Call<PageResponse<UserDTO>> getUsers(@Query("role") String role, @Query("page") int page, @Query("size") int size);
}

View File

@@ -1,17 +1,57 @@
/*
* Retrofit interface for login and registration endpoints.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api.auth;
import com.example.petstoremobile.dtos.AuthDTO;
import com.example.petstoremobile.dtos.AvatarUploadResponse;
import com.example.petstoremobile.dtos.UserDTO;
import java.util.Map;
import okhttp3.MultipartBody;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.Multipart;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Part;
//Api for logging in and getting current user
public interface AuthApi {
// endpoint for downloading the current user's avatar file
String AVATAR_FILE_PATH = "api/v1/auth/me/avatar/file";
//login endpoint
@POST("api/v1/auth/login")
Call<AuthDTO.LoginResponse> login(@Body AuthDTO.LoginRequest loginRequest);
//get current user endpoint
@GET("api/v1/auth/me")
Call<AuthDTO.UserResponse> getCurrentUser();
Call<UserDTO> getMe();
//update current user endpoint
@PUT("api/v1/auth/me")
Call<UserDTO> updateMe(@Body Map<String, String> updates);
//upload avatar endpoint
@Multipart
@POST("api/v1/auth/me/avatar")
Call<AvatarUploadResponse> uploadAvatar(@Part MultipartBody.Part avatar);
//delete avatar endpoint
@DELETE("api/v1/auth/me/avatar")
Call<Void> deleteAvatar();
//forgot password endpoint
@POST("api/v1/auth/forgot-password")
Call<Void> forgotPassword(@Body Map<String, String> body);
}

View File

@@ -1,6 +1,11 @@
package com.example.petstoremobile.api.auth;
/*
* Interceptor that attaches the auth token to outgoing requests.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
import android.content.Context;
package com.example.petstoremobile.api.auth;
import androidx.annotation.NonNull;
@@ -15,8 +20,8 @@ public class AuthInterceptor implements Interceptor {
private final TokenManager tokenManager;
public AuthInterceptor(Context context) {
this.tokenManager = TokenManager.getInstance(context);
public AuthInterceptor(TokenManager tokenManager) {
this.tokenManager = tokenManager;
}
@NonNull
@@ -25,19 +30,26 @@ public class AuthInterceptor implements Interceptor {
String token = tokenManager.getToken();
String url = chain.request().url().toString();
if (url.contains("auth/login") || url.contains("auth/register")) {
boolean isAuthEndpoint = url.contains("auth/login") || url.contains("auth/register");
if (isAuthEndpoint) {
return chain.proceed(chain.request());
}
//If we have a token then add it to the request
Response response;
if (token != null) {
Request request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer " + token)
.build();
return chain.proceed(request);
response = chain.proceed(request);
} else {
response = chain.proceed(chain.request());
}
//If no token then just pass the request
return chain.proceed(chain.request());
if (response.code() == 401) {
tokenManager.forceLogout();
}
return response;
}
}

View File

@@ -1,28 +1,47 @@
/*
* Handles saving and retrieving the authentication token.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.api.auth;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
//Store login token in shared preferences
import javax.inject.Inject;
import javax.inject.Singleton;
import dagger.hilt.android.qualifiers.ApplicationContext;
//Used to save and retrieve login data
@Singleton
public class TokenManager {
public static final String ACTION_FORCE_LOGOUT = "com.example.petstoremobile.ACTION_FORCE_LOGOUT";
private static final String TOKEN_KEY = "token";
private static final String USERNAME_KEY = "username";
private static final String ROLE_KEY = "role";
private static final String PREFS_NAME = "auth_prefs";
private static final String USER_ID_KEY = "user_id";
private static final String PRIMARY_STORE_ID_KEY = "primary_store_id";
private static TokenManager instance;
private final Context context;
private SharedPreferences prefs;
private TokenManager(Context context) {
@Inject
public TokenManager(@ApplicationContext Context context) {
this.context = context;
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
public static TokenManager getInstance(Context context) {
if (instance == null) {
instance = new TokenManager(context);
}
return instance;
public void forceLogout() {
clearLoginData();
Intent intent = new Intent(ACTION_FORCE_LOGOUT);
intent.setPackage(context.getPackageName());
context.sendBroadcast(intent);
}
//save login data after login
@@ -56,6 +75,19 @@ public class TokenManager {
prefs.edit().putLong(USER_ID_KEY, userId).apply();
}
public void savePrimaryStoreId(Long storeId) {
if (storeId != null) {
prefs.edit().putLong(PRIMARY_STORE_ID_KEY, storeId).apply();
} else {
prefs.edit().remove(PRIMARY_STORE_ID_KEY).apply();
}
}
public Long getPrimaryStoreId() {
long id = prefs.getLong(PRIMARY_STORE_ID_KEY, -1L);
return id == -1L ? null : id;
}
//Check if logged in
public boolean isLoggedIn() {
return getToken() != null;
@@ -65,6 +97,4 @@ public class TokenManager {
public void clearLoginData() {
prefs.edit().clear().apply();
}
}
}

View File

@@ -0,0 +1,213 @@
/*
* Sets up the Retrofit client and dependency injection for network calls.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.di;
import android.content.Context;
import android.os.Build;
import com.example.petstoremobile.BuildConfig;
import com.example.petstoremobile.api.*;
import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.auth.AuthInterceptor;
import com.example.petstoremobile.api.auth.TokenManager;
import java.util.concurrent.TimeUnit;
import javax.inject.Named;
import javax.inject.Singleton;
import dagger.Module;
import dagger.Provides;
import dagger.hilt.InstallIn;
import dagger.hilt.android.qualifiers.ApplicationContext;
import dagger.hilt.components.SingletonComponent;
import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
//Module to provide dependencies injection for the api
@Module
@InstallIn(SingletonComponent.class)
public class NetworkModule {
@Provides
@Singleton
@Named("baseUrl")
public static String provideBaseUrl() {
return isEmulator() ? BuildConfig.EMULATOR_BACKEND_URL : BuildConfig.DEVICE_BACKEND_URL;
}
// Check if the device is an emulator
private static boolean isEmulator() {
return Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.MANUFACTURER.contains("Genymotion")
|| Build.HARDWARE.contains("goldfish")
|| Build.HARDWARE.contains("ranchu")
|| Build.PRODUCT.contains("sdk")
|| Build.PRODUCT.contains("sdk_gphone")
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"));
}
@Provides
@Singleton
public static OkHttpClient provideOkHttpClient(TokenManager tokenManager) {
HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor();
interceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);
return new OkHttpClient.Builder()
.addInterceptor(interceptor)
.addInterceptor(new AuthInterceptor(tokenManager))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
}
//build the retrofit instance
@Provides
@Singleton
public static Retrofit provideRetrofit(@Named("baseUrl") String baseUrl, OkHttpClient client) {
return new Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.client(client)
.build();
}
//associate the api with the retrofit instance
@Provides
@Singleton
public static PetApi providePetApi(Retrofit retrofit) {
return retrofit.create(PetApi.class);
}
@Provides
@Singleton
public static ServiceApi provideServiceApi(Retrofit retrofit) {
return retrofit.create(ServiceApi.class);
}
@Provides
@Singleton
public static SupplierApi provideSupplierApi(Retrofit retrofit) {
return retrofit.create(SupplierApi.class);
}
@Provides
@Singleton
public static AdoptionApi provideAdoptionApi(Retrofit retrofit) {
return retrofit.create(AdoptionApi.class);
}
@Provides
@Singleton
public static AppointmentApi provideAppointmentApi(Retrofit retrofit) {
return retrofit.create(AppointmentApi.class);
}
@Provides
@Singleton
public static ProductApi provideProductApi(Retrofit retrofit) {
return retrofit.create(ProductApi.class);
}
@Provides
@Singleton
public static SaleApi provideSaleApi(Retrofit retrofit) {
return retrofit.create(SaleApi.class);
}
@Provides
@Singleton
public static PurchaseOrderApi providePurchaseOrderApi(Retrofit retrofit) {
return retrofit.create(PurchaseOrderApi.class);
}
@Provides
@Singleton
public static ProductSupplierApi provideProductSupplierApi(Retrofit retrofit) {
return retrofit.create(ProductSupplierApi.class);
}
@Provides
@Singleton
public static InventoryApi provideInventoryApi(Retrofit retrofit) {
return retrofit.create(InventoryApi.class);
}
@Provides
@Singleton
public static AuthApi provideAuthApi(Retrofit retrofit) {
return retrofit.create(AuthApi.class);
}
@Provides
@Singleton
public static ChatApi provideChatApi(Retrofit retrofit) {
return retrofit.create(ChatApi.class);
}
@Provides
@Singleton
public static CustomerApi provideCustomerApi(Retrofit retrofit) {
return retrofit.create(CustomerApi.class);
}
@Provides
@Singleton
public static MessageApi provideMessageApi(Retrofit retrofit) {
return retrofit.create(MessageApi.class);
}
@Provides
@Singleton
public static StoreApi provideStoreApi(Retrofit retrofit) {
return retrofit.create(StoreApi.class);
}
@Provides
@Singleton
public static CategoryApi provideCategoryApi(Retrofit retrofit) {
return retrofit.create(CategoryApi.class);
}
@Provides
@Singleton
public static UserApi provideUserApi(Retrofit retrofit) {
return retrofit.create(UserApi.class);
}
@Provides
@Singleton
public static EmployeeApi provideEmployeeApi(Retrofit retrofit) {
return retrofit.create(EmployeeApi.class);
}
@Provides
@Singleton
public static RefundApi provideRefundApi(Retrofit retrofit) {
return retrofit.create(RefundApi.class);
}
@Provides
@Singleton
public static CouponApi provideCouponApi(Retrofit retrofit) {
return retrofit.create(CouponApi.class);
}
@Provides
@Singleton
public static ActivityLogApi provideActivityLogApi(Retrofit retrofit) {
return retrofit.create(ActivityLogApi.class);
}
}

View File

@@ -0,0 +1,41 @@
/*
* Data transfer object for activity log entries.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for activity logs.
*/
public class ActivityLogDTO {
private Long logId;
private String activity;
private String fullName;
private String fullNameSnapshot;
private String logTimestamp;
private String role;
private String roleSnapshot;
private Long storeId;
private String storeName;
private String storeNameSnapshot;
private Long userId;
private String username;
private String usernameSnapshot;
public Long getLogId() { return logId; }
public String getActivity() { return activity; }
public String getFullName() { return fullName; }
public String getFullNameSnapshot() { return fullNameSnapshot; }
public String getLogTimestamp() { return logTimestamp; }
public String getRole() { return role; }
public String getRoleSnapshot() { return roleSnapshot; }
public Long getStoreId() { return storeId; }
public String getStoreName() { return storeName; }
public String getStoreNameSnapshot() { return storeNameSnapshot; }
public Long getUserId() { return userId; }
public String getUsername() { return username; }
public String getUsernameSnapshot() { return usernameSnapshot; }
}

View File

@@ -0,0 +1,86 @@
/*
* Data transfer object for adoption records.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import java.math.BigDecimal;
/**
* Data Transfer Object for pet adoptions.
*/
public class AdoptionDTO {
private Long adoptionId;
private Long petId;
private String petName;
private Long customerId;
private String customerName;
private Long employeeId;
private String employeeName;
private Long sourceStoreId;
private String sourceStoreName;
private String adoptionDate;
private String adoptionStatus;
private BigDecimal adoptionFee;
private String createdAt;
private String updatedAt;
public AdoptionDTO() {}
public AdoptionDTO(Long petId, Long customerId, Long employeeId, Long sourceStoreId,
String adoptionDate, String adoptionStatus, BigDecimal adoptionFee) {
this.petId = petId;
this.customerId = customerId;
this.employeeId = employeeId;
this.sourceStoreId = sourceStoreId;
this.adoptionDate = adoptionDate;
this.adoptionStatus = adoptionStatus;
this.adoptionFee = adoptionFee;
}
public Long getAdoptionId() { return adoptionId; }
public void setAdoptionId(Long adoptionId) { this.adoptionId = adoptionId; }
public Long getPetId() { return petId; }
public void setPetId(Long petId) { this.petId = petId; }
public String getPetName() { return petName; }
public void setPetName(String petName) { this.petName = petName; }
public Long getCustomerId() { return customerId; }
public void setCustomerId(Long customerId) { this.customerId = customerId; }
public String getCustomerName() { return customerName; }
public void setCustomerName(String customerName) { this.customerName = customerName; }
public Long getEmployeeId() { return employeeId; }
public void setEmployeeId(Long employeeId) { this.employeeId = employeeId; }
public String getEmployeeName() { return employeeName; }
public void setEmployeeName(String employeeName) { this.employeeName = employeeName; }
public Long getSourceStoreId() { return sourceStoreId; }
public void setSourceStoreId(Long sourceStoreId) { this.sourceStoreId = sourceStoreId; }
public String getSourceStoreName() { return sourceStoreName; }
public void setSourceStoreName(String sourceStoreName) { this.sourceStoreName = sourceStoreName; }
public String getAdoptionDate() { return adoptionDate; }
public void setAdoptionDate(String adoptionDate) { this.adoptionDate = adoptionDate; }
public String getAdoptionStatus() { return adoptionStatus; }
public void setAdoptionStatus(String adoptionStatus) { this.adoptionStatus = adoptionStatus; }
public BigDecimal getAdoptionFee() { return adoptionFee; }
public void setAdoptionFee(BigDecimal adoptionFee) { this.adoptionFee = adoptionFee; }
public String getCreatedAt() { return createdAt; }
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
public String getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,90 @@
/*
* Data transfer object for appointments.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for appointments.
*/
public class AppointmentDTO {
private Long appointmentId;
private Long customerId;
private String customerName;
private Long storeId;
private String storeName;
private Long serviceId;
private String serviceName;
private Long employeeId;
private String employeeName;
private String appointmentDate;
private String appointmentTime;
private String appointmentStatus;
private String petName;
private Long petId;
private String createdAt;
private String updatedAt;
public AppointmentDTO(Long customerId, Long storeId, Long serviceId,
String appointmentDate, String appointmentTime,
String appointmentStatus, Long petId) {
this(customerId, storeId, serviceId, null, appointmentDate, appointmentTime, appointmentStatus, petId);
}
public AppointmentDTO(Long customerId, Long storeId, Long serviceId, Long employeeId,
String appointmentDate, String appointmentTime,
String appointmentStatus, Long petId) {
this.customerId = customerId;
this.storeId = storeId;
this.serviceId = serviceId;
this.employeeId = employeeId;
this.appointmentDate = appointmentDate;
this.appointmentTime = appointmentTime;
this.appointmentStatus = appointmentStatus;
this.petId = petId;
}
public Long getAppointmentId() { return appointmentId; }
public Long getCustomerId() { return customerId; }
public String getCustomerName() { return customerName; }
public Long getStoreId() { return storeId; }
public String getStoreName() { return storeName; }
public Long getServiceId() { return serviceId; }
public String getServiceName() { return serviceName; }
public Long getEmployeeId() { return employeeId; }
public String getEmployeeName() { return employeeName; }
public String getAppointmentDate() { return appointmentDate; }
public String getAppointmentTime() { return appointmentTime; }
public String getAppointmentStatus() { return appointmentStatus; }
public String getPetName() { return petName; }
public Long getPetId() { return petId; }
public String getCreatedAt() { return createdAt; }
public String getUpdatedAt() { return updatedAt; }
public Long getPetID() { return petId; }
public String getServiceType() { return serviceName; }
public Long getServiceID() { return serviceId; }
public String getStatus() { return appointmentStatus; }
}

View File

@@ -1,6 +1,15 @@
/*
* Data transfer object for authentication requests and responses.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
//Used to send login data to the backend
/**
* Data Transfer Object for authentication credentials.
*/
public class AuthDTO {
public static class LoginRequest {
private String username;

View File

@@ -0,0 +1,35 @@
/*
* Response object returned after uploading an avatar image.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Response containing the URL of a newly uploaded avatar.
*/
public class AvatarUploadResponse {
private String avatarUrl;
private String message;
public AvatarUploadResponse() {
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

View File

@@ -0,0 +1,32 @@
/*
* Request object for deleting multiple items at once.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import java.util.List;
/**
* Request body for deleting multiple records at once.
*/
public class BulkDeleteRequest {
private List<Long> ids;
public BulkDeleteRequest() {
}
public BulkDeleteRequest(List<Long> ids) {
this.ids = ids;
}
public List<Long> getIds() {
return ids;
}
public void setIds(List<Long> ids) {
this.ids = ids;
}
}

View File

@@ -0,0 +1,39 @@
/*
* Data transfer object for categories.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for product categories.
*/
public class CategoryDTO {
private Long categoryId;
private String categoryName;
private String categoryType;
private String createdAt;
private String updatedAt;
public Long getCategoryId() {
return categoryId;
}
public String getCategoryName() {
return categoryName;
}
public String getCategoryType() {
return categoryType;
}
public String getCreatedAt() {
return createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -1,5 +1,15 @@
/*
* Data transfer object for chat conversations.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for chat conversations.
*/
public class ConversationDTO {
private Long id;
private Long customerId;
@@ -12,6 +22,14 @@ public class ConversationDTO {
public ConversationDTO() {}
public ConversationDTO(Long id, Long customerId, Long staffId, String lastMessage, String status) {
this.id = id;
this.customerId = customerId;
this.staffId = staffId;
this.lastMessage = lastMessage;
this.status = status;
}
public Long getId() {
return id;
}

View File

@@ -0,0 +1,62 @@
/*
* Data transfer object for coupons.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import java.math.BigDecimal;
/**
* Data Transfer Object for coupons.
*/
public class CouponDTO {
private Long couponId;
private String couponCode;
private String discountType;
private BigDecimal discountValue;
private BigDecimal minOrderAmount;
private Boolean active;
private String startsAt;
private String endsAt;
private Integer usageLimit;
private String createdAt;
private String updatedAt;
public CouponDTO() {}
public Long getCouponId() { return couponId; }
public void setCouponId(Long couponId) { this.couponId = couponId; }
public String getCouponCode() { return couponCode; }
public void setCouponCode(String couponCode) { this.couponCode = couponCode; }
public String getDiscountType() { return discountType; }
public void setDiscountType(String discountType) { this.discountType = discountType; }
public BigDecimal getDiscountValue() { return discountValue; }
public void setDiscountValue(BigDecimal discountValue) { this.discountValue = discountValue; }
public BigDecimal getMinOrderAmount() { return minOrderAmount; }
public void setMinOrderAmount(BigDecimal minOrderAmount) { this.minOrderAmount = minOrderAmount; }
public Boolean getActive() { return active; }
public void setActive(Boolean active) { this.active = active; }
public String getStartsAt() { return startsAt; }
public void setStartsAt(String startsAt) { this.startsAt = startsAt; }
public String getEndsAt() { return endsAt; }
public void setEndsAt(String endsAt) { this.endsAt = endsAt; }
public Integer getUsageLimit() { return usageLimit; }
public void setUsageLimit(Integer usageLimit) { this.usageLimit = usageLimit; }
public String getCreatedAt() { return createdAt; }
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
public String getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -1,59 +1,90 @@
/*
* Data transfer object for customers.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import com.google.gson.annotations.SerializedName;
/**
* Data Transfer Object for customers.
*/
public class CustomerDTO {
@SerializedName("customerId")
@SerializedName("id")
private Long customerId;
private String username;
private String firstName;
private String lastName;
private String fullName;
private String email;
private String phone;
private Boolean active;
private Integer loyaltyPoints;
private Long primaryStoreId;
private String createdAt;
private String updatedAt;
private String password;
private String role;
public CustomerDTO() {}
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
public CustomerDTO(String username, String password, String firstName, String lastName,
String email, String phone) {
this.username = username;
this.password = password;
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public Long getCustomerId() { return customerId; }
public void setCustomerId(Long customerId) { this.customerId = customerId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getFullName() {
return (firstName != null ? firstName : "") + " " + (lastName != null ? lastName : "");
if (fullName != null) return fullName;
String f = firstName != null ? firstName : "";
String l = lastName != null ? lastName : "";
return (f + " " + l).trim();
}
}
public void setFullName(String fullName) { this.fullName = fullName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public Boolean getActive() { return active; }
public void setActive(Boolean active) { this.active = active; }
public Integer getLoyaltyPoints() { return loyaltyPoints; }
public void setLoyaltyPoints(Integer loyaltyPoints) { this.loyaltyPoints = loyaltyPoints; }
public Long getPrimaryStoreId() { return primaryStoreId; }
public void setPrimaryStoreId(Long primaryStoreId) { this.primaryStoreId = primaryStoreId; }
public String getCreatedAt() { return createdAt; }
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
public String getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}

View File

@@ -0,0 +1,39 @@
/*
* Data transfer object used to populate dropdown menus.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for simple dropdown selection lists.
*/
public class DropdownDTO {
private Long id;
private String label;
public DropdownDTO() {}
public DropdownDTO(Long id, String label) {
this.id = id;
this.label = label;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
}

View File

@@ -0,0 +1,166 @@
/*
* Data transfer object for employees.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for employees.
*/
public class EmployeeDTO {
private Long id;
private String username;
private String firstName;
private String lastName;
private String fullName;
private String email;
private String phone;
private String role;
private String staffRole;
private Boolean active;
private Integer loyaltyPoints;
private Long primaryStoreId;
private String createdAt;
private String updatedAt;
private String password;
public EmployeeDTO() {}
public EmployeeDTO(String username, String password, String firstName, String lastName,
String email, String phone, String role, String staffRole, boolean active, Long primaryStoreId) {
this.username = username;
this.password = password;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.phone = phone;
this.role = role;
this.staffRole = staffRole;
this.active = active;
this.primaryStoreId = primaryStoreId;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public String getStaffRole() {
return staffRole;
}
public void setStaffRole(String staffRole) {
this.staffRole = staffRole;
}
public Boolean getActive() {
return active;
}
public void setActive(Boolean active) {
this.active = active;
}
public Integer getLoyaltyPoints() {
return loyaltyPoints;
}
public void setLoyaltyPoints(Integer loyaltyPoints) {
this.loyaltyPoints = loyaltyPoints;
}
public Long getPrimaryStoreId() {
return primaryStoreId;
}
public void setPrimaryStoreId(Long primaryStoreId) {
this.primaryStoreId = primaryStoreId;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(String updatedAt) {
this.updatedAt = updatedAt;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,23 @@
/*
* Represents an error response from the server.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Used to get messages of any errors from the backend.
*/
public class ErrorResponse {
private String message;
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

View File

@@ -0,0 +1,106 @@
/*
* Data transfer object for inventory items.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for inventory stock.
*/
public class InventoryDTO {
// Response fields (from backend InventoryResponse)
private Long inventoryId;
private Long prodId;
private String productName;
private String categoryName;
private Long storeId;
private String storeName;
private Integer quantity;
private String createdAt;
private String updatedAt;
public InventoryDTO() {
}
// Constructor for create/update requests (matches InventoryRequest)
public InventoryDTO(Long prodId, Long storeId, Integer quantity) {
this.prodId = prodId;
this.storeId = storeId;
this.quantity = quantity;
}
public Long getInventoryId() {
return inventoryId;
}
public void setInventoryId(Long inventoryId) {
this.inventoryId = inventoryId;
}
public Long getProdId() {
return prodId;
}
public void setProdId(Long prodId) {
this.prodId = prodId;
}
public String getProductName() {
return productName;
}
public void setProductName(String productName) {
this.productName = productName;
}
public String getCategoryName() {
return categoryName;
}
public void setCategoryName(String categoryName) {
this.categoryName = categoryName;
}
public Long getStoreId() {
return storeId;
}
public void setStoreId(Long storeId) {
this.storeId = storeId;
}
public String getStoreName() {
return storeName;
}
public void setStoreName(String storeName) {
this.storeName = storeName;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(String updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -1,7 +1,17 @@
/*
* Data transfer object for chat messages.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import com.google.gson.annotations.SerializedName;
/**
* Data Transfer Object for chat messages.
*/
public class MessageDTO {
@SerializedName("id")
@@ -22,6 +32,24 @@ public class MessageDTO {
@SerializedName("isRead")
private Boolean isRead;
@SerializedName("attachmentUrl")
private String attachmentUrl;
@SerializedName("attachmentName")
private String attachmentName;
@SerializedName("attachmentMimeType")
private String attachmentMimeType;
@SerializedName("attachmentSizeBytes")
private Long attachmentSizeBytes;
@SerializedName("senderRole")
private String senderRole;
@SerializedName("senderDisplayName")
private String senderDisplayName;
public MessageDTO() {}
public Long getId() { return id; }
@@ -41,4 +69,22 @@ public class MessageDTO {
public Boolean getIsRead() { return isRead; }
public void setIsRead(Boolean isRead) { this.isRead = isRead; }
public String getAttachmentUrl() { return attachmentUrl; }
public void setAttachmentUrl(String attachmentUrl) { this.attachmentUrl = attachmentUrl; }
public String getAttachmentName() { return attachmentName; }
public void setAttachmentName(String attachmentName) { this.attachmentName = attachmentName; }
public String getAttachmentMimeType() { return attachmentMimeType; }
public void setAttachmentMimeType(String attachmentMimeType) { this.attachmentMimeType = attachmentMimeType; }
public Long getAttachmentSizeBytes() { return attachmentSizeBytes; }
public void setAttachmentSizeBytes(Long attachmentSizeBytes) { this.attachmentSizeBytes = attachmentSizeBytes; }
public String getSenderRole() { return senderRole; }
public void setSenderRole(String senderRole) { this.senderRole = senderRole; }
public String getSenderDisplayName() { return senderDisplayName; }
public void setSenderDisplayName(String senderDisplayName) { this.senderDisplayName = senderDisplayName; }
}

View File

@@ -1,8 +1,17 @@
/*
* Wrapper for paginated responses from the server.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import java.util.List;
//Used to get data from the API
/**
* Generic response wrapper for paginated API results.
*/
public class PageResponse<T> {
private List<T> content;
private int totalPages;

View File

@@ -1,5 +1,15 @@
/*
* Data transfer object for pets.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object representing a pet.
*/
public class PetDTO {
private Long petId;
private String petName;
@@ -7,9 +17,13 @@ public class PetDTO {
private String petBreed;
private Integer petAge;
private String petStatus;
private String petPrice;
private Double petPrice;
private String createdAt;
private String updatedAt;
private Long customerId;
private String customerName;
private Long storeId;
private String storeName;
public Long getPetId() { return petId; }
public void setPetId(Long petId) { this.petId = petId; }
@@ -29,12 +43,24 @@ public class PetDTO {
public String getPetStatus() { return petStatus; }
public void setPetStatus(String petStatus) { this.petStatus = petStatus; }
public String getPetPrice() { return petPrice; }
public void setPetPrice(String petPrice) { this.petPrice = petPrice; }
public Double getPetPrice() { return petPrice; }
public void setPetPrice(Double petPrice) { this.petPrice = petPrice; }
public String getCreatedAt() { return createdAt; }
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
public String getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; }
public Long getCustomerId() { return customerId; }
public void setCustomerId(Long customerId) { this.customerId = customerId; }
public String getCustomerName() { return customerName; }
public void setCustomerName(String customerName) { this.customerName = customerName; }
public Long getStoreId() { return storeId; }
public void setStoreId(Long storeId) { this.storeId = storeId; }
public String getStoreName() { return storeName; }
public void setStoreName(String storeName) { this.storeName = storeName; }
}

View File

@@ -0,0 +1,91 @@
/*
* Data transfer object for products.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import java.math.BigDecimal;
/**
* Data Transfer Object for products.
*/
public class ProductDTO {
private Long prodId;
private String prodName;
private Long categoryId;
private String categoryName;
private String prodDesc;
private BigDecimal prodPrice;
private String createdAt;
private String updatedAt;
public ProductDTO() {
}
// Constructor for create/update
public ProductDTO(String prodName, Long categoryId, String prodDesc, BigDecimal prodPrice) {
this.prodName = prodName;
this.categoryId = categoryId;
this.prodDesc = prodDesc;
this.prodPrice = prodPrice;
}
public Long getProdId() {
return prodId;
}
public void setProdId(Long prodId) {
this.prodId = prodId;
}
public String getProdName() {
return prodName;
}
public void setProdName(String prodName) {
this.prodName = prodName;
}
public Long getCategoryId() {
return categoryId;
}
public void setCategoryId(Long categoryId) {
this.categoryId = categoryId;
}
public String getCategoryName() {
return categoryName;
}
public void setCategoryName(String categoryName) {
this.categoryName = categoryName;
}
public String getProdDesc() {
return prodDesc;
}
public void setProdDesc(String prodDesc) {
this.prodDesc = prodDesc;
}
public BigDecimal getProdPrice() {
return prodPrice;
}
public void setProdPrice(BigDecimal prodPrice) {
this.prodPrice = prodPrice;
}
public String getCreatedAt() {
return createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -0,0 +1,58 @@
/*
* Data transfer object for product-supplier relationships.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import java.math.BigDecimal;
/**
* Data Transfer Object for mapping products to suppliers.
*/
public class ProductSupplierDTO {
private Long productId;
private String productName;
private Long supplierId;
private String supplierName;
private BigDecimal cost;
private String createdAt;
private String updatedAt;
// Constructor for create/update
public ProductSupplierDTO(Long productId, Long supplierId, BigDecimal cost) {
this.productId = productId;
this.supplierId = supplierId;
this.cost = cost;
}
public Long getProductId() {
return productId;
}
public String getProductName() {
return productName;
}
public Long getSupplierId() {
return supplierId;
}
public String getSupplierName() {
return supplierName;
}
public BigDecimal getCost() {
return cost;
}
public String getCreatedAt() {
return createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -0,0 +1,59 @@
/*
* Data transfer object for purchase orders.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for purchase orders.
*/
public class PurchaseOrderDTO {
private Long purchaseOrderId;
private Long supId;
private String supplierName;
private Long storeId;
private String storeName;
private String orderDate;
private String status;
private String createdAt;
private String updatedAt;
public Long getPurchaseOrderId() {
return purchaseOrderId;
}
public Long getSupId() {
return supId;
}
public String getSupplierName() {
return supplierName;
}
public Long getStoreId() {
return storeId;
}
public String getStoreName() {
return storeName;
}
public String getOrderDate() {
return orderDate;
}
public String getStatus() {
return status;
}
public String getCreatedAt() {
return createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -0,0 +1,68 @@
/*
* Data transfer object for refunds.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import java.math.BigDecimal;
/**
* Data Transfer Object for refund processing.
*/
public class RefundDTO {
// Response fields
private Long id;
private Long saleId;
private Long customerId;
private BigDecimal amount;
private String reason;
private String status;
private String createdAt;
private String updatedAt;
// Constructor for create request
public RefundDTO(Long saleId, String reason) {
this.saleId = saleId;
this.reason = reason;
}
// Constructor for update request
public RefundDTO(String status) {
this.status = status;
}
public Long getId() {
return id;
}
public Long getSaleId() {
return saleId;
}
public Long getCustomerId() {
return customerId;
}
public BigDecimal getAmount() {
return amount;
}
public String getReason() {
return reason;
}
public String getStatus() {
return status;
}
public String getCreatedAt() {
return createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -0,0 +1,200 @@
/*
* Data transfer object for sales.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
import java.math.BigDecimal;
import java.util.List;
/**
* Data Transfer Object for sales transactions.
*/
public class SaleDTO {
// Response fields
private Long saleId;
private String saleDate;
private Long employeeId;
private String employeeName;
private Long customerId;
private String customerName;
private Long storeId;
private String storeName;
private BigDecimal totalAmount;
private BigDecimal subtotalAmount;
private BigDecimal couponDiscountAmount;
private BigDecimal employeeDiscountAmount;
private BigDecimal loyaltyDiscountAmount;
private BigDecimal pointsDiscountAmount;
private Integer pointsUsed;
private String paymentMethod;
private String channel;
private Boolean isRefund;
private Long originalSaleId;
private Long cartId;
private Long couponId;
private Integer pointsEarned;
private List<SaleItemDTO> items;
private String createdAt;
// Constructor for create request
public SaleDTO(Long storeId, String paymentMethod, List<SaleItemDTO> items,
Boolean isRefund, Long originalSaleId, Long customerId) {
this.storeId = storeId;
this.paymentMethod = paymentMethod;
this.items = items;
this.isRefund = isRefund;
this.originalSaleId = originalSaleId;
this.customerId = customerId;
}
public Long getSaleId() {
return saleId;
}
public String getSaleDate() {
return saleDate;
}
public Long getEmployeeId() {
return employeeId;
}
public String getEmployeeName() {
return employeeName;
}
public Long getStoreId() {
return storeId;
}
public String getStoreName() {
return storeName;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public BigDecimal getSubtotalAmount() {
return subtotalAmount;
}
public BigDecimal getCouponDiscountAmount() {
return couponDiscountAmount;
}
public BigDecimal getEmployeeDiscountAmount() {
return employeeDiscountAmount;
}
public BigDecimal getLoyaltyDiscountAmount() {
return loyaltyDiscountAmount;
}
public void setLoyaltyDiscountAmount(BigDecimal loyaltyDiscountAmount) {
this.loyaltyDiscountAmount = loyaltyDiscountAmount;
}
public Integer getPointsUsed() {
return pointsUsed;
}
public void setPointsUsed(Integer pointsUsed) {
this.pointsUsed = pointsUsed;
}
public String getPaymentMethod() {
return paymentMethod;
}
public String getChannel() {
return channel;
}
public Boolean getIsRefund() {
return isRefund;
}
public Long getOriginalSaleId() {
return originalSaleId;
}
public Long getCartId() {
return cartId;
}
public Long getCouponId() {
return couponId;
}
public void setCouponId(Long couponId) {
this.couponId = couponId;
}
public Integer getPointsEarned() {
return pointsEarned;
}
public List<SaleItemDTO> getItems() {
return items;
}
public String getCreatedAt() {
return createdAt;
}
public Long getCustomerId() {
return customerId;
}
public String getCustomerName() {
return customerName;
}
public BigDecimal getPointsDiscountAmount() {
return pointsDiscountAmount;
}
public void setPointsDiscountAmount(BigDecimal pointsDiscountAmount) {
this.pointsDiscountAmount = pointsDiscountAmount;
}
// Nested SaleItemDTO
public static class SaleItemDTO {
private Long saleItemId;
private Long prodId;
private String productName;
private Integer quantity;
private BigDecimal unitPrice;
// Constructor for request
public SaleItemDTO(Long prodId, Integer quantity) {
this.prodId = prodId;
this.quantity = quantity;
}
public Long getSaleItemId() {
return saleItemId;
}
public Long getProdId() {
return prodId;
}
public String getProductName() {
return productName;
}
public Integer getQuantity() {
return quantity;
}
public BigDecimal getUnitPrice() {
return unitPrice;
}
}
}

View File

@@ -1,5 +1,15 @@
/*
* Request object for sending a new chat message.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Request body for sending a new chat message.
*/
public class SendMessageRequest {
private String content;

View File

@@ -1,5 +1,15 @@
/*
* Data transfer object for services.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for services.
*/
public class ServiceDTO {
private Long serviceId;
private String serviceName;

View File

@@ -0,0 +1,35 @@
/*
* Data transfer object for store information.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for store information.
*/
public class StoreDTO {
private Long storeId;
private String storeName;
private String address;
private String phone;
private String email;
private String createdAt;
private String updatedAt;
// Constructor for hardcoded fallback
public StoreDTO(Long storeId, String storeName) {
this.storeId = storeId;
this.storeName = storeName;
}
public Long getStoreId() { return storeId; }
public String getStoreName() { return storeName; }
public String getAddress() { return address; }
public String getPhone() { return phone; }
public String getEmail() { return email; }
public String getCreatedAt() { return createdAt; }
public String getUpdatedAt() { return updatedAt; }
}

View File

@@ -1,5 +1,15 @@
/*
* Data transfer object for suppliers.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for suppliers.
*/
public class SupplierDTO {
private Long supId;
private String supCompany;

View File

@@ -0,0 +1,27 @@
/*
* Request object for changing a conversation's status.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Request body for updating chat conversation status.
*/
public class UpdateConversationStatusRequest {
private String status;
public UpdateConversationStatusRequest(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,60 @@
/*
* Data transfer object for user account information.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.dtos;
/**
* Data Transfer Object for user account details.
*/
public class UserDTO {
private Long id;
private String username;
private String email;
private String fullName;
private String phone;
private String avatarUrl;
private String role;
private Long storeId;
private String storeName;
// Getters
public Long getId() {
return id;
}
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
public String getFullName() {
return fullName;
}
public String getPhone() {
return phone;
}
public String getAvatarUrl() {
return avatarUrl;
}
public String getRole() {
return role;
}
public Long getStoreId() {
return storeId;
}
public String getStoreName() {
return storeName;
}
}

View File

@@ -1,407 +1,639 @@
/*
* Fragment for the real-time chat screen.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.fragments;
import android.app.Activity;
import android.app.Dialog;
import android.content.ContentValues;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.MediaStore;
import android.util.Log;
import android.view.*;
import android.widget.*;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.ChatAdapter;
import com.example.petstoremobile.adapters.MessageAdapter;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.api.ChatApi;
import com.example.petstoremobile.api.CustomerApi;
import com.example.petstoremobile.api.MessageApi;
import com.example.petstoremobile.api.RetrofitClient;
import com.example.petstoremobile.databinding.FragmentChatBinding;
import com.example.petstoremobile.dtos.ConversationDTO;
import com.example.petstoremobile.dtos.CustomerDTO;
import com.example.petstoremobile.dtos.MessageDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.SendMessageRequest;
import com.example.petstoremobile.models.Chat;
import com.example.petstoremobile.models.Message;
import com.example.petstoremobile.services.ChatNotificationService;
import com.example.petstoremobile.utils.DialogUtils;
import com.example.petstoremobile.utils.FileUtils;
import com.example.petstoremobile.utils.GlideUtils;
import com.example.petstoremobile.utils.Resource;
import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.ChatListViewModel;
import com.example.petstoremobile.websocket.StompChatManager;
import java.util.*;
import java.util.stream.Collectors;
import retrofit2.*;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import dagger.hilt.android.AndroidEntryPoint;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
/**
* Fragment for handling customer support chat.
*/
@AndroidEntryPoint
public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickListener, StompChatManager.MessageListener,
StompChatManager.ConversationListener, StompChatManager.ConnectionListener {
private static final String TAG = "ChatFragment";
// View
private DrawerLayout drawerLayout;
private RecyclerView rvChatList, rvMessages;
private EditText etMessage;
private Button btnSend;
private FragmentChatBinding binding;
private ChatListViewModel viewModel;
// Adapters
private ChatAdapter chatAdapter;
private ChatAdapter activeChatAdapter;
private ChatAdapter closedChatAdapter;
private MessageAdapter messageAdapter;
// Data
private final List<Chat> chatList = new ArrayList<>();
private final List<Chat> activeChatList = new ArrayList<>();
private final List<Chat> closedChatList = new ArrayList<>();
private final List<Message> messageList = new ArrayList<>();
private final Map<Long, String> customerNames = new HashMap<>();
private Uri pendingAttachmentUri;
// APIs
private ChatApi chatApi;
private CustomerApi customerApi;
private MessageApi messageApi;
@Inject TokenManager tokenManager;
@Inject @Named("baseUrl") String baseUrl;
// chat
private Long currentUserId;
private Long activeConversationId;
private StompChatManager stompChatManager;
private ActivityResultLauncher<Intent> attachmentLauncher;
/**
* Initializes the view model and attachment launcher.
*/
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
viewModel = new ViewModelProvider(requireActivity()).get(ChatListViewModel.class);
attachmentLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
Uri uri = result.getData().getData();
if (uri != null) showAttachmentPreview(uri);
}
}
);
}
/**
* Inflates the layout and sets up UI event listeners.
*/
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
binding = FragmentChatBinding.inflate(inflater, container, false);
View view = inflater.inflate(R.layout.fragment_chat, container, false);
binding.btnHamburger.setOnClickListener(v -> binding.chatDrawerLayout.openDrawer(GravityCompat.START));
chatApi = RetrofitClient.getChatApi(requireContext());
customerApi = RetrofitClient.getCustomerApi(requireContext());
messageApi = RetrofitClient.getMessageApi(requireContext());
binding.etMessage.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEND || actionId == EditorInfo.IME_NULL) {
binding.btnSend.performClick();
return true;
}
return false;
});
drawerLayout = view.findViewById(R.id.chatDrawerLayout);
rvChatList = view.findViewById(R.id.rvChatList);
rvMessages = view.findViewById(R.id.rvMessages);
etMessage = view.findViewById(R.id.etMessage);
btnSend = view.findViewById(R.id.btnSend);
binding.btnSend.setOnClickListener(v -> {
if (pendingAttachmentUri != null) sendWithAttachment(pendingAttachmentUri);
else sendMessage();
});
ImageButton hamburger = view.findViewById(R.id.btnHamburger);
hamburger.setOnClickListener(v -> drawerLayout.openDrawer(GravityCompat.START));
btnSend.setOnClickListener(v -> sendMessage());
binding.btnAttach.setOnClickListener(v -> selectAttachment());
binding.btnRemoveAttachment.setOnClickListener(v -> removeAttachment());
binding.btnCloseChat.setOnClickListener(v -> closeChat());
setupDrawerToggles();
setupRecyclerViews();
observeViewModel();
loadInitialData();
return view;
return binding.getRoot();
}
private void setupRecyclerViews() {
// Set up Drawer menu to select conversation
chatAdapter = new ChatAdapter(chatList, this);
rvChatList.setLayoutManager(new LinearLayoutManager(getContext()));
rvChatList.setAdapter(chatAdapter);
/**
* Sets up the logic to open and close the chat drawer.
*/
private void setupDrawerToggles() {
binding.headerActiveChats.setOnClickListener(v -> {
if (binding.rvActiveChats.getVisibility() == View.VISIBLE) {
binding.rvActiveChats.setVisibility(View.GONE);
binding.ivActiveChevron.setImageResource(android.R.drawable.arrow_down_float);
} else {
binding.rvActiveChats.setVisibility(View.VISIBLE);
binding.ivActiveChevron.setImageResource(android.R.drawable.arrow_up_float);
}
});
binding.headerClosedChats.setOnClickListener(v -> {
if (binding.rvClosedChats.getVisibility() == View.VISIBLE) {
binding.rvClosedChats.setVisibility(View.GONE);
binding.ivClosedChevron.setImageResource(android.R.drawable.arrow_down_float);
} else {
binding.rvClosedChats.setVisibility(View.VISIBLE);
binding.ivClosedChevron.setImageResource(android.R.drawable.arrow_up_float);
}
});
}
/**
* Configures the adapters and layout managers for chat lists and message history.
*/
private void setupRecyclerViews() {
activeChatAdapter = new ChatAdapter(activeChatList, this);
binding.rvActiveChats.setLayoutManager(new LinearLayoutManager(getContext()));
binding.rvActiveChats.setAdapter(activeChatAdapter);
closedChatAdapter = new ChatAdapter(closedChatList, this);
binding.rvClosedChats.setLayoutManager(new LinearLayoutManager(getContext()));
binding.rvClosedChats.setAdapter(closedChatAdapter);
// set up RecyclerView for selected chat to show messages
messageAdapter = new MessageAdapter(messageList, null);
messageAdapter.setBaseUrl(baseUrl);
messageAdapter.setOnAttachmentClickListener(message -> {
if (message.getAttachmentMimeType() != null && message.getAttachmentMimeType().startsWith("image/")) {
showFullScreenImage(message);
} else {
downloadFile(message);
}
});
LinearLayoutManager lm = new LinearLayoutManager(getContext());
lm.setStackFromEnd(true);
rvMessages.setLayoutManager(lm);
rvMessages.setAdapter(messageAdapter);
setConversationActive(false);
binding.rvMessages.setLayoutManager(lm);
binding.rvMessages.setItemAnimator(null);
binding.rvMessages.setAdapter(messageAdapter);
setConversationActive(false, null);
}
//Helper function to load token and user id then connect to websocket
/**
* Displays a full-screen image preview for message attachments.
*/
private void showFullScreenImage(Message message) {
if (baseUrl == null || message.getId() == null) return;
Dialog dialog = new Dialog(requireContext(), android.R.style.Theme_Black_NoTitleBar_Fullscreen);
dialog.setContentView(R.layout.dialog_full_screen_image);
ImageView imageView = dialog.findViewById(R.id.ivFullScreen);
ImageButton closeButton = dialog.findViewById(R.id.btnClose);
ImageButton downloadButton = dialog.findViewById(R.id.btnDownload);
String cleanBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String downloadUrl = cleanBase + "/api/v1/chat/messages/" + message.getId() + "/attachment";
GlideUtils.loadImageWithToken(requireContext(), imageView, downloadUrl, tokenManager.getToken(), R.drawable.placeholder);
closeButton.setOnClickListener(v -> dialog.dismiss());
downloadButton.setOnClickListener(v -> downloadFile(message));
imageView.setOnClickListener(v -> dialog.dismiss());
dialog.show();
}
/**
* Initiates the download process for a message attachment.
*/
private void downloadFile(Message message) {
if (message.getId() == null) return;
DialogUtils.showConfirmDialog(requireContext(), "Download Attachment",
"Do you want to download \"" + message.getAttachmentName() + "\"?", () -> {
Toast.makeText(requireContext(), "Downloading " + message.getAttachmentName() + "...", Toast.LENGTH_SHORT).show();
viewModel.downloadAttachment(message.getId()).observe(getViewLifecycleOwner(), resource -> {
if (resource == null) return;
setLoading(resource.status == Resource.Status.LOADING);
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
saveFileToDownloads(resource.data, message.getAttachmentName(), message.getAttachmentMimeType());
} else if (resource.status == Resource.Status.ERROR) {
Toast.makeText(requireContext(), "Download failed: " + resource.message, Toast.LENGTH_SHORT).show();
}
});
});
}
/**
* Saves the downloaded file body to the device's downloads folder.
*/
private void saveFileToDownloads(ResponseBody body, String fileName, String mimeType) {
android.os.Handler mainHandler = new android.os.Handler(android.os.Looper.getMainLooper());
new Thread(() -> {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentValues values = new ContentValues();
values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
values.put(MediaStore.Downloads.MIME_TYPE, mimeType);
values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
Uri uri = requireContext().getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
if (uri != null) {
try (OutputStream outputStream = requireContext().getContentResolver().openOutputStream(uri);
InputStream inputStream = body.byteStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
mainHandler.post(() -> Toast.makeText(requireContext(), "File saved to Downloads", Toast.LENGTH_SHORT).show());
}
} else {
File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
File file = new File(downloadsDir, fileName);
try (OutputStream outputStream = new FileOutputStream(file);
InputStream inputStream = body.byteStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
mainHandler.post(() -> Toast.makeText(requireContext(), "File saved to Downloads: " + file.getAbsolutePath(), Toast.LENGTH_LONG).show());
}
body.close();
} catch (Exception e) {
Log.e(TAG, "Error saving file", e);
mainHandler.post(() -> Toast.makeText(requireContext(), "Error saving file", Toast.LENGTH_SHORT).show());
}
}).start();
}
/**
* Observes LiveData from the ViewModel to update chat lists and messages.
*/
private void observeViewModel() {
viewModel.getActiveChats().observe(getViewLifecycleOwner(), list -> {
activeChatList.clear();
activeChatList.addAll(list);
activeChatAdapter.notifyDataSetChanged();
updateTitleAndStateIfActive(list);
});
viewModel.getClosedChats().observe(getViewLifecycleOwner(), list -> {
closedChatList.clear();
closedChatList.addAll(list);
closedChatAdapter.notifyDataSetChanged();
updateTitleAndStateIfActive(list);
});
viewModel.getMessageList().observe(getViewLifecycleOwner(), list -> {
int prevSize = messageList.size();
messageList.clear();
messageList.addAll(list);
if (prevSize > 0 && list.size() == prevSize + 1) {
messageAdapter.notifyItemInserted(list.size() - 1);
} else {
messageAdapter.notifyDataSetChanged();
}
scrollToBottom();
});
viewModel.getIsLoading().observe(getViewLifecycleOwner(), this::setLoading);
}
/**
* Toggles the visibility of the progress bar.
*/
private void setLoading(boolean loading) {
if (binding != null && binding.progressBar != null) {
binding.progressBar.setVisibility(loading ? View.VISIBLE : View.GONE);
}
}
/**
* Updates the chat header and input state if the active conversation changes.
*/
private void updateTitleAndStateIfActive(List<Chat> list) {
if (activeConversationId != null) {
for (Chat chat : list) {
if (chat.getChatId().equals(String.valueOf(activeConversationId))) {
binding.tvChatTitle.setText(chat.getCustomerName());
setConversationActive(true, chat.getStatus());
break;
}
}
}
}
/**
* Loads initial chat data and establishes WebSocket connection.
*/
private void loadInitialData() {
TokenManager tm = TokenManager.getInstance(requireContext());
String token = tm.getToken();
currentUserId = tm.getUserId();
String role = tm.getRole();
String token = tokenManager.getToken();
Long currentUserId = tokenManager.getUserId();
String role = tokenManager.getRole();
messageAdapter.setCurrentUserId(currentUserId);
messageAdapter.setToken(token);
// if token exist then connect to websocket
if (token != null) {
stompChatManager = new StompChatManager(token, role);
stompChatManager = new StompChatManager(token, role, baseUrl);
stompChatManager.setMessageListener(this);
stompChatManager.setConversationListener(this);
stompChatManager.setConnectionListener(this);
stompChatManager.connect();
} else {
Log.e(TAG, "No token found");
}
loadCustomers();
if (getArguments() != null && getArguments().containsKey("conversation_id")) {
activeConversationId = getArguments().getLong("conversation_id");
} else if (getActivity() != null && getActivity().getIntent().hasExtra("conversation_id")) {
activeConversationId = getActivity().getIntent().getLongExtra("conversation_id", -1);
getActivity().getIntent().removeExtra("conversation_id");
} else {
activeConversationId = viewModel.getLastActiveConversationId();
}
viewModel.loadCustomers();
if (activeConversationId != null) {
if (stompChatManager != null) stompChatManager.subscribeToConversation(activeConversationId);
viewModel.loadMessageHistory(activeConversationId);
} else {
setConversationActive(false, null);
}
}
//Helper function to load customer names for it to be displayed on drawer menu
private void loadCustomers() {
customerApi.getAllCustomers(0, 100).enqueue(new Callback<PageResponse<CustomerDTO>>() {
@Override
public void onResponse(@NonNull Call<PageResponse<CustomerDTO>> call,
@NonNull Response<PageResponse<CustomerDTO>> response) {
if (response.isSuccessful() && response.body() != null) {
for (CustomerDTO c : response.body().getContent()) {
customerNames.put(c.getCustomerId(), c.getFullName());
}
}
loadConversations();
}
@Override
public void onFailure(@NonNull Call<PageResponse<CustomerDTO>> call,
@NonNull Throwable t) {
loadConversations();
}
});
}
//helper function to load conversations entities to display with customer names in drawer menu
private void loadConversations() {
chatApi.getAllConversations().enqueue(new Callback<List<ConversationDTO>>() {
@Override
public void onResponse(@NonNull Call<List<ConversationDTO>> call,
@NonNull Response<List<ConversationDTO>> response) {
if (response.isSuccessful() && response.body() != null) {
chatList.clear();
List<Chat> loaded = response.body().stream()
.map(dto -> {
String name = customerNames.getOrDefault(
dto.getCustomerId(), "Customer #" + dto.getCustomerId());
return new Chat(String.valueOf(dto.getId()),
name, dto.getLastMessage(),
dto.getCustomerId(), dto.getStaffId());
})
.collect(Collectors.toList());
chatList.addAll(loaded);
chatAdapter.notifyDataSetChanged();
if (activeConversationId == null) {
messageList.clear();
messageAdapter.notifyDataSetChanged();
setConversationActive(false);
}
}
}
@Override
public void onFailure(@NonNull Call<List<ConversationDTO>> call,
@NonNull Throwable t) {
Log.e(TAG, "Error loading conversations", t);
}
});
}
// Called when user taps a chat in the drawer
// Loads messages for that chat selected
/**
* Handles clicks on a chat from the drawer to switch the active conversation.
*/
@Override
public void onChatClick(Chat chat) {
activeConversationId = Long.parseLong(chat.getChatId());
setConversationActive(true);
drawerLayout.closeDrawer(GravityCompat.START);
viewModel.setLastActiveConversationId(activeConversationId);
if (stompChatManager != null) {
stompChatManager.subscribeToConversation(activeConversationId);
}
setConversationActive(true, chat.getStatus());
binding.tvChatTitle.setText(chat.getCustomerName());
binding.chatDrawerLayout.closeDrawer(GravityCompat.START);
loadMessageHistory(activeConversationId);
messageAdapter.setStaffId(chat.getStaffId());
if (stompChatManager != null) stompChatManager.subscribeToConversation(activeConversationId);
viewModel.loadMessageHistory(activeConversationId);
}
//helper function to load messages for selected chat
private void loadMessageHistory(Long conversationId) {
messageApi.getMessages(conversationId).enqueue(new Callback<List<MessageDTO>>() {
@Override
public void onResponse(@NonNull Call<List<MessageDTO>> call,
@NonNull Response<List<MessageDTO>> response) {
if (response.isSuccessful() && response.body() != null) {
messageList.clear();
for (MessageDTO dto : response.body()) {
messageList.add(dtoToModel(dto));
}
messageAdapter.notifyDataSetChanged();
scrollToBottom();
/**
* Closes the active chat conversation.
*/
private void closeChat() {
if (activeConversationId == null) return;
DialogUtils.showConfirmDialog(requireContext(), "Close Chat",
"Are you sure you want to close this chat? This will notify the customer.", () -> {
viewModel.sendMessage(activeConversationId, "The Chat has been closed").observe(getViewLifecycleOwner(), resource -> {
if (resource != null && resource.status == Resource.Status.SUCCESS) {
viewModel.addMessageLocally(resource.data);
viewModel.closeConversation(activeConversationId).observe(getViewLifecycleOwner(), statusResource -> {
if (statusResource == null) return;
setLoading(statusResource.status == Resource.Status.LOADING);
if (statusResource.status == Resource.Status.SUCCESS) {
viewModel.loadConversations();
setConversationActive(true, "CLOSED");
} else if (statusResource.status == Resource.Status.ERROR) {
Toast.makeText(requireContext(), "Failed to close chat: " + statusResource.message, Toast.LENGTH_SHORT).show();
}
});
}
}
@Override
public void onFailure(@NonNull Call<List<MessageDTO>> call,
@NonNull Throwable t) {
Log.e(TAG, "Error loading messages", t);
});
});
}
/**
* Sends a text message to the active conversation.
*/
private void sendMessage() {
if (activeConversationId == null) return;
String text = binding.etMessage.getText().toString().trim();
if (text.isEmpty()) return;
binding.etMessage.setText("");
viewModel.sendMessage(activeConversationId, text).observe(getViewLifecycleOwner(), resource -> {
if (resource == null) return;
setLoading(resource.status == Resource.Status.LOADING);
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
viewModel.addMessageLocally(resource.data);
viewModel.loadConversations();
}
});
}
//Helper function to send a message to the chat
private void sendMessage() {
//check if a chat is selected
/**
* Opens a file picker to select an attachment.
*/
private void selectAttachment() {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
attachmentLauncher.launch(intent);
}
/**
* Displays a preview of the selected attachment.
*/
private void showAttachmentPreview(Uri uri) {
pendingAttachmentUri = uri;
binding.layoutAttachmentPreview.setVisibility(View.VISIBLE);
String mimeType = requireContext().getContentResolver().getType(uri);
binding.tvPreviewName.setText(FileUtils.getFileName(requireContext(), uri));
if (mimeType != null && mimeType.startsWith("image/")) {
binding.ivPreview.setVisibility(View.VISIBLE);
Glide.with(this).load(uri).into(binding.ivPreview);
} else {
binding.ivPreview.setVisibility(View.GONE);
}
}
/**
* Removes the currently selected attachment from the preview.
*/
private void removeAttachment() {
pendingAttachmentUri = null;
binding.layoutAttachmentPreview.setVisibility(View.GONE);
}
/**
* Sends a message with a file attachment.
*/
private void sendWithAttachment(Uri uri) {
if (activeConversationId == null) return;
//get the message from text field
String text = etMessage.getText().toString().trim();
if (text.isEmpty()) return;
File file = FileUtils.getFileFromUri(requireContext(), uri);
if (file == null) {
Toast.makeText(requireContext(), "Failed to prepare file", Toast.LENGTH_SHORT).show();
return;
}
//clear text field after sending
etMessage.setText("");
String text = binding.etMessage.getText().toString().trim();
//calls api to send the message
messageApi.sendMessage(activeConversationId, new SendMessageRequest(text))
.enqueue(new Callback<MessageDTO>() {
@Override
public void onResponse(@NonNull Call<MessageDTO> call,
@NonNull Response<MessageDTO> response) {
if (response.isSuccessful() && response.body() != null) {
messageList.add(dtoToModel(response.body()));
messageAdapter.notifyItemInserted(messageList.size() - 1);
scrollToBottom();
loadConversations();
}
}
@Override
public void onFailure(@NonNull Call<MessageDTO> call,
@NonNull Throwable t) {
Log.e(TAG, "Send failed", t);
}
});
MultipartBody.Part contentPart = text.isEmpty()
? null
: MultipartBody.Part.createFormData("content", text);
String mimeType = requireContext().getContentResolver().getType(uri);
if (mimeType == null) mimeType = "application/octet-stream";
RequestBody filePartBody = RequestBody.create(file, MediaType.parse(mimeType));
MultipartBody.Part filePart = MultipartBody.Part.createFormData("file", file.getName(), filePartBody);
binding.etMessage.setText("");
removeAttachment();
viewModel.sendMessageWithAttachment(activeConversationId, contentPart, filePart).observe(getViewLifecycleOwner(), resource -> {
if (resource == null) return;
setLoading(resource.status == Resource.Status.LOADING);
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
viewModel.addMessageLocally(resource.data);
viewModel.loadConversations();
} else if (resource.status == Resource.Status.ERROR) {
Toast.makeText(requireContext(), "Failed to send attachment: " + resource.message, Toast.LENGTH_SHORT).show();
}
});
}
// When a message is received updates the chat preview
/**
* Callback for when a new message is received through the WebSocket.
*/
@Override
public void onMessageReceived(MessageDTO dto) {
//if there is no active selected conversation or the message received is for another chat, then just update the preview of last message
if (activeConversationId == null || !activeConversationId.equals(dto.getConversationId())) {
updateConversationPreview(dto.getConversationId(), dto.getContent());
return;
}
updateConversationPreview(dto.getConversationId(), dto.getContent());
if (currentUserId != null && currentUserId.equals(dto.getSenderId())) return;
//else add the message to the active chat if it's not from the current user
messageList.add(dtoToModel(dto));
messageAdapter.notifyItemInserted(messageList.size() - 1);
scrollToBottom();
requireActivity().runOnUiThread(() -> {
if (activeConversationId != null && activeConversationId.equals(dto.getConversationId())) {
viewModel.addMessageLocally(dto);
}
viewModel.updateConversationLocally(new ConversationDTO(dto.getConversationId(), 0L, 0L, dto.getContent(), ""));
});
}
// When a new conversation is added, updates the chat preview
/**
* Callback for when a conversation's status or last message is updated.
*/
@Override
public void onConversationUpdated(ConversationDTO dto) {
boolean updated = false;
String name = customerNames.getOrDefault(
dto.getCustomerId(), "Customer #" + dto.getCustomerId());
for (int i = 0; i < chatList.size(); i++) {
Chat existing = chatList.get(i);
if (existing.getChatId().equals(String.valueOf(dto.getId()))) {
chatList.set(i, new Chat(
String.valueOf(dto.getId()),
name,
dto.getLastMessage(),
dto.getCustomerId(),
dto.getStaffId()
));
chatAdapter.notifyItemChanged(i);
updated = true;
break;
requireActivity().runOnUiThread(() -> {
viewModel.updateConversationLocally(dto);
if (activeConversationId != null && activeConversationId.equals(dto.getId())) {
setConversationActive(true, dto.getStatus());
binding.tvChatTitle.setText(viewModel.getCustomerName(dto.getCustomerId()));
}
}
if (!updated) {
chatList.add(0, new Chat(
String.valueOf(dto.getId()),
name,
dto.getLastMessage(),
dto.getCustomerId(),
dto.getStaffId()
));
chatAdapter.notifyItemInserted(0);
}
if (activeConversationId != null && activeConversationId.equals(dto.getId())) {
setConversationActive(true);
}
});
}
/**
* Callback for when the WebSocket connection is successfully opened.
*/
@Override
public void onSocketOpened() {
if (!isAdded()) {
return;
}
loadConversations();
if (activeConversationId != null) {
loadMessageHistory(activeConversationId);
}
if (!isAdded()) return;
requireActivity().runOnUiThread(() -> {
viewModel.loadConversations();
if (activeConversationId != null) viewModel.loadMessageHistory(activeConversationId);
});
}
/**
* Callback for when the WebSocket connection is closed.
*/
@Override
public void onSocketClosed() {
if (!isAdded()) {
return;
}
loadConversations();
if (!isAdded()) return;
requireActivity().runOnUiThread(viewModel::loadConversations);
}
/**
* Callback for when a WebSocket error occurs.
*/
@Override
public void onSocketError() {
if (!isAdded()) {
return;
}
loadConversations();
if (activeConversationId != null) {
loadMessageHistory(activeConversationId);
}
if (!isAdded()) return;
requireActivity().runOnUiThread(() -> {
viewModel.loadConversations();
if (activeConversationId != null) viewModel.loadMessageHistory(activeConversationId);
});
}
// Helper function to convert DTO to message
private Message dtoToModel(MessageDTO dto) {
Message m = new Message();
m.setId(dto.getId());
m.setConversationId(dto.getConversationId());
m.setSenderId(dto.getSenderId());
m.setContent(dto.getContent());
m.setTimestamp(dto.getTimestamp());
m.setIsRead(dto.getIsRead());
return m;
}
//Helper function to scroll to bottom of the chat
/**
* Scrolls the message list to the most recent message.
*/
private void scrollToBottom() {
if (!messageList.isEmpty()) {
rvMessages.post(() ->
rvMessages.smoothScrollToPosition(messageList.size() - 1));
binding.rvMessages.post(() ->
binding.rvMessages.smoothScrollToPosition(messageList.size() - 1));
}
}
// Helper function to update the chat preview last message
private void updateConversationPreview(Long conversationId, String lastMessage) {
if (conversationId == null) {
return;
}
for (int i = 0; i < chatList.size(); i++) {
Chat existing = chatList.get(i);
if (existing.getChatId().equals(String.valueOf(conversationId))) {
Chat updated = new Chat(
existing.getChatId(),
existing.getCustomerName(),
lastMessage,
existing.getCustomerId(),
existing.getStaffId()
);
chatList.set(i, updated);
chatAdapter.notifyItemChanged(i);
return;
}
}
}
//Helper function to enable or disable the send button when there is no active chat
private void setConversationActive(boolean active) {
btnSend.setEnabled(active);
etMessage.setEnabled(active);
/**
* Updates the UI state based on whether a conversation is active and its status.
*/
private void setConversationActive(boolean active, String status) {
boolean isClosed = "CLOSED".equalsIgnoreCase(status);
UIUtils.setViewsEnabled(active && !isClosed, binding.btnSend, binding.etMessage, binding.btnAttach);
binding.btnCloseChat.setVisibility(active && !isClosed ? View.VISIBLE : View.GONE);
if (!active) {
activeConversationId = null;
if (stompChatManager != null) {
stompChatManager.clearConversationSubscription();
}
ChatNotificationService.activeConversationIdInUi = null;
removeAttachment();
if (binding != null && binding.tvChatTitle != null) binding.tvChatTitle.setText("Customer Chat");
if (stompChatManager != null) stompChatManager.clearConversationSubscription();
messageList.clear();
messageAdapter.notifyDataSetChanged();
etMessage.setText("");
etMessage.setHint("Select a chat to start messaging");
binding.etMessage.setText("");
binding.etMessage.setHint("Select a chat to start messaging");
} else {
etMessage.setHint("Type a message...");
binding.etMessage.setHint(isClosed ? "This chat is closed" : "Type a message...");
ChatNotificationService.activeConversationIdInUi = activeConversationId;
}
}
// When fragment is destroyed, disconnect from websocket
/**
* Cleans up resources and disconnects the WebSocket when the view is destroyed.
*/
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
ChatNotificationService.activeConversationIdInUi = null;
if (stompChatManager != null) stompChatManager.disconnect();
}
}

View File

@@ -1,78 +1,82 @@
/*
* Base fragment that provides common list and search functionality.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.fragments;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import com.example.petstoremobile.R;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.databinding.FragmentListBinding;
import com.example.petstoremobile.fragments.listfragments.PetFragment;
import com.example.petstoremobile.fragments.listfragments.ServiceFragment;
import com.example.petstoremobile.fragments.listfragments.SupplierFragment;
import com.example.petstoremobile.fragments.listfragments.AdoptionFragment;
import com.example.petstoremobile.fragments.listfragments.AppointmentFragment;
import com.example.petstoremobile.fragments.listfragments.InventoryFragment;
import com.example.petstoremobile.fragments.listfragments.ProductFragment;
import javax.inject.Inject;
//The Fragment for the displaying the list of entities to be viewed
import dagger.hilt.android.AndroidEntryPoint;
/**
* Fragment that serves as a container for various list-based screens, providing a navigation drawer.
*/
@AndroidEntryPoint
public class ListFragment extends Fragment {
private DrawerLayout drawerLayout;
private LinearLayout drawerPets, drawerServices, drawerSuppliers;
private View touchBlocker;
// Adoptions, Appointments, Inventory, Products
private LinearLayout drawerAdoptions, drawerAppointments, drawerInventory, drawerProducts;
private FragmentListBinding binding;
private NavController innerNavController;
@Inject TokenManager tokenManager;
/**
* Inflates the fragment layout, initializes navigation drawers, and applies role-based access control.
*/
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_list, container, false);
binding = FragmentListBinding.inflate(inflater, container, false);
//get controls from the layout
drawerLayout = view.findViewById(R.id.drawerLayout);
drawerPets = view.findViewById(R.id.drawerPets);
drawerServices = view.findViewById(R.id.drawerServices);
drawerSuppliers = view.findViewById(R.id.drawerSuppliers);
drawerAdoptions = view.findViewById(R.id.drawerAdoptions);
drawerAppointments = view.findViewById(R.id.drawerAppointments);
drawerInventory = view.findViewById(R.id.drawerInventory);
drawerProducts = view.findViewById(R.id.drawerProducts);
//needed to disable touches on the innerContainer while the drawer is open
touchBlocker = view.findViewById(R.id.touchBlocker);
//Display pets fragment by default
if (savedInstanceState == null) {
loadFragment(new PetFragment());
// Check user role and restrict access for STAFF
String role = tokenManager.getRole();
if ("STAFF".equalsIgnoreCase(role)) {
binding.sectionAdmin.setVisibility(View.GONE);
} else if ("ADMIN".equalsIgnoreCase(role)) {
binding.sectionAdmin.setVisibility(View.VISIBLE);
binding.drawerStaff.setVisibility(View.VISIBLE);
} else {
// Default or other roles
binding.sectionAdmin.setVisibility(View.GONE);
}
//add Listeners to the drawer so user won't be able to interact with the innerContainer (the list fragments)
//while the drawer is open
drawerLayout.addDrawerListener(new DrawerLayout.DrawerListener() {
binding.drawerLayout.addDrawerListener(new DrawerLayout.DrawerListener() {
//When the drawer is opened, disable touches on the background
@Override
public void onDrawerOpened(View drawerView) {
touchBlocker.setVisibility(View.VISIBLE);
touchBlocker.setClickable(true);
binding.touchBlocker.setVisibility(View.VISIBLE);
binding.touchBlocker.setClickable(true);
}
//When the drawer is closed, enable touches again
@Override
public void onDrawerClosed(View drawerView) {
touchBlocker.setVisibility(View.GONE);
touchBlocker.setClickable(false);
binding.touchBlocker.setVisibility(View.GONE);
binding.touchBlocker.setClickable(false);
}
//unused methods
@@ -83,63 +87,61 @@ public class ListFragment extends Fragment {
});
// Click listeners for each drawer
//Pets
drawerPets.setOnClickListener(v -> {
loadFragment(new PetFragment());
drawerLayout.closeDrawers();
});
binding.drawerPets.setOnClickListener(v -> navigateTo(R.id.nav_pet));
binding.drawerServices.setOnClickListener(v -> navigateTo(R.id.nav_service));
binding.drawerSuppliers.setOnClickListener(v -> navigateTo(R.id.nav_supplier));
binding.drawerAdoptions.setOnClickListener(v -> navigateTo(R.id.nav_adoption));
binding.drawerAppointments.setOnClickListener(v -> navigateTo(R.id.nav_appointment));
binding.drawerInventory.setOnClickListener(v -> navigateTo(R.id.nav_inventory));
binding.drawerProducts.setOnClickListener(v -> navigateTo(R.id.nav_product));
binding.drawerProductSupplier.setOnClickListener(v -> navigateTo(R.id.nav_product_supplier));
binding.drawerPurchaseOrderView.setOnClickListener(v -> navigateTo(R.id.nav_purchase_order));
binding.drawerSale.setOnClickListener(v -> navigateTo(R.id.nav_sale));
binding.drawerStaff.setOnClickListener(v -> navigateTo(R.id.nav_staff));
binding.drawerCustomers.setOnClickListener(v -> navigateTo(R.id.nav_customer));
binding.drawerAnalytics.setOnClickListener(v -> navigateTo(R.id.nav_analytics));
binding.drawerCoupons.setOnClickListener(v -> navigateTo(R.id.nav_coupon));
binding.drawerActivityLogs.setOnClickListener(v -> navigateTo(R.id.nav_activity_log));
//Services
drawerServices.setOnClickListener(v -> {
loadFragment(new ServiceFragment());
drawerLayout.closeDrawers();
});
//Suppliers
drawerSuppliers.setOnClickListener(v -> {
loadFragment(new SupplierFragment());
drawerLayout.closeDrawers();
});
//Adoptions
drawerAdoptions.setOnClickListener(v -> {
loadFragment(new AdoptionFragment());
drawerLayout.closeDrawers();
});
//Appoinment
drawerAppointments.setOnClickListener(v -> {
loadFragment(new AppointmentFragment());
drawerLayout.closeDrawers();
});
//Inventory
drawerInventory.setOnClickListener(v -> {
loadFragment(new InventoryFragment());
drawerLayout.closeDrawers();
});
//Products
drawerProducts.setOnClickListener(v -> {
loadFragment(new ProductFragment());
drawerLayout.closeDrawers();
});
return view;
return binding.getRoot();
}
//helper function to open the drawer
/**
* Cleans up the binding when the view is destroyed.
*/
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
/**
* Initializes the NavController for the internal fragment container.
*/
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
NavHostFragment navHostFragment = (NavHostFragment) getChildFragmentManager()
.findFragmentById(R.id.inner_nav_host_fragment);
if (navHostFragment != null) {
innerNavController = navHostFragment.getNavController();
}
}
/**
* Navigates to a specific inner destination and closes all drawers.
*/
private void navigateTo(int destinationId) {
if (innerNavController != null) {
innerNavController.navigate(destinationId);
}
binding.drawerLayout.closeDrawers();
}
/**
* Programmatically opens the navigation drawer.
*/
public void openDrawer() {
drawerLayout.openDrawer(GravityCompat.START);
binding.drawerLayout.openDrawer(GravityCompat.START);
}
// helper function to load the fragment into the display
public void loadFragment(Fragment fragment) {
getChildFragmentManager()
.beginTransaction()
.replace(R.id.inner_fragment_container, fragment)
.addToBackStack(null)
.commit();
}
}
}

View File

@@ -1,165 +1,116 @@
/*
* Fragment for viewing and editing the user's profile.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.fragments;
import android.Manifest;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import android.provider.MediaStore;
import android.text.InputType;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.example.petstoremobile.R;
import com.example.petstoremobile.activities.MainActivity;
import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.databinding.FragmentProfileBinding;
import com.example.petstoremobile.dtos.UserDTO;
import com.example.petstoremobile.services.ChatNotificationService;
import com.example.petstoremobile.utils.FileUtils;
import com.example.petstoremobile.utils.GlideUtils;
import com.example.petstoremobile.utils.ImagePickerHelper;
import com.example.petstoremobile.utils.InputValidator;
import com.example.petstoremobile.utils.Resource;
import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.AuthViewModel;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Named;
import dagger.hilt.android.AndroidEntryPoint;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
/**
* Fragment that displays and allows editing of the user's profile information.
*/
@AndroidEntryPoint
public class ProfileFragment extends Fragment {
//initialize the view/controls
private ImageView imgProfile;
private TextView tvProfileName, tvProfileEmail, tvProfilePhone, tvProfileRole;
private Button btnChangePhoto, btnEditEmail, btnEditPhone, btnLogout;
private Uri photoUri;
private FragmentProfileBinding binding;
private UserDTO currentUser;
private AuthViewModel viewModel;
private boolean hasImage = false;
//Initialize the launchers for camera and gallery
private ActivityResultLauncher<Intent> galleryLauncher;
private ActivityResultLauncher<Uri> cameraLauncher;
private ActivityResultLauncher<String> permissionLauncher;
@Inject TokenManager tokenManager;
@Inject @Named("baseUrl") String baseUrl;
//Called when the fragment is created, sets up the launchers is set profile image
private ImagePickerHelper imagePickerHelper;
/**
* Initializes activity launchers and the ImagePickerHelper for camera and gallary.
*/
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
viewModel = new ViewModelProvider(this).get(AuthViewModel.class);
// Launcher to open gallery to select profile image
galleryLauncher = registerForActivityResult(
//open gallery
new ActivityResultContracts.StartActivityForResult(),
result -> {
//if the user selects an image and its not null
if (result.getResultCode() == Activity.RESULT_OK
&& result.getData() != null) {
//get the selected image and set the image to the profile
Uri selectedImage = result.getData().getData();
imgProfile.setImageURI(selectedImage);
//TODO: SAVE CHANGED PHOTO TO DATABASE
}
}
);
imagePickerHelper = new ImagePickerHelper(this, "profile_photo.jpg", new ImagePickerHelper.ImagePickerListener() {
@Override
public void onImagePicked(Uri uri) {
uploadAvatar(uri);
}
// Launcher for camera to open and capture profile image
cameraLauncher = registerForActivityResult(
//open camera
new ActivityResultContracts.TakePicture(),
success -> {
//if a photo is taken set the image profile to it otherwise do nothing
if (success) {
//Clear the old image and set the new one
imgProfile.setImageURI(null);
imgProfile.setImageURI(photoUri);
//TODO: SAVE CHANGED PHOTO TO DATABASE
}
}
);
// Launcher to request camera permission
permissionLauncher = registerForActivityResult(
//ask user for camera permission
new ActivityResultContracts.RequestPermission(),
granted -> {
//if the permission is granted launch the camera
if (granted) {
launchCamera();
}
else {
//if the permission is denied then tell the user to grant it
new AlertDialog.Builder(requireContext())
.setTitle("Permission Permission Required")
.setMessage("Please grant camera permission to use this feature")
.setPositiveButton("Open Settings", (dialog, which) ->{
//open the settings page to grant the permission when they click open settings
Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", requireContext().getPackageName(), null));
startActivity(intent);
})
//close the dialog when the user clicks cancel
.setNegativeButton("Cancel", null)
.show();
}
}
);
@Override
public void onImageRemoved() {
deleteAvatar();
}
});
}
//TODO: MAKE PROFILE VIEW DISPLAY PROFILE DATA FROM DATABASE
/**
* Inflates the fragment layout and sets up listeners for profile.
*/
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_profile, container, false);
binding = FragmentProfileBinding.inflate(inflater, container, false);
//get all the controls from the view
imgProfile = view.findViewById(R.id.imgProfile);
tvProfileName = view.findViewById(R.id.tvProfileName);
tvProfileEmail = view.findViewById(R.id.tvProfileEmail);
tvProfilePhone = view.findViewById(R.id.tvProfilePhone);
tvProfileRole = view.findViewById(R.id.tvProfileRole);
btnChangePhoto = view.findViewById(R.id.btnChangePhoto);
btnEditEmail = view.findViewById(R.id.btnEditEmail);
btnEditPhone = view.findViewById(R.id.btnEditPhone);
btnLogout = view.findViewById(R.id.btnLogout);
//Load Profile Data from backend
loadProfileData();
//Set up listeners for the buttons
//Change photo button
btnChangePhoto.setOnClickListener(v -> {
//Show alert dialog to user to select from gallery or camera
new AlertDialog.Builder(requireContext())
.setTitle("Change Profile Photo")
//set the options for the alert dialog
.setItems(new String[]{"Take Photo", "Choose from Gallery"}, (dialog, which) -> {
if (which == 0) {
// Choose Camera
//Checks if the user has granted the camera permission already
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
//if the permission is already granted then launch the camera
launchCamera();
} else {
//otherwise request the permission
permissionLauncher.launch(Manifest.permission.CAMERA);
}
} else {
// Choose Gallery
Intent intent = new Intent(Intent.ACTION_PICK,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
galleryLauncher.launch(intent);
}
})
.show();
//TODO: UPDATE PHOTO IN DATABASE
binding.btnChangePhoto.setOnClickListener(v -> {
imagePickerHelper.showImagePickerDialog("Change Profile Photo", hasImage);
});
//Edit email button
//When clicked open a dialog to change email
btnEditEmail.setOnClickListener(v -> {
binding.btnEditEmail.setOnClickListener(v -> {
//Make a text field for the user to enter the new email
EditText input = new EditText(requireContext());
input.setPadding(30,30,30,30);
input.setText(tvProfileEmail.getText().toString());
input.setText(binding.tvProfileEmail.getText().toString());
//set input type to email
input.setInputType(android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
@@ -170,19 +121,10 @@ public class ProfileFragment extends Fragment {
.setTitle("Edit Email")
.setView(input)
.setPositiveButton("Save", (dialog, which) -> {
String newEmail = input.getText().toString();
//if the new value is a valid email then set the email to the new value
if (android.util.Patterns.EMAIL_ADDRESS.matcher(newEmail).matches()) {
tvProfileEmail.setText(newEmail);
//TODO: UPDATE THE EMAIL IN DATABASE
}
else {
//tell the user to email is invalid
new AlertDialog.Builder(requireContext())
.setTitle("Error")
.setMessage("Email is invalid")
.setPositiveButton("OK", null)
.show();
if (InputValidator.isValidEmail(input)) {
updateProfileField("email", input.getText().toString());
} else {
Toast.makeText(requireContext(), "Email is invalid", Toast.LENGTH_SHORT).show();
}
})
.setNegativeButton("Cancel", null)
@@ -191,18 +133,17 @@ public class ProfileFragment extends Fragment {
//Edit phone button
//When clicked open a dialog to change phone
btnEditPhone.setOnClickListener(v -> {
binding.btnEditPhone.setOnClickListener(v -> {
//Make a text field for the user to enter the new email
EditText input = new EditText(requireContext());
input.setPadding(30,30,30,30);
input.setText(tvProfilePhone.getText().toString());
input.setText(binding.tvProfilePhone.getText().toString());
//set input type to phone number
input.setInputType(InputType.TYPE_CLASS_PHONE);
input.setInputType(android.view.inputmethod.EditorInfo.TYPE_CLASS_PHONE);
//add canada phone number formatting to input (XXX) XXX-XXXX
input.addTextChangedListener(new android.telephony.PhoneNumberFormattingTextWatcher("CA"));
input.setFilters(new android.text.InputFilter[]{new android.text.InputFilter.LengthFilter(14)});
//add canada phone number formatting to input
UIUtils.formatPhoneInput(input);
//Show alert dialog to user to enter new phone
@@ -210,19 +151,10 @@ public class ProfileFragment extends Fragment {
.setTitle("Edit Phone Number")
.setView(input)
.setPositiveButton("Save", (dialog, which) -> {
String newPhone = input.getText().toString();
//if the new value is format: (XXX) XXX-XXXX then set the phone to the new value
if (newPhone.matches("\\(\\d{3}\\) \\d{3}-\\d{4}")) { //TODO MAKE VALIDATION CLASS INSTEAD FOR THIS
tvProfilePhone.setText(newPhone);
//TODO: UPDATE PHONE IN DATABASE
}
else {
//tell the user to email cannot be empty
new AlertDialog.Builder(requireContext())
.setTitle("Error")
.setMessage("Phone number is invalid. Format: (XXX) XXX-XXXX")
.setPositiveButton("OK", null)
.show();
if (InputValidator.isValidPhone(input)) {
updateProfileField("phone", input.getText().toString());
} else {
Toast.makeText(requireContext(), "Phone number is invalid", Toast.LENGTH_SHORT).show();
}
})
.setNegativeButton("Cancel", null)
@@ -230,26 +162,143 @@ public class ProfileFragment extends Fragment {
});
//Logout button
btnLogout.setOnClickListener(v -> {
TokenManager.getInstance(requireContext()).clearLoginData(); // clear the token for next login
binding.btnLogout.setOnClickListener(v -> {
// Stop notification service before logging out so notifications stop
android.content.Intent serviceIntent = new android.content.Intent(requireContext(), ChatNotificationService.class);
requireContext().stopService(serviceIntent);
// clear the token for next login
tokenManager.clearLoginData();
//get the intent to the main activity and clear the back stack so the back button won't allow the user to go back to the previous screen
Intent intent = new Intent(getActivity(), MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
android.content.Intent intent = new android.content.Intent(getActivity(), MainActivity.class);
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP | android.content.Intent.FLAG_ACTIVITY_NEW_TASK);
//start the activity to go to login page and finish the current activity
startActivity(intent);
requireActivity().finish();
});
return view;
return binding.getRoot();
}
//Helper function create a file in the cache directory to store the photo in then launch the camera to capture the photo
private void launchCamera() {
//create a file in the cache directory to store the photo in
File photoFile = new File(requireContext().getCacheDir(), "profile_photo.jpg");
//get the uri for the file made
photoUri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".fileprovider", photoFile);
//launch the camera to capture the photo and save the photo to photoUri
cameraLauncher.launch(photoUri);
/**
* Toggles the visibility of the progress bar.
*/
private void setLoading(boolean loading) {
if (binding != null && binding.progressBar != null) {
binding.progressBar.setVisibility(loading ? View.VISIBLE : View.GONE);
}
}
}
/**
* Cleans up the binding when the view is destroyed.
*/
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
/**
* Fetches current user profile data from the API and then updates the UI.
*/
private void loadProfileData() {
viewModel.getMe().observe(getViewLifecycleOwner(), resource -> {
if (resource == null) return;
setLoading(resource.status == Resource.Status.LOADING);
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
currentUser = resource.data;
//set the user data to the view
binding.tvProfileName.setText(currentUser.getFullName());
binding.tvProfileEmail.setText(currentUser.getEmail());
binding.tvProfilePhone.setText(currentUser.getPhone());
binding.tvProfileRole.setText(currentUser.getRole());
// get the avatar endpoint to load profile image and the token for authorization
String avatarUrl = baseUrl + AuthApi.AVATAR_FILE_PATH;
String token = tokenManager.getToken();
GlideUtils.loadImageWithToken(requireContext(), binding.imgProfile, avatarUrl, token, R.drawable.placeholder, new GlideUtils.ImageLoadListener() {
@Override
public void onResourceReady() {
hasImage = true;
}
@Override
public void onLoadFailed() {
hasImage = false;
}
});
} else if (resource.status == Resource.Status.ERROR) {
Toast.makeText(getContext(), "Failed to load profile: " + resource.message, Toast.LENGTH_SHORT).show();
}
});
}
/**
* Uploads the selected or captured image as the user's new avatar.
*/
private void uploadAvatar(Uri uri) {
try {
File file = FileUtils.getFileFromUri(requireContext(), uri);
if (file == null) return;
// Create RequestBody for file upload
RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri)));
MultipartBody.Part body = MultipartBody.Part.createFormData("avatar", file.getName(), requestFile);
//Call the backend to upload the avatar
viewModel.uploadAvatar(body).observe(getViewLifecycleOwner(), resource -> {
if (resource == null) return;
setLoading(resource.status == Resource.Status.LOADING);
if (resource.status == Resource.Status.SUCCESS) {
Toast.makeText(getContext(), "Avatar updated successfully", Toast.LENGTH_SHORT).show();
loadProfileData();
} else if (resource.status == Resource.Status.ERROR) {
Toast.makeText(getContext(), "Upload failed: " + resource.message, Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
Log.e("UPLOAD_AVATAR", "Error: " + e.getMessage());
}
}
/**
* Sends a request to the API to delete the current user's avatar image.
*/
private void deleteAvatar() {
viewModel.deleteAvatar().observe(getViewLifecycleOwner(), resource -> {
if (resource == null) return;
setLoading(resource.status == Resource.Status.LOADING);
if (resource.status == Resource.Status.SUCCESS) {
hasImage = false;
binding.imgProfile.setImageResource(R.drawable.placeholder);
Toast.makeText(getContext(), "Avatar removed successfully", Toast.LENGTH_SHORT).show();
} else if (resource.status == Resource.Status.ERROR) {
Toast.makeText(getContext(), "Removal failed: " + resource.message, Toast.LENGTH_SHORT).show();
}
});
}
/**
* Updates a specific profile field (like email or phone) by sending a request to the API.
*/
private void updateProfileField(String fieldName, String value) {
Map<String, String> updates = new HashMap<>();
updates.put(fieldName, value);
viewModel.updateMe(updates).observe(getViewLifecycleOwner(), resource -> {
if (resource == null) return;
setLoading(resource.status == Resource.Status.LOADING);
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
currentUser = resource.data;
Toast.makeText(getContext(), "Profile updated successfully", Toast.LENGTH_SHORT).show();
// Update the view with the new data from backend
binding.tvProfileEmail.setText(currentUser.getEmail());
binding.tvProfilePhone.setText(currentUser.getPhone());
} else if (resource.status == Resource.Status.ERROR) {
Toast.makeText(getContext(), "Update failed: " + resource.message, Toast.LENGTH_SHORT).show();
}
});
}
}

View File

@@ -0,0 +1,206 @@
/*
* Fragment for browsing the activity log.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.fragments.listfragments;
import android.app.DatePickerDialog;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.widget.Toast;
import androidx.navigation.fragment.NavHostFragment;
import com.example.petstoremobile.adapters.ActivityLogAdapter;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.databinding.FragmentActivityLogBinding;
import com.example.petstoremobile.dtos.ActivityLogDTO;
import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.utils.SpinnerUtils;
import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.ActivityLogListViewModel;
import javax.inject.Inject;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import dagger.hilt.android.AndroidEntryPoint;
/**
* Fragment for viewing application activity logs with various filtering options.
*/
@AndroidEntryPoint
public class ActivityLogFragment extends Fragment {
private FragmentActivityLogBinding binding;
private ActivityLogListViewModel viewModel;
private ActivityLogAdapter adapter;
private final List<ActivityLogDTO> logList = new ArrayList<>();
private List<DropdownDTO> storeList = new ArrayList<>();
private String selectedStartDate = null;
private String selectedEndDate = null;
@Inject TokenManager tokenManager;
/**
* Inflates the layout, checks for admin access, and initializes ViewModel and UI components.
*/
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
binding = FragmentActivityLogBinding.inflate(inflater, container, false);
if (!"ADMIN".equalsIgnoreCase(tokenManager.getRole())) {
Toast.makeText(requireContext(), "Access denied", Toast.LENGTH_SHORT).show();
NavHostFragment.findNavController(this).popBackStack();
return binding.getRoot();
}
viewModel = new ViewModelProvider(this).get(ActivityLogListViewModel.class);
setupRecyclerView();
setupFilters();
observeViewModel();
binding.swipeRefreshActivityLog.setOnRefreshListener(() -> viewModel.loadInitialData());
UIUtils.setupHamburgerMenu(binding.btnHamburgerActivityLog, this);
return binding.getRoot();
}
/**
* Triggers initial data loading after the view is created.
*/
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
viewModel.loadInitialData();
}
/**
* Configures the RecyclerView and its scroll listener for pagination.
*/
private void setupRecyclerView() {
adapter = new ActivityLogAdapter(logList);
binding.recyclerViewActivityLog.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewActivityLog.setAdapter(adapter);
binding.recyclerViewActivityLog.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewActivityLog.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
viewModel.loadLogs(false);
}
}
});
}
/**
* Sets up filters for logs, including search, role, store, and date range.
*/
private void setupFilters() {
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter,
binding.etSearchLog, binding.spinnerRoleFilter, binding.spinnerStoreFilter);
UIUtils.attachSearch(binding.etSearchLog, () ->
viewModel.setSearchQuery(binding.etSearchLog.getText().toString()));
String[] roles = {"All Roles", "Admin", "Staff", "Customer"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerRoleFilter, roles, () ->
viewModel.setRoleFilter(binding.spinnerRoleFilter.getSelectedItem() != null
? binding.spinnerRoleFilter.getSelectedItem().toString() : "All Roles"));
SpinnerUtils.setupFilterSpinner(binding.spinnerStoreFilter, this::onStoreSelected);
binding.btnStartDate.setOnClickListener(v -> showDatePicker(true));
binding.btnEndDate.setOnClickListener(v -> showDatePicker(false));
binding.btnClearDates.setOnClickListener(v -> {
selectedStartDate = null;
selectedEndDate = null;
binding.btnStartDate.setText("Start Date");
binding.btnEndDate.setText("End Date");
binding.btnClearDates.setVisibility(View.GONE);
viewModel.setDateRange(null, null);
});
}
/**
* Displays a date picker dialog and updates the selected start or end date.
*/
private void showDatePicker(boolean isStart) {
Calendar cal = Calendar.getInstance();
new DatePickerDialog(requireContext(), (view, year, month, day) -> {
String date = String.format("%04d-%02d-%02d", year, month + 1, day);
String label = String.format("%02d/%02d/%04d", day, month + 1, year);
if (isStart) {
selectedStartDate = date;
binding.btnStartDate.setText(label);
} else {
selectedEndDate = date;
binding.btnEndDate.setText(label);
}
binding.btnClearDates.setVisibility(
selectedStartDate != null || selectedEndDate != null ? View.VISIBLE : View.GONE);
viewModel.setDateRange(selectedStartDate, selectedEndDate);
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show();
}
/**
* Handles store selection from the filter spinner.
*/
private void onStoreSelected() {
int pos = binding.spinnerStoreFilter.getSelectedItemPosition();
Long storeId = (pos > 0 && !storeList.isEmpty()) ? storeList.get(pos - 1).getId() : null;
viewModel.setStoreFilter(storeId);
}
/**
* Observes the ViewModel for log list updates, store options, and loading status.
*/
private void observeViewModel() {
viewModel.getLogs().observe(getViewLifecycleOwner(), list -> {
logList.clear();
logList.addAll(list);
adapter.notifyDataSetChanged();
});
viewModel.getStoreOptions().observe(getViewLifecycleOwner(), stores -> {
storeList = stores;
SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStoreFilter,
stores, DropdownDTO::getLabel, "All Stores", -1L, DropdownDTO::getId);
});
viewModel.getIsLoading().observe(getViewLifecycleOwner(), loading ->
binding.swipeRefreshActivityLog.setRefreshing(loading));
}
/**
* Cleans up the binding reference.
*/
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
}

View File

@@ -1,163 +1,347 @@
/*
* Fragment for browsing adoption records.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.fragments.listfragments;
// Added search/filter bar to filter adoptions by adopter name or pet name.
// Added pull-to-refresh using SwipeRefreshLayout.
import android.graphics.Color;
import android.os.Bundle;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.AdoptionAdapter;
import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.listfragments.detailfragments.AdoptionDetailFragment;
import com.example.petstoremobile.models.Adoption;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import java.util.ArrayList;
import java.util.List;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.databinding.FragmentAdoptionBinding;
import com.example.petstoremobile.dtos.AdoptionDTO;
import com.example.petstoremobile.dtos.StoreDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.Resource;
import com.example.petstoremobile.utils.SpinnerUtils;
import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.AdoptionListViewModel;
import com.example.petstoremobile.utils.EventDecorator;
import com.prolificinteractive.materialcalendarview.CalendarDay;
import com.prolificinteractive.materialcalendarview.CalendarMode;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import javax.inject.Inject;
import dagger.hilt.android.AndroidEntryPoint;
/**
* Fragment for displaying and managing a list of adoptions with a calendar view and filtering.
*/
@AndroidEntryPoint
public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdoptionClickListener {
private List<Adoption> adoptionList = new ArrayList<>();
private List<Adoption> filteredList = new ArrayList<>();
private FragmentAdoptionBinding binding;
private List<AdoptionDTO> adoptionList = new ArrayList<>();
private AdoptionAdapter adapter;
private SwipeRefreshLayout swipeRefreshLayout;
private EditText etSearch;
private ImageButton hamburger;
private AdoptionListViewModel viewModel;
private BulkDeleteHandler bulkDeleteHandler;
private CalendarDay selectedCalendarDay;
private boolean isMonthMode = false;
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
@Inject TokenManager tokenManager;
/**
* Initializes the ViewModel.
*/
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
viewModel = new ViewModelProvider(this).get(AdoptionListViewModel.class);
}
/**
* Inflates the layout and sets up UI components, calendar, and observers.
*/
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_adoption, container, false);
binding = FragmentAdoptionBinding.inflate(inflater, container, false);
hamburger = view.findViewById(R.id.btnHamburger);
setupRecyclerView();
setupSearch();
setupStatusFilter();
setupStoreFilter();
setupSwipeRefresh();
setupCalendar();
setupFilterToggle();
setupBulkDelete();
observeViewModel();
loadAdoptionData();
// Replace with actual API call when backend is ready
setupRecyclerView(view);
setupSearch(view);
setupSwipeRefresh(view);
binding.fabAddAdoption.setOnClickListener(v -> openDetail(-1));
FloatingActionButton fabAddAdoption = view.findViewById(R.id.fabAddAdoption);
fabAddAdoption.setOnClickListener(v -> openAdoptionDetails(-1));
UIUtils.setupHamburgerMenu(binding.btnHamburgerAdoption, this);
//Make the hamburger button open the drawer from listFragment
hamburger.setOnClickListener(v -> {
ListFragment listFragment = (ListFragment) getParentFragment();
//if list fragment is found then use its helper function to open the drawer
if (listFragment != null) {
listFragment.openDrawer();
}
});
binding.btnToggleCalendarModeAdoption.setOnClickListener(v -> toggleCalendarMode());
return view;
return binding.getRoot();
}
// Filters adoption list by adopter name or pet name
private void setupSearch(View view) {
etSearch = view.findViewById(R.id.etSearchAdoption);
etSearch.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {
filterAdoptions(s.toString());
}
@Override public void afterTextChanged(Editable s) {}
/**
* Observes the ViewModel for adoption list, stores, and loading status.
*/
private void observeViewModel() {
viewModel.getAdoptions().observe(getViewLifecycleOwner(), list -> {
adoptionList.clear();
adoptionList.addAll(list);
updateCalendarDecorators();
adapter.notifyDataSetChanged();
});
viewModel.getStores().observe(getViewLifecycleOwner(), list -> {
SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStoreAdoption, list,
StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId);
});
viewModel.getIsLoading().observe(getViewLifecycleOwner(), loading -> {
binding.swipeRefreshAdoption.setRefreshing(loading);
});
}
private void filterAdoptions(String query) {
filteredList.clear();
if (query.isEmpty()) {
filteredList.addAll(adoptionList);
/**
* Configures the bulk delete handler for multiple adoption record deletion.
*/
private void setupBulkDelete() {
bulkDeleteHandler = new BulkDeleteHandler(
this,
binding.layoutBulkDelete,
binding.tvSelectionCount,
binding.btnBulkDelete,
adapter,
"adoption",
viewModel::bulkDeleteAdoptions,
() -> loadAdoptions(true)
);
}
/**
* Reloads adoption data and stores when the fragment resumes.
*/
@Override
public void onResume() {
super.onResume();
loadAdoptions(true);
if (!isStaff()) viewModel.loadStores();
}
/**
* Toggles between month and week display modes for the calendar.
*/
private void toggleCalendarMode() {
isMonthMode = !isMonthMode;
binding.calendarViewAdoption.state().edit()
.setCalendarDisplayMode(isMonthMode ? CalendarMode.MONTHS : CalendarMode.WEEKS)
.commit();
}
/**
* Sets up the filter visibility toggle, considering user roles.
*/
private void setupFilterToggle() {
if (isStaff()) {
UIUtils.setupFilterToggle(binding.btnToggleFilterAdoption, binding.layoutFilterAdoption,
binding.etSearchAdoption, binding.spinnerStatusAdoption);
binding.spinnerStoreAdoption.setVisibility(View.GONE);
} else {
String lower = query.toLowerCase();
for (Adoption a : adoptionList) {
if (a.getAdopterName().toLowerCase().contains(lower)
|| a.getPetName().toLowerCase().contains(lower)
|| a.getStatus().toLowerCase().contains(lower)) {
filteredList.add(a);
UIUtils.setupFilterToggle(binding.btnToggleFilterAdoption, binding.layoutFilterAdoption,
binding.etSearchAdoption, binding.spinnerStatusAdoption, binding.spinnerStoreAdoption);
}
}
/**
* Checks if the currently logged-in user has the STAFF role.
*/
private boolean isStaff() {
return "STAFF".equalsIgnoreCase(tokenManager.getRole());
}
/**
* Configures the calendar view for date-based filtering.
*/
private void setupCalendar() {
binding.calendarViewAdoption.setOnDateChangedListener((widget, date, selected) -> {
if (selected) {
if (date.equals(selectedCalendarDay)) {
selectedCalendarDay = null;
binding.calendarViewAdoption.clearSelection();
} else {
selectedCalendarDay = date;
}
} else {
selectedCalendarDay = null;
}
loadAdoptions(true);
});
}
/**
* Updates calendar decorators to highlight dates with adoptions.
*/
private void updateCalendarDecorators() {
HashSet<CalendarDay> datesWithAdoptions = new HashSet<>();
for (AdoptionDTO adoption : adoptionList) {
try {
if (adoption.getAdoptionDate() != null) {
Date date = dateFormat.parse(adoption.getAdoptionDate());
if (date != null) {
Calendar cal = Calendar.getInstance();
cal.setTime(date);
datesWithAdoptions.add(CalendarDay.from(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)));
}
}
} catch (ParseException e) {
Log.e("AdoptionFragment", "Error parsing date: " + adoption.getAdoptionDate());
}
}
binding.calendarViewAdoption.removeDecorators();
binding.calendarViewAdoption.addDecorator(new EventDecorator(Color.RED, datesWithAdoptions));
}
/**
* Configures the RecyclerView and its scroll listener for pagination.
*/
private void setupRecyclerView() {
adapter = new AdoptionAdapter(adoptionList, this);
binding.recyclerViewAdoptions.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewAdoptions.setAdapter(adapter);
binding.recyclerViewAdoptions.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewAdoptions.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadAdoptions(false);
}
}
}
adapter.notifyDataSetChanged();
}
private void setupSwipeRefresh(View view) {
swipeRefreshLayout = view.findViewById(R.id.swipeRefreshAdoption);
swipeRefreshLayout.setOnRefreshListener(() -> {
loadAdoptionData(); // TODO: Replace with actual API call
filterAdoptions(etSearch.getText().toString());
swipeRefreshLayout.setRefreshing(false);
});
}
private void openAdoptionDetails(int position) {
AdoptionDetailFragment detailFragment = new AdoptionDetailFragment();
Bundle args = new Bundle();
args.putInt("position", position);
if (position != -1) {
Adoption adoption = filteredList.get(position);
int realPosition = adoptionList.indexOf(adoption);
args.putInt("position", realPosition);
args.putInt("adoptionId", adoption.getAdoptionId());
args.putString("adopterName", adoption.getAdopterName());
args.putString("adopterEmail", adoption.getAdopterEmail());
args.putString("adopterPhone", adoption.getAdopterPhone());
args.putString("petName", adoption.getPetName());
args.putString("adoptionDate", adoption.getAdoptionDate());
args.putString("status", adoption.getStatus());
}
detailFragment.setArguments(args);
detailFragment.setAdoptionFragment(this);
ListFragment listFragment = (ListFragment) getParentFragment();
if (listFragment != null) listFragment.loadFragment(detailFragment);
/**
* Sets up the search input listener.
*/
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchAdoption, () -> loadAdoptions(true));
}
public void onAdoptionSaved(int position, Adoption adoption) {
if (position == -1) {
adoptionList.add(adoption);
/**
* Configures the status filter spinner.
*/
private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Completed", "Pending", "Missed", "Cancelled"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusAdoption, statuses, () -> loadAdoptions(true));
}
/**
* Configures the store filter spinner.
*/
private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStoreAdoption, () -> loadAdoptions(true));
}
/**
* Configures the swipe-to-refresh layout.
*/
private void setupSwipeRefresh() {
binding.swipeRefreshAdoption.setOnRefreshListener(() -> loadAdoptions(true));
}
/**
* Loads adoption data based on current filters, search query, and selected date.
*/
private void loadAdoptions(boolean reset) {
String query = binding.etSearchAdoption.getText().toString().trim();
String status = binding.spinnerStatusAdoption.getSelectedItem() != null ? binding.spinnerStatusAdoption.getSelectedItem().toString() : "All Statuses";
Long storeId;
if (isStaff()) {
storeId = tokenManager.getPrimaryStoreId();
} else {
adoptionList.set(position, adoption);
storeId = null;
List<StoreDTO> stores = viewModel.getStores().getValue();
if (binding.spinnerStoreAdoption.getSelectedItemPosition() > 0 && stores != null && !stores.isEmpty()) {
storeId = stores.get(binding.spinnerStoreAdoption.getSelectedItemPosition() - 1).getStoreId();
}
}
filterAdoptions(etSearch.getText().toString());
String selectedDateString = null;
if (selectedCalendarDay != null) {
selectedDateString = String.format(Locale.getDefault(), "%04d-%02d-%02d",
selectedCalendarDay.getYear(), selectedCalendarDay.getMonth(), selectedCalendarDay.getDay());
}
if (status.equals("All Statuses")) status = null;
else status = status.toUpperCase();
viewModel.loadAdoptions(reset, query, status, storeId, selectedDateString, null);
}
public void onAdoptionDeleted(int position) {
adoptionList.remove(position);
filterAdoptions(etSearch.getText().toString());
/**
* Navigates to the adoption detail screen.
*/
private void openDetail(int position) {
Bundle args = new Bundle();
if (position != -1) {
AdoptionDTO a = adoptionList.get(position);
args.putLong("adoptionId", a.getAdoptionId());
}
NavHostFragment.findNavController(this).navigate(R.id.nav_adoption_detail, args);
}
/**
* Handles adoption item clicks by opening details.
*/
@Override
public void onAdoptionClick(int position) {
openAdoptionDetails(position);
public void onAdoptionClick(int position) { openDetail(position); }
/**
* Forwards selection changes to the bulk delete handler.
*/
@Override
public void onSelectionChanged(int selectedCount) {
if (bulkDeleteHandler != null) {
bulkDeleteHandler.onSelectionChanged(selectedCount);
}
}
private void loadAdoptionData() {
adoptionList.clear();
adoptionList.add(new Adoption(1, "Sarah Connor", "sarah@email.com", "555-1234", "Luna", "2026-03-01", "Approved"));
adoptionList.add(new Adoption(2, "Tom Hardy", "tom@email.com", "555-5678", "Bella", "2026-03-05", "Pending"));
adoptionList.add(new Adoption(3, "Emily Clark", "emily@email.com", "555-9012", "Charlie", "2026-03-07", "Pending"));
adoptionList.add(new Adoption(4, "Mike Ross", "mike@email.com", "555-3456", "Milo", "2026-02-20", "Rejected"));
filteredList.clear();
filteredList.addAll(adoptionList);
}
private void setupRecyclerView(View view) {
RecyclerView recyclerView = view.findViewById(R.id.recyclerViewAdoptions);
adapter = new AdoptionAdapter(filteredList, this);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
recyclerView.setAdapter(adapter);
/**
* Cleans up the binding reference.
*/
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
}

View File

@@ -0,0 +1,439 @@
/*
* Fragment for displaying store analytics and charts.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.fragments.listfragments;
import android.graphics.Color;
import android.os.Bundle;
import android.view.*;
import android.widget.*;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.databinding.FragmentAnalyticsBinding;
import com.example.petstoremobile.utils.SpinnerUtils;
import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.AnalyticsViewModel;
import dagger.hilt.android.AndroidEntryPoint;
import javax.inject.Inject;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
/**
* Fragment for displaying business analytics, including revenue, transactions, and product performance.
*/
@AndroidEntryPoint
public class AnalyticsFragment extends Fragment {
@Inject
TokenManager tokenManager;
private FragmentAnalyticsBinding binding;
private AnalyticsViewModel viewModel;
private boolean filtersExpanded = false;
private static final String[] TOP_N_OPTIONS = {"5", "10", "15", "20"};
private static final int[] TOP_N_VALUES = { 5, 10, 15, 20 };
/**
* Inflates the layout, initializes ViewModel, and sets up UI components and filters.
*/
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
binding = FragmentAnalyticsBinding.inflate(inflater, container, false);
viewModel = new ViewModelProvider(this).get(AnalyticsViewModel.class);
setupFilterPanel();
setupViewModeToggle();
observeViewModel();
viewModel.loadAnalytics();
binding.btnRefreshAnalytics.setOnClickListener(v -> viewModel.loadAnalytics());
UIUtils.setupHamburgerMenu(binding.btnHamburgerAnalytics, this);
return binding.getRoot();
}
private static final int COLOR_SELECTED = 0xFF4ECDC4;
private static final int COLOR_UNSELECTED = 0xFFCBD5E1;
/**
* Configures the view mode toggle buttons (My Analytics vs Store Analytics).
*/
private void setupViewModeToggle() {
updateViewModeButtonStyles(viewModel.getViewMode());
binding.btnMyAnalytics.setOnClickListener(v -> {
viewModel.setViewMode("mine");
updateViewModeButtonStyles("mine");
updateStoreFilterVisibility("mine");
});
binding.btnStoreAnalytics.setOnClickListener(v -> {
viewModel.setViewMode("store");
updateViewModeButtonStyles("store");
updateStoreFilterVisibility("store");
});
}
/**
* Updates the styles of the view mode buttons based on the selected mode.
*/
private void updateViewModeButtonStyles(String mode) {
binding.btnMyAnalytics.setBackgroundTintList(
android.content.res.ColorStateList.valueOf(mode.equals("mine") ? COLOR_SELECTED : COLOR_UNSELECTED));
binding.btnStoreAnalytics.setBackgroundTintList(
android.content.res.ColorStateList.valueOf(mode.equals("store") ? COLOR_SELECTED : COLOR_UNSELECTED));
}
/**
* Updates the visibility of the store filter based on the user's role and selected view mode.
*/
private void updateStoreFilterVisibility(String mode) {
boolean isAdmin = "ADMIN".equalsIgnoreCase(tokenManager.getRole());
int vis = (isAdmin && mode.equals("store")) ? View.VISIBLE : View.GONE;
binding.tvStoreFilterLabel.setVisibility(vis);
binding.spinnerFilterStore.setVisibility(vis);
}
/**
* Configures the filter panel, including date pickers, presets, and action buttons.
*/
private void setupFilterPanel() {
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerTopN, TOP_N_OPTIONS);
// Toggle expand/collapse
binding.rowFilterHeader.setOnClickListener(v -> toggleFilters());
// Date pickers
binding.etFilterStartDate.setOnClickListener(v ->
UIUtils.showDatePicker(requireContext(), binding.etFilterStartDate, this::updateFilterSummary));
binding.etFilterEndDate.setOnClickListener(v ->
UIUtils.showDatePicker(requireContext(), binding.etFilterEndDate, this::updateFilterSummary));
// Quick presets
binding.btnPresetToday.setOnClickListener(v -> applyPreset(0, 0));
binding.btnPreset7D.setOnClickListener(v -> applyPreset(-6, 0));
binding.btnPreset30D.setOnClickListener(v -> applyPreset(-29, 0));
binding.btnPreset3M.setOnClickListener(v -> applyPreset(-89, 0));
binding.btnPreset1Y.setOnClickListener(v -> applyPreset(-364, 0));
binding.btnPresetAll.setOnClickListener(v -> {
binding.etFilterStartDate.setText("");
binding.etFilterEndDate.setText("");
updateFilterSummary();
});
binding.btnFilterApply.setOnClickListener(v -> applyFiltersFromUI());
binding.btnFilterReset.setOnClickListener(v -> resetFilters());
}
/**
* Toggles the visibility of the filter content section.
*/
private void toggleFilters() {
filtersExpanded = !filtersExpanded;
binding.llFilterContent.setVisibility(filtersExpanded ? View.VISIBLE : View.GONE);
binding.tvFilterToggleIcon.setText(filtersExpanded ? "" : "");
}
/**
* Applies a date range preset to the filter fields.
*/
private void applyPreset(int startOffset, int endOffset) {
binding.etFilterStartDate.setText(getDateString(startOffset));
binding.etFilterEndDate.setText(getDateString(endOffset));
updateFilterSummary();
applyFiltersFromUI();
}
/**
* Reads filter values from the UI and applies them to the ViewModel.
*/
private void applyFiltersFromUI() {
AnalyticsViewModel.FilterState filter = new AnalyticsViewModel.FilterState();
filter.startDate = binding.etFilterStartDate.getText().toString().trim();
filter.endDate = binding.etFilterEndDate.getText().toString().trim();
Object pm = binding.spinnerFilterPayment.getSelectedItem();
filter.paymentMethod = pm != null ? pm.toString() : "All";
int topNPos = binding.spinnerTopN.getSelectedItemPosition();
filter.topN = (topNPos >= 0 && topNPos < TOP_N_VALUES.length) ? TOP_N_VALUES[topNPos] : 5;
Object store = binding.spinnerFilterStore.getSelectedItem();
viewModel.setStoreFilter(store != null ? store.toString() : "All Stores");
updateFilterSummary();
viewModel.applyFilter(filter);
}
/**
* Resets all filters to their default values.
*/
private void resetFilters() {
binding.etFilterStartDate.setText("");
binding.etFilterEndDate.setText("");
binding.spinnerTopN.setSelection(0);
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, "All");
SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, "All Stores");
updateFilterSummary();
viewModel.resetFilter();
}
/**
* Updates the text summary of the currently selected date range.
*/
private void updateFilterSummary() {
String start = binding.etFilterStartDate.getText().toString().trim();
String end = binding.etFilterEndDate.getText().toString().trim();
if (start.isEmpty() && end.isEmpty()) {
binding.tvFilterSummary.setText("All time");
} else if (start.isEmpty()) {
binding.tvFilterSummary.setText("Up to " + shortDate(end));
} else if (end.isEmpty()) {
binding.tvFilterSummary.setText("From " + shortDate(start));
} else {
binding.tvFilterSummary.setText(shortDate(start) + " " + shortDate(end));
}
}
/**
* Formats a date string into a shorter version for display.
*/
private String shortDate(String date) {
return (date != null && date.length() >= 10) ? date.substring(5) : date;
}
/**
* Returns a formatted date string for a given day.
*/
private String getDateString(int offsetDays) {
Calendar c = Calendar.getInstance();
c.add(Calendar.DAY_OF_YEAR, offsetDays);
return String.format(Locale.US, "%04d-%02d-%02d",
c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1, c.get(Calendar.DAY_OF_MONTH));
}
/**
* Observes the ViewModel for analytics data, loading status, errors, and filter options.
*/
private void observeViewModel() {
viewModel.getAnalyticsData().observe(getViewLifecycleOwner(), this::computeAndDisplay);
viewModel.getIsLoading().observe(getViewLifecycleOwner(), loading -> {
binding.progressBar.setVisibility(loading ? View.VISIBLE : View.GONE);
if (loading) {
binding.tvTotalRevenue.setText("Loading...");
binding.tvTotalTransactions.setText("...");
binding.tvAvgTransaction.setText("...");
binding.tvTotalItems.setText("...");
}
});
viewModel.getErrorMessage().observe(getViewLifecycleOwner(), error -> {
if (error != null) showError(error);
});
viewModel.getAvailablePaymentMethods().observe(getViewLifecycleOwner(), methods -> {
if (methods == null || methods.isEmpty()) return;
String currentSelection = binding.spinnerFilterPayment.getSelectedItem() != null
? binding.spinnerFilterPayment.getSelectedItem().toString() : "All";
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerFilterPayment,
methods.toArray(new String[0]));
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, currentSelection);
});
viewModel.getAvailableStores().observe(getViewLifecycleOwner(), stores -> {
if (stores == null || stores.isEmpty()) return;
String currentSelection = binding.spinnerFilterStore.getSelectedItem() != null
? binding.spinnerFilterStore.getSelectedItem().toString() : "All Stores";
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerFilterStore,
stores.toArray(new String[0]));
SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, currentSelection);
updateStoreFilterVisibility(viewModel.getViewMode());
});
}
/**
* Cleans up the binding reference.
*/
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
/**
* Computes and displays analytics data in summary cards and bar charts.
*/
private void computeAndDisplay(AnalyticsViewModel.AnalyticsData data) {
if (data == null) return;
// Summary cards
binding.tvTotalRevenue.setText("$" + data.totalRevenue.setScale(2, RoundingMode.HALF_UP));
binding.tvTotalTransactions.setText(String.valueOf(data.totalTransactions));
binding.tvAvgTransaction.setText("$" + data.avgTransaction);
binding.tvTotalItems.setText(String.valueOf(data.totalItems));
// Top Revenue Products
binding.llTopRevenue.removeAllViews();
if (data.topRevenueProducts != null && !data.topRevenueProducts.isEmpty()) {
BigDecimal maxRev = data.topRevenueProducts.get(0).getValue();
if (maxRev.compareTo(BigDecimal.ZERO) == 0) maxRev = BigDecimal.ONE;
for (Map.Entry<String, BigDecimal> e : data.topRevenueProducts) {
addBarRow(binding.llTopRevenue, e.getKey(),
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
e.getValue().floatValue() / maxRev.floatValue(), "#ff6b35");
}
} else {
addEmptyRow(binding.llTopRevenue, "No data");
}
// Top Quantity Products
binding.llTopQuantity.removeAllViews();
if (data.topQuantityProducts != null && !data.topQuantityProducts.isEmpty()) {
int maxQty = data.topQuantityProducts.get(0).getValue();
if (maxQty == 0) maxQty = 1;
for (Map.Entry<String, Integer> e : data.topQuantityProducts) {
addBarRow(binding.llTopQuantity, e.getKey(), e.getValue() + " units",
(float) e.getValue() / maxQty, "#4ecdc4");
}
} else {
addEmptyRow(binding.llTopQuantity, "No data");
}
// Payment Methods
binding.llPaymentMethods.removeAllViews();
if (data.paymentMethodStats != null && !data.paymentMethodStats.isEmpty()) {
int maxPay = data.paymentMethodStats.stream().mapToInt(Map.Entry::getValue).max().orElse(1);
String[] payColors = { "#1a759f", "#ff9f1c", "#577590", "#90be6d" };
int ci = 0;
for (Map.Entry<String, Integer> e : data.paymentMethodStats) {
addBarRow(binding.llPaymentMethods, e.getKey(),
e.getValue() + " transactions",
(float) e.getValue() / maxPay, payColors[ci++ % payColors.length]);
}
} else {
addEmptyRow(binding.llPaymentMethods, "No data");
}
// Employee Performance
boolean showEmployeeSection = viewModel.getViewMode().equals("store");
View empParent = (View) binding.llEmployeePerformance.getParent();
if (empParent != null) empParent.setVisibility(showEmployeeSection ? View.VISIBLE : View.GONE);
if (showEmployeeSection) {
binding.llEmployeePerformance.removeAllViews();
if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) {
BigDecimal maxEmp = data.employeePerformance.get(0).getValue();
if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE;
for (Map.Entry<String, BigDecimal> e : data.employeePerformance) {
addBarRow(binding.llEmployeePerformance, e.getKey(),
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f");
}
} else {
addEmptyRow(binding.llEmployeePerformance, "No data");
}
}
// Daily Revenue
binding.tvDailyRevenueTitle.setText(data.dailyRevenueTitle);
binding.llDailyRevenue.removeAllViews();
if (data.dailyRevenue != null && !data.dailyRevenue.isEmpty()) {
BigDecimal maxDaily = data.dailyRevenue.stream()
.map(Map.Entry::getValue).max(BigDecimal::compareTo).orElse(BigDecimal.ONE);
if (maxDaily.compareTo(BigDecimal.ZERO) == 0) maxDaily = BigDecimal.ONE;
for (Map.Entry<String, BigDecimal> e : data.dailyRevenue) {
String label = e.getKey().length() >= 10 ? e.getKey().substring(5) : e.getKey();
addBarRow(binding.llDailyRevenue, label,
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
e.getValue().floatValue() / maxDaily.floatValue(), "#ff6b35");
}
} else {
addEmptyRow(binding.llDailyRevenue, "No data");
}
}
/**
* Dynamically adds a bar chart row to a given layout container.
*/
private void addBarRow(LinearLayout parent, String label, String value, float ratio, String color) {
if (getContext() == null) return;
LinearLayout row = new LinearLayout(getContext());
row.setOrientation(LinearLayout.VERTICAL);
row.setPadding(0, 6, 0, 6);
LinearLayout labelRow = new LinearLayout(getContext());
labelRow.setOrientation(LinearLayout.HORIZONTAL);
TextView tvLabel = new TextView(getContext());
tvLabel.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
tvLabel.setText(label);
tvLabel.setTextColor(Color.parseColor("#444441"));
tvLabel.setTextSize(13f);
TextView tvValue = new TextView(getContext());
tvValue.setText(value);
tvValue.setTextColor(Color.parseColor("#444441"));
tvValue.setTextSize(13f);
tvValue.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_END);
labelRow.addView(tvLabel);
labelRow.addView(tvValue);
LinearLayout barBg = new LinearLayout(getContext());
LinearLayout.LayoutParams bgParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 12);
bgParams.setMargins(0, 4, 0, 0);
barBg.setLayoutParams(bgParams);
barBg.setBackgroundColor(Color.parseColor("#EEEEEE"));
float safeRatio = Math.max(0f, Math.min(1f, ratio));
View barFill = new View(getContext());
barFill.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, safeRatio));
barFill.setBackgroundColor(Color.parseColor(color));
barBg.addView(barFill);
View spacer = new View(getContext());
spacer.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f - safeRatio));
barBg.addView(spacer);
row.addView(labelRow);
row.addView(barBg);
parent.addView(row);
}
/**
* Adds an empty message row to a given layout container.
*/
private void addEmptyRow(LinearLayout parent, String message) {
if (getContext() == null) return;
TextView tv = new TextView(getContext());
tv.setText(message);
tv.setTextColor(Color.parseColor("#888780"));
tv.setTextSize(13f);
parent.addView(tv);
}
/**
* Displays an error message and updates UI to reflect the error state.
*/
private void showError(String msg) {
if (getContext() == null || binding == null) return;
binding.tvTotalRevenue.setText("Error");
binding.tvTotalTransactions.setText("");
binding.tvAvgTransaction.setText("");
binding.tvTotalItems.setText("");
Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show();
}
}

View File

@@ -1,167 +1,380 @@
/*
* Fragment for browsing appointments.
*
* Author: Alex, Nikitha
* Date: April 2026
*/
package com.example.petstoremobile.fragments.listfragments;
// Added search/filter bar to filter appointments by customer name or service type.
// Added pull-to-refresh using SwipeRefreshLayout.
import android.graphics.Color;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.ImageButton;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.AppointmentAdapter;
import com.example.petstoremobile.fragments.ListFragment;
import com.example.petstoremobile.fragments.listfragments.detailfragments.AppointmentDetailFragment;
import com.example.petstoremobile.models.Appointment;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import java.util.ArrayList;
import java.util.List;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.databinding.FragmentAppointmentBinding;
import com.example.petstoremobile.dtos.AppointmentDTO;
import com.example.petstoremobile.dtos.StoreDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.Resource;
import com.example.petstoremobile.utils.SpinnerUtils;
import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.AppointmentListViewModel;
import com.example.petstoremobile.utils.EventDecorator;
import com.example.petstoremobile.viewmodels.AuthViewModel;
import com.prolificinteractive.materialcalendarview.CalendarDay;
import com.prolificinteractive.materialcalendarview.CalendarMode;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import javax.inject.Inject;
import dagger.hilt.android.AndroidEntryPoint;
/**
* Fragment for displaying and managing a list of appointments with calendar integration and filtering.
*/
@AndroidEntryPoint
public class AppointmentFragment extends Fragment implements AppointmentAdapter.OnAppointmentClickListener {
private List<Appointment> appointmentList = new ArrayList<>(); // full data list
private List<Appointment> filteredList = new ArrayList<>(); // filtered display list
private FragmentAppointmentBinding binding;
private List<AppointmentDTO> appointmentList = new ArrayList<>();
private AppointmentAdapter adapter;
private SwipeRefreshLayout swipeRefreshLayout;
private EditText etSearch;
private ImageButton hamburger;
private AppointmentListViewModel viewModel;
private AuthViewModel authViewModel;
private BulkDeleteHandler bulkDeleteHandler;
@Inject TokenManager tokenManager;
private CalendarDay selectedCalendarDay;
private boolean isMonthMode = false;
private Long currentUserId = null;
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
/**
* Initializes ViewModels for appointment and authentication data.
*/
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
viewModel = new ViewModelProvider(this).get(AppointmentListViewModel.class);
authViewModel = new ViewModelProvider(this).get(AuthViewModel.class);
}
/**
* Inflates the layout and sets up UI components, calendar, and observers.
*/
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_appointment, container, false);
binding = FragmentAppointmentBinding.inflate(inflater, container, false);
hamburger = view.findViewById(R.id.btnHamburger);
setupRecyclerView();
setupSearch();
setupStatusFilter();
setupStoreFilter();
setupSwipeRefresh();
setupCalendar();
setupFilterToggle();
setupMyAppointmentFilter();
setupBulkDelete();
observeViewModel();
loadAppointmentData(); // TODO: Replace with actual API call when backend is ready
setupRecyclerView(view);
setupSearch(view);
setupSwipeRefresh(view);
binding.fabAddAppointment.setOnClickListener(v -> openAppointmentDetails(-1));
FloatingActionButton fabAddAppointment = view.findViewById(R.id.fabAddAppointment);
fabAddAppointment.setOnClickListener(v -> openAppointmentDetails(-1));
UIUtils.setupHamburgerMenu(binding.btnHamburger, this);
//Make the hamburger button open the drawer from listFragment
hamburger.setOnClickListener(v -> {
ListFragment listFragment = (ListFragment) getParentFragment();
//if list fragment is found then use its helper function to open the drawer
if (listFragment != null) {
listFragment.openDrawer();
}
});
binding.btnToggleCalendarMode.setOnClickListener(v -> toggleCalendarMode());
return view;
loadCurrentUserInfo();
return binding.getRoot();
}
// Sets up the search bar to filter appointments by customer name or service type
private void setupSearch(View view) {
etSearch = view.findViewById(R.id.etSearchAppointment);
etSearch.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {
filterAppointments(s.toString());
}
@Override public void afterTextChanged(Editable s) {}
/**
* Observes the ViewModel for appointment list, stores, and loading status.
*/
private void observeViewModel() {
viewModel.getAppointments().observe(getViewLifecycleOwner(), list -> {
appointmentList.clear();
appointmentList.addAll(list);
updateCalendarDecorators();
adapter.notifyDataSetChanged();
});
viewModel.getStores().observe(getViewLifecycleOwner(), list -> {
SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerStore, list,
StoreDTO::getStoreName, "All Stores", -1L, StoreDTO::getStoreId);
});
viewModel.getIsLoading().observe(getViewLifecycleOwner(), loading -> {
binding.swipeRefreshAppointment.setRefreshing(loading);
});
}
// Filters the appointment list based on the search query
private void filterAppointments(String query) {
filteredList.clear();
if (query.isEmpty()) {
filteredList.addAll(appointmentList);
/**
* Configures the bulk delete handler for multiple appointment deletion.
*/
private void setupBulkDelete() {
bulkDeleteHandler = new BulkDeleteHandler(
this,
binding.layoutBulkDelete,
binding.tvSelectionCount,
binding.btnBulkDelete,
adapter,
"appointment",
viewModel::bulkDeleteAppointments,
() -> loadAppointmentData(true)
);
}
/**
* Reloads appointment data and stores when the fragment resumes.
*/
@Override
public void onResume() {
super.onResume();
loadAppointmentData(true);
if (!isStaff()) viewModel.loadStores();
}
/**
* Toggles between month and week display modes for the calendar.
*/
private void toggleCalendarMode() {
isMonthMode = !isMonthMode;
binding.calendarView.state().edit()
.setCalendarDisplayMode(isMonthMode ? CalendarMode.MONTHS : CalendarMode.WEEKS)
.commit();
}
/**
* Sets up the "My Appointments" filter button.
*/
private void setupMyAppointmentFilter() {
binding.btnMyAppointments.setOnClickListener(v -> {
loadAppointmentData(true);
});
}
/**
* Loads information about the currently logged-in user.
*/
private void loadCurrentUserInfo() {
authViewModel.getMe().observe(getViewLifecycleOwner(), resource -> {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
currentUserId = resource.data.getId();
}
});
}
/**
* Sets up the filter visibility toggle.
*/
private void setupFilterToggle() {
if (isStaff()) {
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter, binding.etSearchAppointment, binding.spinnerStatus);
binding.spinnerStore.setVisibility(View.GONE);
} else {
String lower = query.toLowerCase();
for (Appointment a : appointmentList) {
if (a.getCustomerName().toLowerCase().contains(lower)
|| a.getServiceType().toLowerCase().contains(lower)
|| a.getPetName().toLowerCase().contains(lower)) {
filteredList.add(a);
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter, binding.etSearchAppointment,
binding.spinnerStatus, binding.spinnerStore);
}
}
/**
* Configures the calendar view for date-based filtering.
*/
private void setupCalendar() {
binding.calendarView.setOnDateChangedListener((widget, date, selected) -> {
if (selected) {
if (date.equals(selectedCalendarDay)) {
selectedCalendarDay = null;
binding.calendarView.clearSelection();
} else {
selectedCalendarDay = date;
}
} else {
selectedCalendarDay = null;
}
}
adapter.notifyDataSetChanged();
}
// Sets up pull-to-refresh: reloads data when user swipes down
private void setupSwipeRefresh(View view) {
swipeRefreshLayout = view.findViewById(R.id.swipeRefreshAppointment);
swipeRefreshLayout.setOnRefreshListener(() -> {
loadAppointmentData(); // TODO: Replace with actual API call when backend is ready
filterAppointments(etSearch.getText().toString());
swipeRefreshLayout.setRefreshing(false);
loadAppointmentData(true);
});
}
/**
* Updates calendar decorators to highlight dates with appointments.
*/
private void updateCalendarDecorators() {
HashSet<CalendarDay> datesWithAppointments = new HashSet<>();
for (AppointmentDTO appointment : appointmentList) {
try {
Date date = dateFormat.parse(appointment.getAppointmentDate());
if (date != null) {
Calendar cal = Calendar.getInstance();
cal.setTime(date);
datesWithAppointments.add(CalendarDay.from(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)));
}
} catch (ParseException e) {
Log.e("AppointmentFragment", "Error parsing date: " + appointment.getAppointmentDate());
}
}
binding.calendarView.removeDecorators();
binding.calendarView.addDecorator(new EventDecorator(Color.RED, datesWithAppointments));
}
/**
* Sets up the search input listener.
*/
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchAppointment, () -> loadAppointmentData(true));
}
/**
* Configures the status filter spinner.
*/
private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Booked", "Completed", "Cancelled", "Missed"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, () -> loadAppointmentData(true));
}
/**
* Configures the store filter spinner.
*/
private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, () -> loadAppointmentData(true));
}
/**
* Configures the swipe-to-refresh layout.
*/
private void setupSwipeRefresh() {
binding.swipeRefreshAppointment.setOnRefreshListener(() -> loadAppointmentData(true));
}
/**
* Navigates to the appointment detail screen.
*/
private void openAppointmentDetails(int position) {
AppointmentDetailFragment detailFragment = new AppointmentDetailFragment();
Bundle args = new Bundle();
args.putInt("position", position);
if (position != -1) {
Appointment appointment = filteredList.get(position);
// Find the real position in the full list for save/delete callbacks
int realPosition = appointmentList.indexOf(appointment);
args.putInt("position", realPosition);
args.putInt("appointmentId", appointment.getAppointmentId());
args.putString("customerName", appointment.getCustomerName());
args.putString("petName", appointment.getPetName());
args.putString("serviceType", appointment.getServiceType());
args.putString("appointmentDate", appointment.getAppointmentDate());
args.putString("appointmentTime", appointment.getAppointmentTime());
args.putString("status", appointment.getStatus());
AppointmentDTO a = appointmentList.get(position);
args.putLong("appointmentId", a.getAppointmentId());
}
detailFragment.setArguments(args);
detailFragment.setAppointmentFragment(this);
ListFragment listFragment = (ListFragment) getParentFragment();
if (listFragment != null) listFragment.loadFragment(detailFragment);
}
public void onAppointmentSaved(int position, Appointment appointment) {
if (position == -1) {
appointmentList.add(appointment);
} else {
appointmentList.set(position, appointment);
}
filterAppointments(etSearch.getText().toString());
}
public void onAppointmentDeleted(int position) {
appointmentList.remove(position);
filterAppointments(etSearch.getText().toString());
NavHostFragment.findNavController(this).navigate(R.id.nav_appointment_detail, args);
}
/**
* Handles appointment item clicks by opening details.
*/
@Override
public void onAppointmentClick(int position) {
openAppointmentDetails(position);
}
// Helper function to load hardcoded sample data
// Replace with API call
private void loadAppointmentData() {
appointmentList.clear();
appointmentList.add(new Appointment(1, "John Smith", "Buddy", "Grooming", "2026-03-10", "10:00 AM", "Confirmed"));
appointmentList.add(new Appointment(2, "Jane Doe", "Luna", "Vet Checkup", "2026-03-11", "02:00 PM", "Pending"));
appointmentList.add(new Appointment(3, "Bob Lee", "Max", "Training", "2026-03-12", "11:00 AM", "Confirmed"));
appointmentList.add(new Appointment(4, "Alice Brown", "Milo", "Grooming", "2026-03-13", "03:00 PM", "Cancelled"));
filteredList.clear();
filteredList.addAll(appointmentList);
/**
* Forwards selection changes to the bulk delete handler.
*/
@Override
public void onSelectionChanged(int count) {
if (bulkDeleteHandler != null) {
bulkDeleteHandler.onSelectionChanged(count);
}
}
private void setupRecyclerView(View view) {
RecyclerView recyclerView = view.findViewById(R.id.recyclerViewAppointments);
adapter = new AppointmentAdapter(filteredList, this); // adapter uses filteredList
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
recyclerView.setAdapter(adapter);
/**
* Checks if the currently logged-in user has the STAFF role.
*/
private boolean isStaff() {
return "STAFF".equalsIgnoreCase(tokenManager.getRole());
}
/**
* Loads appointment data based on current filters, search query, and selected date.
*/
private void loadAppointmentData(boolean reset) {
String query = binding.etSearchAppointment.getText().toString().trim();
String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses";
Long storeId;
if (isStaff()) {
storeId = tokenManager.getPrimaryStoreId();
} else {
storeId = null;
List<StoreDTO> stores = viewModel.getStores().getValue();
if (binding.spinnerStore.getSelectedItemPosition() > 0 && stores != null && !stores.isEmpty()) {
storeId = stores.get(binding.spinnerStore.getSelectedItemPosition() - 1).getStoreId();
}
}
String selectedDateString = null;
if (selectedCalendarDay != null) {
selectedDateString = String.format(Locale.getDefault(), "%04d-%02d-%02d",
selectedCalendarDay.getYear(), selectedCalendarDay.getMonth(), selectedCalendarDay.getDay());
}
Long employeeId = null;
if (binding.btnMyAppointments.isChecked()) {
employeeId = currentUserId;
}
if (status.equals("All Statuses")) status = null;
else status = status.toUpperCase();
viewModel.loadAppointments(reset, query, status, storeId, selectedDateString, employeeId);
}
/**
* Configures the RecyclerView and its scroll listener for pagination.
*/
private void setupRecyclerView() {
adapter = new AppointmentAdapter(appointmentList, this);
binding.recyclerViewAppointments.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewAppointments.setAdapter(adapter);
binding.recyclerViewAppointments.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewAppointments.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadAppointmentData(false);
}
}
});
}
/**
* Cleans up the binding reference.
*/
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
}

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