You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
616 lines
22 KiB
616 lines
22 KiB
import 'dart:convert'; |
|
import 'dart:async'; |
|
|
|
import 'package:flutter/material.dart'; |
|
import 'package:flutter/services.dart'; |
|
import 'package:intl/intl.dart'; |
|
import 'package:url_launcher/url_launcher.dart'; |
|
import 'package:http/http.dart' as http; |
|
import 'package:shared_preferences/shared_preferences.dart'; |
|
import 'package:awesome_notifications/awesome_notifications.dart'; |
|
import 'package:scheduled_timer/scheduled_timer.dart'; |
|
|
|
void main() { |
|
AwesomeNotifications().initialize( |
|
// set the icon to null if you want to use the default app icon |
|
null, |
|
[ |
|
NotificationChannel( |
|
channelGroupKey: 'reminder_channel_group', |
|
channelKey: 'reminder_channel', |
|
channelName: 'Check-In Reminder notifications', |
|
channelDescription: 'Notification channel for Check-In Reminders', |
|
defaultColor: Colors.cyan.shade900, |
|
ledColor: Colors.white) |
|
], |
|
// Channel groups are only visual and are not required |
|
channelGroups: [ |
|
NotificationChannelGroup( |
|
channelGroupkey: 'reminder_channel_group', |
|
channelGroupName: 'Reminder group') |
|
], |
|
debug: true |
|
); |
|
runApp(const TBDoctor()); |
|
} |
|
|
|
class TBDoctor extends StatelessWidget { |
|
const TBDoctor({Key? key}) : super(key: key); |
|
|
|
get id => null; |
|
|
|
// This widget is the root of your application. |
|
@override |
|
Widget build(BuildContext context) { |
|
return MaterialApp( |
|
title: 'TBDoctor', |
|
theme: ThemeData( |
|
primarySwatch: Colors.cyan, |
|
), |
|
home: const TBDoctorHomePage(title: 'Telecommuting Bank Doctor'), |
|
); |
|
} |
|
} |
|
|
|
class SNowBuildingReservation { |
|
final DateTime reservationDateTime; |
|
final String locationLink; |
|
final String? reservationLink; |
|
|
|
const SNowBuildingReservation({ |
|
required this.reservationDateTime, |
|
required this.locationLink, |
|
this.reservationLink, |
|
}); |
|
factory SNowBuildingReservation.fromJson(Map<String, dynamic> json) { |
|
try { |
|
return SNowBuildingReservation( |
|
reservationDateTime: DateTime.parse(json['u_reservation_short_date']), |
|
locationLink: json['location']['link'], |
|
reservationLink: json['workplace_request']['link'], |
|
); |
|
} catch (e) { |
|
return SNowBuildingReservation( |
|
reservationDateTime: DateTime.parse(json['u_reservation_short_date']), |
|
locationLink: json['location']['link'], |
|
); |
|
} |
|
} |
|
|
|
static List<SNowBuildingReservation> sampleFromJson(String str) { |
|
final Map<String, dynamic> jsonData = json.decode(str); |
|
return List<SNowBuildingReservation>.from( |
|
jsonData['result'].map((x) => SNowBuildingReservation.fromJson(x))); |
|
} |
|
} |
|
|
|
class SNowUser { |
|
final String userSysId; |
|
final String userName; |
|
|
|
const SNowUser({ |
|
required this.userName, |
|
required this.userSysId, |
|
}); |
|
factory SNowUser.fromJson(Map<String, dynamic> json) { |
|
return SNowUser( |
|
userName: json['result']['user_name'], |
|
userSysId: json['result']['user_sys_id'], |
|
); |
|
} |
|
} |
|
|
|
class TBDoctorHomePage extends StatefulWidget { |
|
const TBDoctorHomePage({Key? key, required this.title}) : super(key: key); |
|
final String title; |
|
|
|
@override |
|
State<TBDoctorHomePage> createState() => _TBDoctorHomePageState(); |
|
} |
|
|
|
class _TBDoctorHomePageState extends State<TBDoctorHomePage> { |
|
// Set by user interaction |
|
static String? _cookie; |
|
static String? _xUserToken; |
|
static double _tbdHoursToDate = 8.0; |
|
static String? _snowServer; |
|
|
|
static double _inOfficeHoursToDate = 4.5; |
|
|
|
// Set from SNow API |
|
static SNowUser selfUser = const SNowUser(userName: "", userSysId: ""); |
|
static Map<String, String> locations = <String, String>{}; |
|
List<SNowBuildingReservation> reservations = <SNowBuildingReservation>[]; |
|
|
|
// Given by Administration |
|
final double _totalWorkingHours = 1860.00; |
|
final double _summerHoursOffsetHours = 16.00; |
|
final double _allHandsHours = 90.0; |
|
final double _focusWeekFirstHours = 37.5; |
|
final double _focusWeekSecondHours = 37.5; |
|
|
|
// Set using calculations |
|
double _totalTBDTimeHours = 0.0; |
|
double _totalInOfficeHours = 0.0; |
|
double _totalInOfficeDays = 0.0; |
|
double _tbdHoursLeftForYear = 0.0; |
|
double _tbdDaysLeftForYear = 0.0; |
|
double _inOfficeDaysLeftForYear = 0.0; |
|
|
|
// Required for program |
|
late SharedPreferences _prefs; |
|
late ScheduledTimer checkinReminder; |
|
|
|
void _setCalcs() async { |
|
if (_inOfficeHoursToDate == 0.0 && _tbdHoursToDate == 0.0 || |
|
_snowServer == null || _snowServer!.isEmpty) { |
|
showDialog( |
|
context: context, |
|
builder: (BuildContext context) => _buildPopupDialog(context), |
|
); |
|
} |
|
setState(() { |
|
_totalTBDTimeHours = (_totalWorkingHours - (_totalWorkingHours * 0.2)) - |
|
_summerHoursOffsetHours; |
|
_totalInOfficeHours = (_totalWorkingHours - (_totalWorkingHours * 0.8)); |
|
_totalInOfficeDays = _totalInOfficeHours / 7.5; |
|
_tbdHoursLeftForYear = _totalTBDTimeHours - _tbdHoursToDate; |
|
_tbdDaysLeftForYear = _tbdHoursLeftForYear / 7.5; |
|
_inOfficeDaysLeftForYear = |
|
_totalInOfficeDays - (_inOfficeHoursToDate / 7.5); |
|
double totalForcedHours = |
|
_allHandsHours + _focusWeekFirstHours + _focusWeekSecondHours; |
|
_inOfficeDaysLeftForYear -= totalForcedHours / 7.5; |
|
}); |
|
} |
|
|
|
static Future<http.Response> _requestURI(String uri) async { |
|
Uri? url = Uri.tryParse(uri); |
|
Map<String, String> myHeaders = <String, String>{}; |
|
String cookie = '', xUserToken = ''; |
|
if (_cookie == null) { |
|
var url = Uri.parse('https://$_snowServer.service-now.com/'); |
|
if (await canLaunchUrl(url)) { |
|
await launchUrl(url); |
|
// Obtain shared preferences. |
|
final prefs = await SharedPreferences.getInstance(); |
|
//TODO: Get the actual cookie/xUserToken from auth flow |
|
|
|
_cookie = cookie; |
|
_xUserToken = xUserToken; |
|
await prefs.setString("cookie", cookie); |
|
await prefs.setString("xUserToken", xUserToken); |
|
} else { |
|
throw 'Could not launch $url'; |
|
} |
|
} |
|
myHeaders['Cookie'] = _cookie ?? cookie; |
|
myHeaders['X-UserToken'] = _xUserToken ?? xUserToken; |
|
http.Response response = await http.get(url!, headers: myHeaders); |
|
return response; |
|
} |
|
|
|
void _populateSelf() async { |
|
if (_cookie == null || _xUserToken == null) { |
|
_cookie = _prefs.getString("cookie"); |
|
_xUserToken = _prefs.getString("xUserToken"); |
|
} |
|
var response = await _requestURI( |
|
"https://$_snowServer.service-now.com/api/now/ui/user/current_user"); |
|
if (response.statusCode == 200 || response.statusCode == 202) { |
|
selfUser = SNowUser.fromJson(jsonDecode(response.body)); |
|
_setCalcs(); |
|
_populateReservations(); |
|
} else { |
|
throw Exception('Failed to load account'); |
|
} |
|
} |
|
|
|
void _populateReservations() async { |
|
var response = await _requestURI( |
|
'https://$_snowServer.service-now.com/api/now/table/sn_wsd_core_reservation?requested_for=${selfUser.userSysId}&state=confirmed&start%3E%3Djavascript:gs.beginningOfToday()'); |
|
if (response.statusCode == 200 || response.statusCode == 202) { |
|
reservations = SNowBuildingReservation.sampleFromJson(response.body); |
|
reservations.sort((a, b) { |
|
return a.reservationDateTime.compareTo(b.reservationDateTime); |
|
}); |
|
for (SNowBuildingReservation res in reservations) { |
|
if (!_TBDoctorHomePageState.locations.containsKey(res.locationLink)) { |
|
var response = |
|
await _TBDoctorHomePageState._requestURI(res.locationLink); |
|
if (response.statusCode == 200 || response.statusCode == 202) { |
|
_TBDoctorHomePageState.locations[res.locationLink] = |
|
jsonDecode(response.body)['result']['name']; |
|
} else { |
|
throw Exception('Failed to load location'); |
|
} |
|
} |
|
} |
|
|
|
setState(() {}); |
|
} else { |
|
throw Exception('Failed to load reservations'); |
|
} |
|
} |
|
|
|
void _launchUrl(String uri) async { |
|
var url = Uri.parse(uri); |
|
if (await canLaunchUrl(url)) { |
|
await launchUrl(url); |
|
} else { |
|
throw 'Could not launch $url'; |
|
} |
|
} |
|
|
|
void _newReservation() async { |
|
_launchUrl( |
|
'https://$_snowServer.service-now.com/serviceportal?id=sc_cat_item&sys_id=8fe9981d1bdb6c10b346cb35624bcb1d'); |
|
} |
|
|
|
void _healthCheck() async { |
|
_launchUrl( |
|
'https://$_snowServer.service-now.com/serviceportal?id=sc_cat_item&sys_id=9c578864ed111010fa9b395e35885545'); |
|
} |
|
|
|
void _viewAllReservation() async { |
|
_launchUrl( |
|
'https://$_snowServer.service-now.com/serviceportal?id=u_my_building_schedule&t=sn_wsd_core_reservation&filter=active%3Dtrue%5Estate%3Dconfirmed%5Estart%3E%3Djavascript:gs.beginningOfToday()%5Ereservation_subtypeINsingle,occurrence%5EORDERBYstart%5EEQ'); |
|
} |
|
|
|
Widget _buildPopupDialog(BuildContext context) { |
|
String? tbdString = _tbdHoursToDate.toString(); |
|
String? inOfficeString = _inOfficeHoursToDate.toString(); |
|
return AlertDialog( |
|
title: const Text('Manually change hours'), |
|
content: Column( |
|
mainAxisSize: MainAxisSize.min, |
|
crossAxisAlignment: CrossAxisAlignment.start, |
|
children: <Widget>[ |
|
Column( |
|
children: [ |
|
const Text("Don't forget to save and close"), |
|
TextFormField( |
|
decoration: const InputDecoration(labelText: "SNow Server"), |
|
initialValue: _snowServer, |
|
onChanged: (String? newVal) { |
|
try { |
|
_snowServer = newVal; |
|
} finally { |
|
_prefs.setString("_snowServer", _snowServer!); |
|
} |
|
}, |
|
), |
|
TextFormField( |
|
decoration: const InputDecoration(labelText: "TBD Hours Spent"), |
|
inputFormatters: [ |
|
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')), |
|
], |
|
keyboardType: |
|
const TextInputType.numberWithOptions(decimal: true), |
|
initialValue: _tbdHoursToDate.toString(), |
|
onChanged: (String? newVal) { |
|
tbdString = newVal; |
|
try { |
|
_tbdHoursToDate = double.parse(tbdString ?? '0.0'); |
|
_prefs.setDouble("_tbdHoursToDate", _tbdHoursToDate); |
|
} catch (e) { |
|
_tbdHoursToDate = double.parse("${tbdString!}.0"); |
|
_prefs.setDouble("_tbdHoursToDate", _tbdHoursToDate); |
|
} |
|
}, |
|
), |
|
TextFormField( |
|
decoration: |
|
const InputDecoration(labelText: "Time Spent in Office"), |
|
inputFormatters: [ |
|
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')), |
|
], |
|
keyboardType: |
|
const TextInputType.numberWithOptions(decimal: true), |
|
initialValue: _inOfficeHoursToDate.toString(), |
|
onChanged: (String? newVal) { |
|
inOfficeString = newVal; |
|
try { |
|
_inOfficeHoursToDate = |
|
double.parse(inOfficeString ?? '0.0'); |
|
_prefs.setDouble( |
|
"_inOfficeHoursToDate", _inOfficeHoursToDate); |
|
} catch (e) { |
|
_inOfficeHoursToDate = |
|
double.parse("${inOfficeString!}.0"); |
|
_prefs.setDouble( |
|
"_inOfficeHoursToDate", _inOfficeHoursToDate); |
|
} |
|
}, |
|
), |
|
], |
|
), |
|
], |
|
), |
|
actions: <Widget>[ |
|
FloatingActionButton( |
|
onPressed: () { |
|
_setCalcs(); |
|
Navigator.pop(context); |
|
}, |
|
child: const Text('Close'), |
|
), |
|
], |
|
); |
|
} |
|
void _showReminderNotification() { |
|
DateTime now = DateTime.now(); |
|
if (now.weekday == DateTime.saturday || now.weekday == DateTime.sunday) { |
|
return; |
|
} |
|
AwesomeNotifications().createNotification( |
|
content: NotificationContent( |
|
id: 10, |
|
channelKey: 'reminder_channel', |
|
title: 'Check-In Reminder', |
|
body: 'Are you remote, in office, or out today?'), |
|
actionButtons: <NotificationActionButton>[ |
|
NotificationActionButton(key: 'remote', label: 'Remote'), |
|
NotificationActionButton(key: 'office', label: 'In Office'), |
|
NotificationActionButton(key: 'no', label: 'Not Today'), |
|
], |
|
); |
|
} |
|
|
|
@override |
|
void initState() { |
|
SharedPreferences.getInstance().then((SharedPreferences p) { |
|
_prefs = p; |
|
_inOfficeHoursToDate = _prefs.getDouble("_inOfficeHoursToDate") ?? 0.0; |
|
_tbdHoursToDate = _prefs.getDouble("_tbdHoursToDate") ?? 0.0; |
|
_snowServer = _prefs.getString("_snowServer"); |
|
_setCalcs(); |
|
_populateSelf(); |
|
}); |
|
DateTime now = DateTime.now(); |
|
checkinReminder = ScheduledTimer( |
|
id: "checkinReminder", |
|
onExecute: _showReminderNotification, |
|
defaultScheduledTime: DateTime(now.year, now.month, now.day + 1, 7, 0), |
|
onMissedSchedule: _showReminderNotification, |
|
); |
|
|
|
AwesomeNotifications().actionStream.listen((receivedNotification) { |
|
DateTime now = DateTime.now(); |
|
double hours = 7.5; |
|
if (now.weekday == DateTime.saturday || now.weekday == DateTime.sunday) { |
|
debugPrint("Detected weekend, no work allowed"); |
|
return; |
|
} |
|
if (now.isAfter(DateTime(now.year, 06, 23)) || now.isBefore(DateTime(now.year, 09, 14))) { |
|
debugPrint("Detected summer hours"); |
|
hours = 8; |
|
if (now.weekday == DateTime.friday) { |
|
debugPrint("Detected summer hours, half day friday"); |
|
hours = 4.5; |
|
} |
|
} |
|
SharedPreferences.getInstance().then((SharedPreferences p) { |
|
_prefs = p; |
|
switch(receivedNotification.buttonKeyPressed) { |
|
case "remote": |
|
debugPrint("Modifying TBD since user responded 'remote'"); |
|
_tbdHoursToDate += hours; |
|
_prefs.setDouble("_tbdHoursToDate", _tbdHoursToDate); |
|
break; |
|
case "office": |
|
debugPrint("Modifying in-office since user responded 'office'"); |
|
_inOfficeHoursToDate += hours; |
|
_prefs.setDouble("_tbdHoursToDate", _tbdHoursToDate); |
|
break; |
|
default: |
|
debugPrint("Didn't care."); |
|
break; |
|
} |
|
// Schedule the next one? |
|
DateTime now = DateTime.now(); |
|
checkinReminder = ScheduledTimer( |
|
id: "checkinReminder", |
|
onExecute: _showReminderNotification, |
|
defaultScheduledTime: DateTime(now.year, now.month, now.day + 1, 7, 0), |
|
onMissedSchedule: _showReminderNotification, |
|
); |
|
_setCalcs(); |
|
}); |
|
}); |
|
super.initState(); |
|
} |
|
|
|
@override |
|
Widget build(BuildContext context) { |
|
return Scaffold( |
|
appBar: AppBar( |
|
title: Text( |
|
widget.title, |
|
style: const TextStyle( |
|
color: Colors.white, |
|
), |
|
), |
|
backgroundColor: Colors.cyan.shade900, |
|
), |
|
body: SingleChildScrollView( |
|
child: Column( |
|
mainAxisAlignment: MainAxisAlignment.start, |
|
children: <Widget>[ |
|
Row( |
|
mainAxisAlignment: MainAxisAlignment.spaceAround, |
|
children: const [ |
|
SizedBox( |
|
height: 16, |
|
), |
|
], |
|
), |
|
Row( |
|
mainAxisAlignment: MainAxisAlignment.spaceAround, |
|
children: [ |
|
Container( |
|
decoration: BoxDecoration( |
|
borderRadius: BorderRadius.circular(5), |
|
color: Colors.cyan.shade900, |
|
), |
|
padding: const EdgeInsets.all(8), |
|
child: Column( |
|
children: [ |
|
Row( |
|
mainAxisAlignment: MainAxisAlignment.spaceAround, |
|
children: const [ |
|
Text( |
|
'TBD Days Left', |
|
style: TextStyle( |
|
fontSize: 20, |
|
color: Colors.white, |
|
), |
|
), |
|
], |
|
), |
|
Row( |
|
mainAxisAlignment: MainAxisAlignment.spaceAround, |
|
children: [ |
|
Text( |
|
(_tbdDaysLeftForYear).toStringAsFixed(2), |
|
style: const TextStyle( |
|
fontSize: 20, |
|
color: Colors.white, |
|
), |
|
), |
|
], |
|
), |
|
], |
|
), |
|
), |
|
Container( |
|
padding: const EdgeInsets.all(8), |
|
decoration: BoxDecoration( |
|
borderRadius: BorderRadius.circular(5), |
|
color: Colors.cyan.shade900, |
|
), |
|
child: Column( |
|
children: [ |
|
Row( |
|
mainAxisAlignment: MainAxisAlignment.spaceAround, |
|
children: const [ |
|
Text( |
|
'Unscheduled Office Days Left', |
|
style: TextStyle( |
|
fontSize: 20, |
|
color: Colors.white, |
|
), |
|
), |
|
], |
|
), |
|
Row( |
|
mainAxisAlignment: MainAxisAlignment.spaceAround, |
|
children: [ |
|
Text( |
|
(_inOfficeDaysLeftForYear).toStringAsFixed(2), |
|
style: const TextStyle( |
|
fontSize: 20, |
|
color: Colors.white, |
|
), |
|
), |
|
], |
|
) |
|
], |
|
), |
|
) |
|
], |
|
), |
|
Column( |
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
children: [ |
|
Row( |
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|
children: [ |
|
ElevatedButton( |
|
onPressed: _healthCheck, |
|
child: const Text('Health Check')), |
|
ElevatedButton( |
|
onPressed: _newReservation, |
|
child: const Text('New Reservation')), |
|
ElevatedButton( |
|
onPressed: _viewAllReservation, |
|
child: const Text('View All')), |
|
], |
|
), |
|
ListView.separated( |
|
padding: const EdgeInsets.all(15), |
|
physics: const BouncingScrollPhysics(), |
|
shrinkWrap: true, |
|
scrollDirection: Axis.vertical, |
|
itemBuilder: (BuildContext context, int index) => InkWell( |
|
onTap: () { |
|
_launchUrl(reservations[index].reservationLink ?? 'https://www.youtube.com/watch?v=t3otBjVZzT0'); |
|
}, |
|
child: Container( |
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 0), |
|
height: 40, |
|
decoration: BoxDecoration( |
|
borderRadius: BorderRadius.circular(5), |
|
color: Colors.cyan.shade900, |
|
), |
|
child: Row( |
|
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
children: [ |
|
Column( |
|
crossAxisAlignment: CrossAxisAlignment.start, |
|
mainAxisAlignment: MainAxisAlignment.center, |
|
children: [ |
|
Text( |
|
DateFormat('dd-MMM-yyyy').format(reservations |
|
.elementAt(index) |
|
.reservationDateTime), |
|
textAlign: TextAlign.start, |
|
style: const TextStyle( |
|
fontSize: 18, |
|
color: Colors.white, |
|
), |
|
), |
|
], |
|
), |
|
const Spacer(), |
|
Column( |
|
crossAxisAlignment: CrossAxisAlignment.end, |
|
mainAxisAlignment: MainAxisAlignment.center, |
|
children: [ |
|
Text( |
|
locations[reservations |
|
.elementAt(index) |
|
.locationLink] ?? |
|
"Location hasn't been discovered yet!", |
|
textAlign: TextAlign.end, |
|
style: const TextStyle( |
|
fontSize: 18, color: Colors.white)), |
|
], |
|
) |
|
], |
|
), |
|
), |
|
), |
|
separatorBuilder: (context, index) => const SizedBox( |
|
height: 10, |
|
), |
|
itemCount: reservations.length, |
|
), |
|
], |
|
), |
|
], |
|
), |
|
), |
|
floatingActionButton: FloatingActionButton( |
|
onPressed: () { |
|
showDialog( |
|
context: context, |
|
builder: (BuildContext context) => _buildPopupDialog(context), |
|
); |
|
}, |
|
tooltip: 'Health Check', |
|
child: const Icon(Icons.settings), |
|
), |
|
); |
|
} |
|
}
|
|
|