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 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 sampleFromJson(String str) { final Map jsonData = json.decode(str); return List.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 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 createState() => _TBDoctorHomePageState(); } class _TBDoctorHomePageState extends State { // 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 locations = {}; List reservations = []; // 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 _requestURI(String uri) async { Uri? url = Uri.tryParse(uri); Map myHeaders = {}; 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: [ 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: [ 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(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: [ 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), ), ); } }