init commit - move from separate git repos
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fbla_ui/shared.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
var apiAddress = 'https://homelab.marinodev.com/fbla-api';
|
||||
var client = http.Client();
|
||||
// var apiAddress = '192.168.0.114:8000';
|
||||
// var apiLogoAddress = 'https://homelab.marinodev.com/fbla-api';
|
||||
// var apiLogoAddress = 'http://192.168.0.114:8000';
|
||||
|
||||
Future fetchBusinessData() async {
|
||||
try {
|
||||
var response = await http
|
||||
.get(Uri.parse('$apiAddress/businessdata'))
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode == 200) {
|
||||
var decodedResponse = json.decode(response.body);
|
||||
List<Business> businessList = List<Business>.from(
|
||||
decodedResponse.map((json) => Business.fromJson(json)).toList());
|
||||
return businessList;
|
||||
} else {
|
||||
return 'Error ${response.statusCode}! Please try again later!';
|
||||
}
|
||||
} on TimeoutException {
|
||||
return 'Unable to connect to server (timeout).\nPlease try again later.';
|
||||
} on SocketException {
|
||||
return 'Unable to connect to server (socket exception).\nPlease check your internet connection.\n';
|
||||
}
|
||||
}
|
||||
|
||||
Future createBusiness(Business business, String jwt) async {
|
||||
var json = '''
|
||||
{
|
||||
"id": ${business.id},
|
||||
"name": "${business.name}",
|
||||
"description": "${business.description}",
|
||||
"type": "${business.type.name}",
|
||||
"website": "${business.website}",
|
||||
"contactName": "${business.contactName}",
|
||||
"contactEmail": "${business.contactEmail}",
|
||||
"contactPhone": "${business.contactPhone}",
|
||||
"notes": "${business.notes}",
|
||||
"locationName": "${business.locationName}",
|
||||
"locationAddress": "${business.locationAddress}"
|
||||
}
|
||||
''';
|
||||
|
||||
try {
|
||||
var response = await http.post(Uri.parse('$apiAddress/createbusiness'),
|
||||
body: json,
|
||||
headers: {'Authorization': jwt}).timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
return response.body;
|
||||
}
|
||||
} on TimeoutException {
|
||||
return 'Unable to connect to server (timeout). Please try again later';
|
||||
} on SocketException {
|
||||
return 'Unable to connect to server (socket exception). Please check your internet connection.';
|
||||
}
|
||||
}
|
||||
|
||||
Future deleteBusiness(Business business, String jwt) async {
|
||||
var json = '''
|
||||
{
|
||||
"id": ${business.id}
|
||||
}
|
||||
''';
|
||||
|
||||
try {
|
||||
var response = await http.post(Uri.parse('$apiAddress/deletebusiness'),
|
||||
body: json,
|
||||
headers: {'Authorization': jwt}).timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
return response.body;
|
||||
}
|
||||
} on TimeoutException {
|
||||
return 'Unable to connect to server (timeout). Please try again later';
|
||||
} on SocketException {
|
||||
return 'Unable to connect to server (socket exception). Please check your internet connection.';
|
||||
}
|
||||
}
|
||||
|
||||
Future editBusiness(Business business, String jwt) async {
|
||||
var json = '''
|
||||
{
|
||||
"id": ${business.id},
|
||||
"name": "${business.name}",
|
||||
"description": "${business.description}",
|
||||
"type": "${business.type.name}",
|
||||
"website": "${business.website}",
|
||||
"contactName": "${business.contactName}",
|
||||
"contactEmail": "${business.contactEmail}",
|
||||
"contactPhone": "${business.contactPhone}",
|
||||
"notes": "${business.notes}",
|
||||
"locationName": "${business.locationName}",
|
||||
"locationAddress": "${business.locationAddress}"
|
||||
}
|
||||
''';
|
||||
print(json);
|
||||
try {
|
||||
var response = await http.post(Uri.parse('$apiAddress/editbusiness'),
|
||||
body: json,
|
||||
headers: {'Authorization': jwt}).timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
return response.body;
|
||||
}
|
||||
} on TimeoutException {
|
||||
return 'Unable to connect to server (timeout). Please try again later';
|
||||
} on SocketException {
|
||||
return 'Unable to connect to server (socket exception). Please check your internet connection.';
|
||||
}
|
||||
}
|
||||
|
||||
Future signIn(String username, String password) async {
|
||||
var json = '''
|
||||
{
|
||||
"username": "$username",
|
||||
"password": "$password"
|
||||
}
|
||||
''';
|
||||
|
||||
var response = await http.post(
|
||||
Uri.parse('$apiAddress/signin'),
|
||||
body: json,
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
return response.body;
|
||||
} else {
|
||||
return 'Error: ${response.body}';
|
||||
}
|
||||
}
|
||||
|
||||
Future marinoDevLogo() async {
|
||||
var response = await http.get(
|
||||
Uri.parse('$apiAddress/marinodev'),
|
||||
);
|
||||
|
||||
// File logo = File ('${getTemporaryDirectory().toString()}/marinodev.svg');
|
||||
// logo.writeAsBytes(response.bodyBytes);
|
||||
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
Future getLogo(int logoId) async {
|
||||
var response = await http.get(
|
||||
Uri.parse('$apiAddress/logos/$logoId'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return response.bodyBytes;
|
||||
} else {
|
||||
return 'Error ${response.statusCode}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
||||
import 'package:fbla_ui/api_logic.dart';
|
||||
import 'package:fbla_ui/main.dart';
|
||||
import 'package:fbla_ui/pages/create_edit_business.dart';
|
||||
import 'package:fbla_ui/pages/export_data.dart';
|
||||
import 'package:fbla_ui/pages/signin_page.dart';
|
||||
import 'package:fbla_ui/shared.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rive/rive.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
typedef Callback = void Function();
|
||||
|
||||
class Home extends StatefulWidget {
|
||||
final Callback themeCallback;
|
||||
|
||||
const Home({super.key, required this.themeCallback});
|
||||
|
||||
@override
|
||||
State<Home> createState() => _HomeState();
|
||||
}
|
||||
|
||||
class _HomeState extends State<Home> {
|
||||
late Future refreshBusinessDataFuture;
|
||||
bool _isPreviousData = false;
|
||||
late List<Business> businesses;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
refreshBusinessDataFuture = fetchBusinessData();
|
||||
initialLogin();
|
||||
}
|
||||
|
||||
Future<void> initialLogin() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
bool? rememberMe = prefs.getBool('rememberMe');
|
||||
if (rememberMe != null && rememberMe) {
|
||||
String? username = prefs.getString('username');
|
||||
String? password = prefs.getString('password');
|
||||
|
||||
jwt = await signIn(username!, password!);
|
||||
if (!jwt.contains('Error:')) {
|
||||
setState(() {
|
||||
loggedIn = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setStateCallback() {
|
||||
setState(() {
|
||||
loggedIn = loggedIn;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool widescreen = MediaQuery.sizeOf(context).width >= 1000;
|
||||
return Scaffold(
|
||||
floatingActionButton: _getFAB(),
|
||||
body: RefreshIndicator(
|
||||
edgeOffset: 120,
|
||||
onRefresh: () async {
|
||||
var refreshedData = fetchBusinessData();
|
||||
await refreshedData;
|
||||
setState(() {
|
||||
refreshBusinessDataFuture = refreshedData;
|
||||
});
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
title: widescreen ? _searchBar() : const Text('Job Link'),
|
||||
toolbarHeight: 70,
|
||||
pinned: true,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
expandedHeight: widescreen ? 70 : 120,
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
bottom: _getBottom(),
|
||||
leading: IconButton(
|
||||
icon: getIconFromThemeMode(themeMode),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
widget.themeCallback();
|
||||
});
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('About'),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.background,
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Welcome to my FBLA 2024 Coding and Programming submission!\n\n'
|
||||
'MarinoDev Job Link aims to provide comprehensive details of businesses and community partners'
|
||||
' for Waukesha West High School\'s Career and Technical Education Department.\n\n'),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
child: const Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Git Repo:'),
|
||||
Text(
|
||||
'https://git.marinodev.com/MarinoDev/FBLA24\n',
|
||||
style: TextStyle(
|
||||
color: Colors.blue)),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
launchUrl(Uri.https(
|
||||
'git.marinodev.com',
|
||||
'/MarinoDev/FBLA24'));
|
||||
},
|
||||
),
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
child: const Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Please direct any questions to'),
|
||||
Text('drake@marinodev.com',
|
||||
style: TextStyle(
|
||||
color: Colors.blue)),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
launchUrl(Uri.parse(
|
||||
'mailto:drake@marinodev.com'));
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('OK'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
onPressed: () async {
|
||||
if (!_isPreviousData) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text('There is no data!'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selectedDataTypes = <DataType>{};
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ExportData(businesses: businesses)));
|
||||
}
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: IconButton(
|
||||
icon: loggedIn
|
||||
? const Icon(Icons.account_circle)
|
||||
: const Icon(Icons.login),
|
||||
onPressed: () {
|
||||
if (loggedIn) {
|
||||
var payload = JWT.decode(jwt).payload;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.background,
|
||||
title: Text('Hi, ${payload['username']}!'),
|
||||
content: Text(
|
||||
'You are logged in as an admin with username ${payload['username']}.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Logout'),
|
||||
onPressed: () async {
|
||||
final prefs = await SharedPreferences
|
||||
.getInstance();
|
||||
prefs.setBool('rememberMe', false);
|
||||
prefs.setString('username', '');
|
||||
prefs.setString('password', '');
|
||||
|
||||
setState(() {
|
||||
loggedIn = false;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SignInPage(
|
||||
refreshAccount: setStateCallback)));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
FutureBuilder(
|
||||
future: refreshBusinessDataFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
if (snapshot.hasData) {
|
||||
if (snapshot.data.runtimeType == String) {
|
||||
_isPreviousData = false;
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(children: [
|
||||
Center(
|
||||
child: Text(snapshot.data,
|
||||
textAlign: TextAlign.center)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FilledButton(
|
||||
child: const Text('Retry'),
|
||||
onPressed: () {
|
||||
var refreshedData = fetchBusinessData();
|
||||
setState(() {
|
||||
refreshBusinessDataFuture = refreshedData;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
));
|
||||
}
|
||||
|
||||
businesses = snapshot.data;
|
||||
_isPreviousData = true;
|
||||
|
||||
return BusinessDisplayPanel(
|
||||
businesses: businesses,
|
||||
widescreen: widescreen,
|
||||
selectable: false);
|
||||
} else if (snapshot.hasError) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
|
||||
child: Text(
|
||||
'Error when loading data! Error: ${snapshot.error}'),
|
||||
));
|
||||
}
|
||||
} else if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
if (_isPreviousData) {
|
||||
return BusinessDisplayPanel(
|
||||
businesses: businesses,
|
||||
widescreen: widescreen,
|
||||
selectable: false);
|
||||
} else {
|
||||
return SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
alignment: Alignment.center,
|
||||
// child: const CircularProgressIndicator(),
|
||||
child: const SizedBox(
|
||||
width: 75,
|
||||
height: 75,
|
||||
child: RiveAnimation.asset(
|
||||
'assets/mdev_triangle_loading.riv'),
|
||||
// child: RiveAnimation.file(
|
||||
// 'assets/mdev_triangle_loading.riv',
|
||||
// ),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'\nError: ${snapshot.error}',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _getFAB() {
|
||||
if (loggedIn) {
|
||||
return FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CreateEditBusiness()));
|
||||
},
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _searchBar() {
|
||||
return SizedBox(
|
||||
width: 800,
|
||||
height: 50,
|
||||
child: TextField(
|
||||
onChanged: (query) {
|
||||
setState(() {
|
||||
searchFilter = query;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Search',
|
||||
hintText: 'Search',
|
||||
prefixIcon: const Padding(
|
||||
padding: EdgeInsets.only(left: 8.0),
|
||||
child: Icon(Icons.search),
|
||||
),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(25.0)),
|
||||
),
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: IconButton(
|
||||
tooltip: 'Filters',
|
||||
icon: Icon(Icons.filter_list,
|
||||
color: isFiltered
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onBackground),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
// DO NOT MOVE TO SEPARATE WIDGET, setState is needed in main tree
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.background,
|
||||
title: const Text('Filter Options'),
|
||||
content: const FilterChips(),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Reset'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
filters = <BusinessType>{};
|
||||
selectedChips = <BusinessType>{};
|
||||
isFiltered = false;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () {
|
||||
selectedChips = Set.from(filters);
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Apply'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
filters = Set.from(selectedChips);
|
||||
if (filters.isNotEmpty) {
|
||||
isFiltered = true;
|
||||
} else {
|
||||
isFiltered = false;
|
||||
}
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget? _getBottom() {
|
||||
if (MediaQuery.sizeOf(context).width <= 1000) {
|
||||
return PreferredSize(
|
||||
preferredSize: const Size.fromHeight(0),
|
||||
child: SizedBox(
|
||||
// color: Theme.of(context).colorScheme.background,
|
||||
height: 70,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: _searchBar(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:fbla_ui/home.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
ThemeMode themeMode = ThemeMode.system;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
bool? isDark = prefs.getBool('isDark');
|
||||
|
||||
themeMode = isDark != null && isDark
|
||||
? ThemeMode.dark
|
||||
: isDark != null && !isDark
|
||||
? ThemeMode.light
|
||||
: ThemeMode.system;
|
||||
runApp(const MaterialApp(
|
||||
title: 'Job Link',
|
||||
home: MainApp(),
|
||||
));
|
||||
}
|
||||
|
||||
class MainApp extends StatefulWidget {
|
||||
final bool? isDark;
|
||||
|
||||
const MainApp({super.key, this.isDark});
|
||||
|
||||
@override
|
||||
State<MainApp> createState() => _MainAppState();
|
||||
}
|
||||
|
||||
class _MainAppState extends State<MainApp> {
|
||||
void _switchTheme() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (MediaQuery.of(context).platformBrightness == Brightness.dark &&
|
||||
themeMode == ThemeMode.system) {
|
||||
setState(() {
|
||||
themeMode = ThemeMode.light;
|
||||
});
|
||||
prefs.setBool('isDark', false);
|
||||
} else if (MediaQuery.of(context).platformBrightness == Brightness.light &&
|
||||
themeMode == ThemeMode.system) {
|
||||
setState(() {
|
||||
themeMode = ThemeMode.dark;
|
||||
});
|
||||
prefs.setBool('isDark', true);
|
||||
} else if (themeMode == ThemeMode.light) {
|
||||
setState(() {
|
||||
themeMode = ThemeMode.dark;
|
||||
});
|
||||
prefs.setBool('isDark', true);
|
||||
} else if (themeMode == ThemeMode.dark) {
|
||||
setState(() {
|
||||
themeMode = ThemeMode.light;
|
||||
});
|
||||
prefs.setBool('isDark', false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Job Link',
|
||||
themeMode: themeMode,
|
||||
// themeMode: ThemeMode.light,
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
primary: Colors.blue,
|
||||
onPrimary: Colors.white,
|
||||
secondary: Colors.blue.shade900,
|
||||
background: const Color.fromARGB(255, 31, 31, 31),
|
||||
tertiary: Colors.green.shade900,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
inputDecorationTheme: const InputDecorationTheme(),
|
||||
useMaterial3: true,
|
||||
),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.light(
|
||||
brightness: Brightness.light,
|
||||
primary: Colors.blue,
|
||||
onPrimary: Colors.white,
|
||||
secondary: Colors.blue.shade200,
|
||||
background: Colors.white,
|
||||
tertiary: Colors.green,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: Colors.black),
|
||||
inputDecorationTheme:
|
||||
const InputDecorationTheme(border: UnderlineInputBorder()),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: Home(themeCallback: _switchTheme),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:fbla_ui/api_logic.dart';
|
||||
import 'package:fbla_ui/main.dart';
|
||||
import 'package:fbla_ui/pages/create_edit_business.dart';
|
||||
import 'package:fbla_ui/pages/signin_page.dart';
|
||||
import 'package:fbla_ui/shared.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class BusinessDetail extends StatefulWidget {
|
||||
final Business inputBusiness;
|
||||
|
||||
const BusinessDetail({super.key, required this.inputBusiness});
|
||||
|
||||
@override
|
||||
State<BusinessDetail> createState() => _CreateBusinessDetailState();
|
||||
}
|
||||
|
||||
class _CreateBusinessDetailState extends State<BusinessDetail> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Business business = Business.copy(widget.inputBusiness);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(business.name),
|
||||
actions: _getActions(business),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(business.name,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(
|
||||
business.description,
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
child: Image.network(
|
||||
'https://$apiAddress/fbla-api/logos/${business.id}',
|
||||
width: 48,
|
||||
height: 48, errorBuilder: (BuildContext context,
|
||||
Object exception, StackTrace? stackTrace) {
|
||||
return getIconFromType(business.type, 48,
|
||||
Theme.of(context).colorScheme.onSurface);
|
||||
}),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.link),
|
||||
title: const Text('Website'),
|
||||
subtitle: Text(business.website,
|
||||
style: const TextStyle(color: Colors.blue)),
|
||||
onTap: () {
|
||||
launchUrl(Uri.parse('https://${business.website}'));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: (business.contactEmail.isNotEmpty ||
|
||||
business.contactPhone.isNotEmpty),
|
||||
child: Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8.0),
|
||||
child: Text(
|
||||
business.contactName.isEmpty
|
||||
? 'Contact ${business.name}'
|
||||
: business.contactName,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Visibility(
|
||||
visible: business.contactPhone.isNotEmpty,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.phone),
|
||||
title: Text(business.contactPhone),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.background,
|
||||
title: Text(business.contactName.isEmpty
|
||||
? 'Contact ${business.name}?'
|
||||
: 'Contact ${business.contactName}'),
|
||||
content: Text(business.contactName.isEmpty
|
||||
? 'Would you like to call or text ${business.name}?'
|
||||
: 'Would you like to call or text ${business.contactName}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Text'),
|
||||
onPressed: () {
|
||||
launchUrl(Uri.parse(
|
||||
'sms:${business.contactPhone}'));
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Call'),
|
||||
onPressed: () async {
|
||||
launchUrl(Uri.parse(
|
||||
'tel:${business.contactPhone}'));
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: business.contactEmail.isNotEmpty,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.email),
|
||||
title: Text(business.contactEmail),
|
||||
onTap: () {
|
||||
launchUrl(Uri.parse('mailto:${business.contactEmail}'));
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
child: Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.location_on),
|
||||
title: Text(business.locationName),
|
||||
subtitle: Text(business.locationAddress),
|
||||
onTap: () {
|
||||
launchUrl(Uri.parse(Uri.encodeFull(
|
||||
'https://www.google.com/maps/search/?api=1&query=${business.locationName}')));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: business.notes.isNotEmpty,
|
||||
child: Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.notes),
|
||||
title: const Text(
|
||||
'Additional Notes:',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(business.notes),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget>? _getActions(Business business) {
|
||||
if (loggedIn) {
|
||||
return [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
CreateEditBusiness(inputBusiness: business)));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
title: const Text('Are You Sure?'),
|
||||
content:
|
||||
Text('This will permanently delete ${business.name}.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Yes'),
|
||||
onPressed: () async {
|
||||
String? deleteResult =
|
||||
await deleteBusiness(business, jwt);
|
||||
if (deleteResult != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(deleteResult)));
|
||||
} else {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MainApp()));
|
||||
}
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import 'package:fbla_ui/api_logic.dart';
|
||||
import 'package:fbla_ui/main.dart';
|
||||
import 'package:fbla_ui/shared.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CreateEditBusiness extends StatefulWidget {
|
||||
final Business? inputBusiness;
|
||||
|
||||
const CreateEditBusiness({super.key, this.inputBusiness});
|
||||
|
||||
@override
|
||||
State<CreateEditBusiness> createState() => _CreateEditBusinessState();
|
||||
}
|
||||
|
||||
class _CreateEditBusinessState extends State<CreateEditBusiness> {
|
||||
late TextEditingController _nameController;
|
||||
late TextEditingController _websiteController;
|
||||
late TextEditingController _descriptionController;
|
||||
late TextEditingController _contactNameController;
|
||||
late TextEditingController _contactPhoneController;
|
||||
late TextEditingController _contactEmailController;
|
||||
late TextEditingController _notesController;
|
||||
late TextEditingController _locationNameController;
|
||||
late TextEditingController _locationAddressController;
|
||||
Business business = Business(
|
||||
id: 0,
|
||||
name: 'Business',
|
||||
description: 'Add details about the business below.',
|
||||
type: BusinessType.other,
|
||||
website: '',
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
notes: '',
|
||||
locationName: '',
|
||||
locationAddress: '',
|
||||
);
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.inputBusiness != null) {
|
||||
business = Business.copy(widget.inputBusiness!);
|
||||
_nameController = TextEditingController(text: business.name);
|
||||
_descriptionController =
|
||||
TextEditingController(text: business.description);
|
||||
} else {
|
||||
_nameController = TextEditingController();
|
||||
_descriptionController = TextEditingController();
|
||||
}
|
||||
_websiteController = TextEditingController(text: business.website);
|
||||
_contactNameController = TextEditingController(text: business.contactName);
|
||||
_contactPhoneController =
|
||||
TextEditingController(text: business.contactPhone);
|
||||
_contactEmailController =
|
||||
TextEditingController(text: business.contactEmail);
|
||||
_notesController = TextEditingController(text: business.notes);
|
||||
_locationNameController =
|
||||
TextEditingController(text: business.locationName);
|
||||
_locationAddressController =
|
||||
TextEditingController(text: business.locationAddress);
|
||||
}
|
||||
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final TextEditingController businessTypeController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: !_isLoading,
|
||||
onPopInvoked: _handlePop,
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: (widget.inputBusiness != null)
|
||||
? Text('Edit ${widget.inputBusiness?.name}', maxLines: 1)
|
||||
: const Text('Add New Business'),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: _isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3.0,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.save),
|
||||
onPressed: () async {
|
||||
if (formKey.currentState!.validate()) {
|
||||
formKey.currentState?.save();
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
String? result;
|
||||
// if (business.contactName == '') {
|
||||
// business.contactName = 'Contact ${business.name}';
|
||||
// }
|
||||
if (widget.inputBusiness != null) {
|
||||
result = await editBusiness(business, jwt);
|
||||
} else {
|
||||
result = await createBusiness(business, jwt);
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (result != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
width: 400,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(result)));
|
||||
} else {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MainApp()));
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Check field inputs!'),
|
||||
width: 200,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 1000,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(business.name,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(
|
||||
business.description,
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
child: Image.network(
|
||||
width: 48,
|
||||
height: 48,
|
||||
'https://logo.clearbit.com/${business.website}',
|
||||
errorBuilder: (BuildContext context,
|
||||
Object exception, StackTrace? stackTrace) {
|
||||
return getIconFromType(business.type, 48,
|
||||
Theme.of(context).colorScheme.onBackground);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0),
|
||||
child: TextFormField(
|
||||
controller: _nameController,
|
||||
autovalidateMode:
|
||||
AutovalidateMode.onUserInteraction,
|
||||
maxLength: 30,
|
||||
onChanged: (inputName) {
|
||||
setState(() {
|
||||
business.name = inputName;
|
||||
});
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Business Name',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isEmpty) {
|
||||
return 'Name is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, bottom: 8.0),
|
||||
child: TextFormField(
|
||||
controller: _websiteController,
|
||||
autovalidateMode:
|
||||
AutovalidateMode.onUserInteraction,
|
||||
keyboardType: TextInputType.url,
|
||||
onChanged: (inputUrl) {
|
||||
business.website = Uri.encodeFull(inputUrl
|
||||
.toLowerCase()
|
||||
.replaceAll('https://', '')
|
||||
.replaceAll('http://', '')
|
||||
.replaceAll('www.', ''));
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Website',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isEmpty) {
|
||||
return 'Website is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0),
|
||||
child: TextFormField(
|
||||
controller: _descriptionController,
|
||||
autovalidateMode:
|
||||
AutovalidateMode.onUserInteraction,
|
||||
maxLength: 500,
|
||||
onChanged: (inputDesc) {
|
||||
setState(() {
|
||||
business.description = inputDesc;
|
||||
});
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Business Description',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isEmpty) {
|
||||
return 'Description is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
// Padding(
|
||||
// padding: const EdgeInsets.only(
|
||||
// left: 8.0, right: 8.0, bottom: 16.0),
|
||||
// child: Row(
|
||||
// children: [
|
||||
// ElevatedButton(
|
||||
// style: ButtonStyle(
|
||||
// backgroundColor:
|
||||
// MaterialStateProperty.all(
|
||||
// Theme.of(context)
|
||||
// .colorScheme
|
||||
// .background)),
|
||||
// child: const Row(
|
||||
// children: [
|
||||
// Icon(Icons.search),
|
||||
// Text('Search For Location'),
|
||||
// ],
|
||||
// ),
|
||||
// onPressed: () {},
|
||||
// ),
|
||||
// const Padding(
|
||||
// padding: EdgeInsets.only(
|
||||
// left: 32.0, right: 32.0),
|
||||
// child: Text(
|
||||
// 'OR',
|
||||
// style: TextStyle(fontSize: 24),
|
||||
// ),
|
||||
// ),
|
||||
// Expanded(
|
||||
// child: Column(
|
||||
// children: [
|
||||
// TextFormField(
|
||||
// controller: _locationNameController,
|
||||
// onChanged: (inputName) {
|
||||
// setState(() {
|
||||
// business.locationName =
|
||||
// inputName;
|
||||
// });
|
||||
// },
|
||||
// onTapOutside:
|
||||
// (PointerDownEvent event) {
|
||||
// FocusScope.of(context).unfocus();
|
||||
// },
|
||||
// decoration: const InputDecoration(
|
||||
// labelText:
|
||||
// 'Location Name (optional)',
|
||||
// ),
|
||||
// ),
|
||||
// TextFormField(
|
||||
// controller:
|
||||
// _locationAddressController,
|
||||
// onChanged: (inputAddr) {
|
||||
// setState(() {
|
||||
// business.locationAddress =
|
||||
// inputAddr;
|
||||
// });
|
||||
// },
|
||||
// onTapOutside:
|
||||
// (PointerDownEvent event) {
|
||||
// FocusScope.of(context).unfocus();
|
||||
// },
|
||||
// decoration: const InputDecoration(
|
||||
// labelText:
|
||||
// 'Location Address (optional)',
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, bottom: 8.0),
|
||||
child: TextFormField(
|
||||
controller: _locationNameController,
|
||||
onChanged: (inputName) {
|
||||
setState(() {
|
||||
business.locationName = inputName;
|
||||
});
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Location Name (optional)',
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, bottom: 16.0),
|
||||
child: TextFormField(
|
||||
controller: _locationAddressController,
|
||||
onChanged: (inputAddr) {
|
||||
setState(() {
|
||||
business.locationAddress = inputAddr;
|
||||
});
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Location Address (optional)',
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, bottom: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Type of Business',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
DropdownMenu<BusinessType>(
|
||||
initialSelection: business.type,
|
||||
controller: businessTypeController,
|
||||
label: const Text('Business Type'),
|
||||
dropdownMenuEntries: const [
|
||||
DropdownMenuEntry(
|
||||
value: BusinessType.food,
|
||||
label: 'Food Related'),
|
||||
DropdownMenuEntry(
|
||||
value: BusinessType.shop,
|
||||
label: 'Shop'),
|
||||
DropdownMenuEntry(
|
||||
value: BusinessType.outdoors,
|
||||
label: 'Outdoors'),
|
||||
DropdownMenuEntry(
|
||||
value: BusinessType.manufacturing,
|
||||
label: 'Manufacturing'),
|
||||
DropdownMenuEntry(
|
||||
value: BusinessType.other,
|
||||
label: 'Other'),
|
||||
],
|
||||
onSelected: (inputType) {
|
||||
business.type = inputType!;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, bottom: 8.0),
|
||||
child: TextFormField(
|
||||
controller: _contactNameController,
|
||||
onSaved: (inputText) {
|
||||
business.contactName = inputText!;
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText:
|
||||
'Contact Information Name (optional)',
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, bottom: 8.0),
|
||||
child: TextFormField(
|
||||
controller: _contactPhoneController,
|
||||
inputFormatters: [PhoneFormatter()],
|
||||
keyboardType: TextInputType.phone,
|
||||
onSaved: (inputText) {
|
||||
business.contactPhone = inputText!;
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Contact Phone # (optional)',
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, bottom: 8.0),
|
||||
child: TextFormField(
|
||||
controller: _contactEmailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onSaved: (inputText) {
|
||||
business.contactEmail = inputText!;
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Contact Email (optional)',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null) {
|
||||
if (value.isEmpty) {
|
||||
return null;
|
||||
} else if (!RegExp(
|
||||
r'^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$')
|
||||
.hasMatch(value)) {
|
||||
return 'Enter a valid Email';
|
||||
} else if (value.characters.length > 50) {
|
||||
return 'Contact Email cannot be longer than 50 characters';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, bottom: 8.0),
|
||||
child: TextFormField(
|
||||
controller: _notesController,
|
||||
maxLength: 300,
|
||||
onSaved: (inputText) {
|
||||
business.notes = inputText!;
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Other Notes (optional)',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 75,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePop(bool didPop) {
|
||||
if (!didPop) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
width: 400,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text('Please wait for it to save.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PhoneFormatter extends TextInputFormatter {
|
||||
String phoneFormat(value) {
|
||||
String input = value.replaceAll(RegExp(r'[\D]'), '');
|
||||
String phoneFormatted = input.isNotEmpty
|
||||
? '(${input.substring(0, input.length >= 3 ? 3 : null)}${input.length >= 4 ? ') ' : ''}${input.length > 3 ? input.substring(3, input.length >= 5 ? 6 : null) + (input.length >= 7 ? '-${input.substring(6, input.length >= 10 ? 10 : null)}' : '') : ''}'
|
||||
: input;
|
||||
return phoneFormatted;
|
||||
}
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
String text = newValue.text;
|
||||
|
||||
if (newValue.selection.baseOffset == 0) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
return newValue.copyWith(
|
||||
text: phoneFormat(text),
|
||||
selection: TextSelection.collapsed(offset: phoneFormat(text).length));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fbla_ui/api_logic.dart';
|
||||
import 'package:fbla_ui/shared.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
bool isDataTypesFiltered = false;
|
||||
bool isBusinessesFiltered = true;
|
||||
bool _isLoading = false;
|
||||
|
||||
class ExportData extends StatefulWidget {
|
||||
final List<Business> businesses;
|
||||
|
||||
const ExportData({super.key, required this.businesses});
|
||||
|
||||
@override
|
||||
State<ExportData> createState() => _ExportDataState();
|
||||
}
|
||||
|
||||
class _ExportDataState extends State<ExportData> {
|
||||
late Future refreshBusinessDataFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
refreshBusinessDataFuture = fetchBusinessData();
|
||||
_isLoading = false;
|
||||
selectedBusinesses = <Business>{};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
floatingActionButton: _FAB(businesses: widget.businesses),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
forceMaterialTransparency: false,
|
||||
title: const Text('Export Data'),
|
||||
toolbarHeight: 70,
|
||||
pinned: true,
|
||||
centerTitle: true,
|
||||
expandedHeight: 120,
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
// DO NOT MOVE TO SEPARATE WIDGET, setState is needed in main tree
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.background,
|
||||
title: const Text('Data Types'),
|
||||
content: const SizedBox(
|
||||
width: 400,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Data Columns you would like to show on the datasheet'),
|
||||
FilterDataTypeChips(),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Reset'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
dataTypeFilters = <DataType>{};
|
||||
selectedDataTypes = <DataType>{};
|
||||
isDataTypesFiltered = false;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () {
|
||||
selectedDataTypes = Set.from(dataTypeFilters);
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Apply'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
selectedDataTypes =
|
||||
sortDataTypes(selectedDataTypes);
|
||||
dataTypeFilters =
|
||||
Set.from(selectedDataTypes);
|
||||
if (dataTypeFilters.isNotEmpty) {
|
||||
isDataTypesFiltered = true;
|
||||
} else {
|
||||
isDataTypesFiltered = false;
|
||||
}
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(0),
|
||||
child: SizedBox(
|
||||
height: 70,
|
||||
width: 1000,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: TextField(
|
||||
onChanged: (query) {
|
||||
setState(() {
|
||||
searchFilter = query;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Search',
|
||||
hintText: 'Search',
|
||||
prefixIcon: const Padding(
|
||||
padding: EdgeInsets.only(left: 8.0),
|
||||
child: Icon(Icons.search),
|
||||
),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(25.0)),
|
||||
),
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.filter_list,
|
||||
color: isFiltered
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onBackground),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
// DO NOT MOVE TO SEPARATE WIDGET, setState is needed in main tree
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.background,
|
||||
title: const Text('Filter Options'),
|
||||
content: const FilterChips(),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Reset'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
filters = <BusinessType>{};
|
||||
selectedChips = <BusinessType>{};
|
||||
isFiltered = false;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () {
|
||||
selectedChips = Set.from(filters);
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Apply'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
filters = selectedChips;
|
||||
if (filters.isNotEmpty) {
|
||||
isFiltered = true;
|
||||
} else {
|
||||
isFiltered = false;
|
||||
}
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BusinessDisplayPanel(
|
||||
businesses: widget.businesses,
|
||||
widescreen: MediaQuery.sizeOf(context).width >= 1000,
|
||||
selectable: true),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FAB extends StatefulWidget {
|
||||
final List<Business> businesses;
|
||||
|
||||
const _FAB({required this.businesses});
|
||||
|
||||
@override
|
||||
State<_FAB> createState() => _FABState();
|
||||
}
|
||||
|
||||
class _FABState extends State<_FAB> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FloatingActionButton(
|
||||
child: _isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3.0,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.save_alt),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
DateTime dateTime = DateTime.now();
|
||||
String minute = '00';
|
||||
if (dateTime.minute.toString().length < 2) {
|
||||
minute = '0${dateTime.minute}';
|
||||
} else {
|
||||
minute = dateTime.minute.toString();
|
||||
}
|
||||
|
||||
String time = dateTime.hour <= 13
|
||||
? '${dateTime.hour}:${minute}AM'
|
||||
: '${dateTime.hour - 12}:${minute}PM';
|
||||
String fileName =
|
||||
'Business Data - ${dateTime.month}-${dateTime.day}-${dateTime.year} $time.pdf';
|
||||
|
||||
final pdf = pw.Document();
|
||||
var svgBytes = await marinoDevLogo();
|
||||
selectedDataTypes = sortDataTypes(selectedDataTypes);
|
||||
|
||||
List<pw.Padding> headers = [];
|
||||
if (selectedDataTypes.isEmpty) {
|
||||
dataTypeFilters.addAll(DataType.values);
|
||||
} else {
|
||||
for (var filter in selectedDataTypes) {
|
||||
dataTypeFilters.add(filter);
|
||||
}
|
||||
}
|
||||
for (var filter in dataTypeFilters) {
|
||||
headers.add(pw.Padding(
|
||||
child: pw.Text(dataTypeFriendly[filter]!,
|
||||
style: const pw.TextStyle(fontSize: 10)),
|
||||
padding: const pw.EdgeInsets.all(4.0)));
|
||||
}
|
||||
|
||||
List<pw.TableRow> rows = [];
|
||||
if (selectedBusinesses.isEmpty) {
|
||||
selectedBusinesses.addAll(widget.businesses);
|
||||
isBusinessesFiltered = false;
|
||||
} else {
|
||||
isBusinessesFiltered = true;
|
||||
}
|
||||
|
||||
double remainingSpace = 744;
|
||||
|
||||
if (dataTypeFilters.contains(DataType.logo)) {
|
||||
remainingSpace -= 32;
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.type)) {
|
||||
remainingSpace -= 56;
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.contactName)) {
|
||||
remainingSpace -= 72;
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.contactPhone)) {
|
||||
remainingSpace -= 76;
|
||||
}
|
||||
|
||||
double nameWidth = 0;
|
||||
double websiteWidth = 0;
|
||||
double contactEmailWidth = 0;
|
||||
double notesWidth = 0;
|
||||
double descriptionWidth = 0;
|
||||
if (dataTypeFilters.contains(DataType.name)) {
|
||||
nameWidth = (remainingSpace / 6);
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.website)) {
|
||||
websiteWidth = (remainingSpace / 5);
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.contactEmail)) {
|
||||
contactEmailWidth = (remainingSpace / 5);
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.notes)) {
|
||||
notesWidth = (remainingSpace / 7);
|
||||
}
|
||||
remainingSpace -=
|
||||
(nameWidth + websiteWidth + contactEmailWidth + notesWidth);
|
||||
if (dataTypeFilters.contains(DataType.description)) {
|
||||
descriptionWidth = remainingSpace;
|
||||
}
|
||||
|
||||
Map<int, pw.TableColumnWidth> columnWidths = {};
|
||||
|
||||
int columnNum = -1;
|
||||
for (var dataType in dataTypeFilters) {
|
||||
pw.TableColumnWidth width = const pw.FixedColumnWidth(0);
|
||||
if (dataType == DataType.logo) {
|
||||
width = const pw.FixedColumnWidth(32);
|
||||
columnNum++;
|
||||
} else if (dataType == DataType.name) {
|
||||
width = pw.FixedColumnWidth(nameWidth);
|
||||
columnNum++;
|
||||
} else if (dataType == DataType.description) {
|
||||
width = pw.FixedColumnWidth(descriptionWidth);
|
||||
columnNum++;
|
||||
} else if (dataType == DataType.type) {
|
||||
width = const pw.FixedColumnWidth(56);
|
||||
columnNum++;
|
||||
} else if (dataType == DataType.website) {
|
||||
width = pw.FixedColumnWidth(websiteWidth);
|
||||
columnNum++;
|
||||
} else if (dataType == DataType.contactName) {
|
||||
width = const pw.FixedColumnWidth(72);
|
||||
columnNum++;
|
||||
} else if (dataType == DataType.contactEmail) {
|
||||
width = pw.FixedColumnWidth(contactEmailWidth);
|
||||
columnNum++;
|
||||
} else if (dataType == DataType.contactPhone) {
|
||||
width = const pw.FixedColumnWidth(76);
|
||||
columnNum++;
|
||||
} else if (dataType == DataType.notes) {
|
||||
width = pw.FixedColumnWidth(notesWidth);
|
||||
columnNum++;
|
||||
}
|
||||
|
||||
columnWidths.addAll({columnNum: width});
|
||||
}
|
||||
|
||||
for (var business in selectedBusinesses) {
|
||||
List<pw.Padding> data = [];
|
||||
bool hasLogo = false;
|
||||
Uint8List businessLogo = Uint8List(0);
|
||||
if (dataTypeFilters.contains(DataType.logo)) {
|
||||
try {
|
||||
var apiLogo = await getLogo(business.id);
|
||||
if (apiLogo.runtimeType != String) {
|
||||
businessLogo = apiLogo;
|
||||
hasLogo = true;
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Logo not available! $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.name)) {
|
||||
data.add(pw.Padding(
|
||||
child: pw.Text(
|
||||
business.name,
|
||||
// style: const pw.TextStyle(fontSize: 10)
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(4.0)));
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.description)) {
|
||||
pw.TextStyle style = const pw.TextStyle(fontSize: 9);
|
||||
if (business.description.length >= 200) {
|
||||
style = const pw.TextStyle(fontSize: 8);
|
||||
}
|
||||
if (business.description.length >= 400) {
|
||||
style = const pw.TextStyle(fontSize: 7);
|
||||
}
|
||||
data.add(pw.Padding(
|
||||
child: pw.Text(
|
||||
business.description,
|
||||
style: style,
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(4.0)));
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.type)) {
|
||||
data.add(pw.Padding(
|
||||
child: pw.Text(
|
||||
business.type.name,
|
||||
// style: const pw.TextStyle(fontSize: 10)
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(4.0)));
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.website)) {
|
||||
data.add(pw.Padding(
|
||||
child: pw.Text(
|
||||
business.website,
|
||||
// style: const pw.TextStyle(fontSize: 10)
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(4.0)));
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.contactName)) {
|
||||
data.add(pw.Padding(
|
||||
child: pw.Text(
|
||||
business.contactName,
|
||||
// style: const pw.TextStyle(fontSize: 10)
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(4.0)));
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.contactEmail)) {
|
||||
data.add(pw.Padding(
|
||||
child: pw.Text(
|
||||
business.contactEmail,
|
||||
// style: const pw.TextStyle(fontSize: 10)
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(4.0)));
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.contactPhone)) {
|
||||
data.add(pw.Padding(
|
||||
child: pw.Text(
|
||||
business.contactPhone,
|
||||
// style: const pw.TextStyle(fontSize: 10)
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(4.0)));
|
||||
}
|
||||
if (dataTypeFilters.contains(DataType.notes)) {
|
||||
pw.TextStyle style = const pw.TextStyle(fontSize: 9);
|
||||
if (business.description.length >= 200) {
|
||||
style = const pw.TextStyle(fontSize: 8);
|
||||
}
|
||||
data.add(pw.Padding(
|
||||
child: pw.Text(business.notes, style: style),
|
||||
padding: const pw.EdgeInsets.all(4.0)));
|
||||
}
|
||||
|
||||
if (dataTypeFilters.contains(DataType.logo)) {
|
||||
if (hasLogo) {
|
||||
rows.add(pw.TableRow(
|
||||
children: [
|
||||
pw.Padding(
|
||||
child: pw.ClipRRect(
|
||||
child: pw.Image(pw.MemoryImage(businessLogo),
|
||||
height: 24, width: 24),
|
||||
horizontalRadius: 4,
|
||||
verticalRadius: 4),
|
||||
padding: const pw.EdgeInsets.all(4.0)),
|
||||
...data
|
||||
],
|
||||
));
|
||||
} else {
|
||||
rows.add(pw.TableRow(
|
||||
children: [
|
||||
pw.Padding(
|
||||
child: getPwIconFromType(
|
||||
business.type, 24, PdfColors.black),
|
||||
padding: const pw.EdgeInsets.all(4.0)),
|
||||
...data
|
||||
],
|
||||
));
|
||||
}
|
||||
} else {
|
||||
rows.add(pw.TableRow(
|
||||
children: data,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
var themeIcon = pw.ThemeData.withFont(
|
||||
base: await PdfGoogleFonts.notoSansDisplayMedium(),
|
||||
icons: await PdfGoogleFonts.materialIcons());
|
||||
|
||||
var finaltheme = themeIcon.copyWith(
|
||||
defaultTextStyle: const pw.TextStyle(fontSize: 9),
|
||||
);
|
||||
pdf.addPage(pw.MultiPage(
|
||||
theme: finaltheme,
|
||||
// theme: pw.ThemeData(
|
||||
// tableCell: const pw.TextStyle(fontSize: 4),
|
||||
// defaultTextStyle: const pw.TextStyle(fontSize: 4),
|
||||
// header0: const pw.TextStyle(fontSize: 4),
|
||||
// paragraphStyle: const pw.TextStyle(fontSize: 4),
|
||||
// ),
|
||||
// theme: pw.ThemeData.withFont(
|
||||
// icons: await PdfGoogleFonts.materialIcons()),
|
||||
pageFormat: PdfPageFormat.letter,
|
||||
orientation: pw.PageOrientation.landscape,
|
||||
margin: const pw.EdgeInsets.all(24),
|
||||
build: (pw.Context context) {
|
||||
return [
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.SvgImage(svg: utf8.decode(svgBytes), height: 40),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8.0),
|
||||
child: pw.Text('Business Datasheet',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: pw.FontWeight.bold)),
|
||||
),
|
||||
pw.Text(
|
||||
'Generated on ${dateTime.month}/${dateTime.day}/${dateTime.year} at $time',
|
||||
style: const pw.TextStyle(fontSize: 12),
|
||||
textAlign: pw.TextAlign.right),
|
||||
//
|
||||
]),
|
||||
pw.Table(
|
||||
columnWidths: columnWidths,
|
||||
// defaultColumnWidth: pw.IntrinsicColumnWidth(),
|
||||
border: const pw.TableBorder(
|
||||
bottom: pw.BorderSide(),
|
||||
left: pw.BorderSide(),
|
||||
right: pw.BorderSide(),
|
||||
top: pw.BorderSide(),
|
||||
horizontalInside: pw.BorderSide(),
|
||||
verticalInside: pw.BorderSide()),
|
||||
children: [
|
||||
pw.TableRow(
|
||||
decoration:
|
||||
const pw.BoxDecoration(color: PdfColors.blue400),
|
||||
children: headers,
|
||||
repeat: true,
|
||||
),
|
||||
...rows,
|
||||
]),
|
||||
];
|
||||
}));
|
||||
|
||||
Uint8List pdfBytes = await pdf.save();
|
||||
|
||||
if (kIsWeb) {
|
||||
await Printing.sharePdf(
|
||||
bytes: await pdf.save(),
|
||||
filename: fileName,
|
||||
);
|
||||
} else {
|
||||
var dir = await getTemporaryDirectory();
|
||||
var tempDir = dir.path;
|
||||
|
||||
File pdfFile = File('$tempDir/$fileName');
|
||||
pdfFile.writeAsBytesSync(pdfBytes);
|
||||
|
||||
OpenFilex.open(pdfFile.path);
|
||||
}
|
||||
|
||||
if (!isBusinessesFiltered) {
|
||||
selectedBusinesses = <Business>{};
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Error generating PDF! $e'),
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:fbla_ui/api_logic.dart';
|
||||
import 'package:fbla_ui/home.dart';
|
||||
import 'package:fbla_ui/shared.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
bool loggedIn = false;
|
||||
|
||||
class SignInPage extends StatefulWidget {
|
||||
final Callback refreshAccount;
|
||||
|
||||
const SignInPage({super.key, required this.refreshAccount});
|
||||
|
||||
@override
|
||||
State<SignInPage> createState() => _SignInPageState();
|
||||
}
|
||||
|
||||
class _SignInPageState extends State<SignInPage> {
|
||||
final _signInKey = GlobalKey<FormState>();
|
||||
final TextEditingController _usernameController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
String username = '';
|
||||
String password = '';
|
||||
bool obscurePassword = true;
|
||||
bool rememberMe = false;
|
||||
bool _isloading = false;
|
||||
String? errorMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Admin Sign In'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Form(
|
||||
key: _signInKey,
|
||||
child: Center(
|
||||
heightFactor: 1.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 50, 12, 50),
|
||||
height: 475,
|
||||
width: 500,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Center(
|
||||
child: Text(
|
||||
'Admin Sign In',
|
||||
style: TextStyle(
|
||||
fontSize: 30, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextFormField(
|
||||
onChanged: (value) {
|
||||
username = value.trim();
|
||||
},
|
||||
controller: _usernameController,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
labelText: 'Username',
|
||||
border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextFormField(
|
||||
onChanged: (value) {
|
||||
password = value.trim();
|
||||
},
|
||||
onFieldSubmitted: (value) async {
|
||||
password = value.trim();
|
||||
setState(() {
|
||||
errorMessage = null;
|
||||
_isloading = true;
|
||||
});
|
||||
jwt = await signIn(username, password).timeout(
|
||||
const Duration(seconds: 20), onTimeout: () {
|
||||
_isloading = false;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content:
|
||||
Text('Could not Sign in (timeout)!')),
|
||||
);
|
||||
});
|
||||
if (!jwt.contains('Error:')) {
|
||||
final SharedPreferences prefs =
|
||||
await SharedPreferences.getInstance();
|
||||
await prefs.setString('username', username);
|
||||
await prefs.setString('password', password);
|
||||
await prefs.setBool('rememberMe', rememberMe);
|
||||
loggedIn = true;
|
||||
widget.refreshAccount();
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
setState(() {
|
||||
errorMessage = 'Invalid Username/Password';
|
||||
_isloading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
controller: _passwordController,
|
||||
autocorrect: false,
|
||||
obscureText: obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.fingerprint),
|
||||
labelText: 'Password',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
obscurePassword = !obscurePassword;
|
||||
});
|
||||
},
|
||||
icon: obscurePassword
|
||||
? const Icon(Icons.visibility_off)
|
||||
: const Icon(Icons.visibility),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (errorMessage != null)
|
||||
Text(
|
||||
errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: rememberMe,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
rememberMe = value!;
|
||||
});
|
||||
},
|
||||
title: const Text('Remember me'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
// padding: const EdgeInsets.only(left: 20.0, right: 20.0, top: 12.0, bottom: 12.0),
|
||||
),
|
||||
icon: _isloading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
))
|
||||
: const Icon(Icons.done, color: Colors.white),
|
||||
label: const Text('Sign In',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
errorMessage = null;
|
||||
_isloading = true;
|
||||
});
|
||||
jwt = await signIn(username, password).timeout(
|
||||
const Duration(seconds: 20), onTimeout: () {
|
||||
_isloading = false;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content:
|
||||
Text('Could not Sign in (timeout)!')),
|
||||
);
|
||||
});
|
||||
if (!jwt.contains('Error:')) {
|
||||
final SharedPreferences prefs =
|
||||
await SharedPreferences.getInstance();
|
||||
await prefs.setString('username', username);
|
||||
await prefs.setString('password', password);
|
||||
await prefs.setBool('rememberMe', rememberMe);
|
||||
loggedIn = true;
|
||||
widget.refreshAccount();
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
setState(() {
|
||||
errorMessage = 'Invalid Username/Password';
|
||||
_isloading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fbla_ui/api_logic.dart';
|
||||
import 'package:fbla_ui/pages/business_detail.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
late String jwt;
|
||||
Set<BusinessType> filters = <BusinessType>{};
|
||||
Set<BusinessType> selectedChips = <BusinessType>{};
|
||||
String searchFilter = '';
|
||||
bool isFiltered = false;
|
||||
Set<Business> selectedBusinesses = <Business>{};
|
||||
Set<DataType> selectedDataTypes = <DataType>{};
|
||||
Set<DataType> dataTypeFilters = <DataType>{};
|
||||
|
||||
enum DataType {
|
||||
logo,
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
website,
|
||||
contactName,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
notes,
|
||||
}
|
||||
|
||||
Map<DataType, int> dataTypeValues = {
|
||||
DataType.logo: 0,
|
||||
DataType.name: 1,
|
||||
DataType.description: 2,
|
||||
DataType.type: 3,
|
||||
DataType.website: 4,
|
||||
DataType.contactName: 5,
|
||||
DataType.contactEmail: 6,
|
||||
DataType.contactPhone: 7,
|
||||
DataType.notes: 8
|
||||
};
|
||||
|
||||
Map<DataType, String> dataTypeFriendly = {
|
||||
DataType.logo: 'Logo',
|
||||
DataType.name: 'Name',
|
||||
DataType.description: 'Description',
|
||||
DataType.type: 'Type',
|
||||
DataType.website: 'Website',
|
||||
DataType.contactName: 'Contact Name',
|
||||
DataType.contactEmail: 'Contact Email',
|
||||
DataType.contactPhone: 'Contact Phone',
|
||||
DataType.notes: 'Notes'
|
||||
};
|
||||
|
||||
Set<DataType> sortDataTypes(Set<DataType> set) {
|
||||
List<DataType> list = set.toList();
|
||||
list.sort((a, b) {
|
||||
return dataTypeValues[a]!.compareTo(dataTypeValues[b]!);
|
||||
});
|
||||
set = list.toSet();
|
||||
return set;
|
||||
}
|
||||
|
||||
enum BusinessType {
|
||||
food,
|
||||
shop,
|
||||
outdoors,
|
||||
manufacturing,
|
||||
other,
|
||||
}
|
||||
|
||||
class Business {
|
||||
int id;
|
||||
String name;
|
||||
String description;
|
||||
BusinessType type;
|
||||
String website;
|
||||
String contactName;
|
||||
String contactEmail;
|
||||
String contactPhone;
|
||||
String notes;
|
||||
String locationName;
|
||||
String locationAddress;
|
||||
|
||||
Business({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.type,
|
||||
required this.website,
|
||||
required this.contactName,
|
||||
required this.contactEmail,
|
||||
required this.contactPhone,
|
||||
required this.notes,
|
||||
required this.locationName,
|
||||
required this.locationAddress,
|
||||
});
|
||||
|
||||
factory Business.fromJson(Map<String, dynamic> json) {
|
||||
bool typeValid = true;
|
||||
try {
|
||||
BusinessType.values.byName(json['type']);
|
||||
} catch (e) {
|
||||
typeValid = false;
|
||||
}
|
||||
return Business(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
type: typeValid
|
||||
? BusinessType.values.byName(json['type'])
|
||||
: BusinessType.other,
|
||||
website: json['website'],
|
||||
contactName: json['contactName'],
|
||||
contactEmail: json['contactEmail'],
|
||||
contactPhone: json['contactPhone'],
|
||||
notes: json['notes'],
|
||||
locationName: json['locationName'],
|
||||
locationAddress: json['locationAddress'],
|
||||
);
|
||||
}
|
||||
|
||||
factory Business.copy(Business input) {
|
||||
return Business(
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
type: input.type,
|
||||
website: input.website,
|
||||
contactName: input.contactName,
|
||||
contactEmail: input.contactEmail,
|
||||
contactPhone: input.contactPhone,
|
||||
notes: input.notes,
|
||||
locationName: input.locationName,
|
||||
locationAddress: input.locationAddress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<BusinessType, List<Business>> groupBusinesses(List<Business> businesses) {
|
||||
Map<BusinessType, List<Business>> groupedBusinesses =
|
||||
groupBy<Business, BusinessType>(businesses, (business) => business.type);
|
||||
|
||||
return groupedBusinesses;
|
||||
}
|
||||
|
||||
Icon getIconFromType(BusinessType type, double size, Color color) {
|
||||
switch (type) {
|
||||
case BusinessType.food:
|
||||
return Icon(
|
||||
Icons.restaurant,
|
||||
size: size,
|
||||
color: color,
|
||||
);
|
||||
case BusinessType.shop:
|
||||
return Icon(
|
||||
Icons.store,
|
||||
size: size,
|
||||
color: color,
|
||||
);
|
||||
case BusinessType.outdoors:
|
||||
return Icon(
|
||||
Icons.forest,
|
||||
size: size,
|
||||
color: color,
|
||||
);
|
||||
case BusinessType.manufacturing:
|
||||
return Icon(
|
||||
Icons.factory,
|
||||
size: size,
|
||||
color: color,
|
||||
);
|
||||
case BusinessType.other:
|
||||
return Icon(
|
||||
Icons.business,
|
||||
size: size,
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pw.Icon getPwIconFromType(BusinessType type, double size, PdfColor color) {
|
||||
switch (type) {
|
||||
case BusinessType.food:
|
||||
return pw.Icon(const pw.IconData(0xe56c), size: size, color: color);
|
||||
case BusinessType.shop:
|
||||
return pw.Icon(const pw.IconData(0xea12), size: size, color: color);
|
||||
case BusinessType.outdoors:
|
||||
return pw.Icon(const pw.IconData(0xea99), size: size, color: color);
|
||||
case BusinessType.manufacturing:
|
||||
return pw.Icon(const pw.IconData(0xebbc), size: size, color: color);
|
||||
case BusinessType.other:
|
||||
return pw.Icon(const pw.IconData(0xe0af), size: size, color: color);
|
||||
}
|
||||
}
|
||||
|
||||
Text getNameFromType(BusinessType type, Color color) {
|
||||
switch (type) {
|
||||
case BusinessType.food:
|
||||
return Text('Food Related', style: TextStyle(color: color));
|
||||
case BusinessType.shop:
|
||||
return Text('Shops', style: TextStyle(color: color));
|
||||
case BusinessType.outdoors:
|
||||
return Text('Outdoors', style: TextStyle(color: color));
|
||||
case BusinessType.manufacturing:
|
||||
return Text('Manufacturing', style: TextStyle(color: color));
|
||||
case BusinessType.other:
|
||||
return Text('Other', style: TextStyle(color: color));
|
||||
}
|
||||
}
|
||||
|
||||
Icon getIconFromThemeMode(ThemeMode theme) {
|
||||
switch (theme) {
|
||||
case ThemeMode.dark:
|
||||
return const Icon(Icons.dark_mode);
|
||||
case ThemeMode.light:
|
||||
return const Icon(Icons.light_mode);
|
||||
case ThemeMode.system:
|
||||
return const Icon(Icons.brightness_4);
|
||||
}
|
||||
}
|
||||
|
||||
class BusinessDisplayPanel extends StatefulWidget {
|
||||
final List<Business> businesses;
|
||||
final bool widescreen;
|
||||
final bool selectable;
|
||||
|
||||
const BusinessDisplayPanel(
|
||||
{super.key,
|
||||
required this.businesses,
|
||||
required this.widescreen,
|
||||
required this.selectable});
|
||||
|
||||
@override
|
||||
State<BusinessDisplayPanel> createState() => _BusinessDisplayPanelState();
|
||||
}
|
||||
|
||||
class _BusinessDisplayPanelState extends State<BusinessDisplayPanel> {
|
||||
Set<Business> selectedBusinesses = <Business>{};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<BusinessHeader> headers = [];
|
||||
List<Business> filteredBusinesses = [];
|
||||
for (var business in widget.businesses) {
|
||||
if (business.name.toLowerCase().contains(searchFilter.toLowerCase())) {
|
||||
filteredBusinesses.add(business);
|
||||
}
|
||||
}
|
||||
var groupedBusinesses = groupBusinesses(filteredBusinesses);
|
||||
var businessTypes = groupedBusinesses.keys.toList();
|
||||
|
||||
for (var i = 0; i < businessTypes.length; i++) {
|
||||
if (filters.contains(businessTypes[i])) {
|
||||
isFiltered = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFiltered) {
|
||||
for (var i = 0; i < businessTypes.length; i++) {
|
||||
if (filters.contains(businessTypes[i])) {
|
||||
headers.add(BusinessHeader(
|
||||
type: businessTypes[i],
|
||||
widescreen: widget.widescreen,
|
||||
selectable: widget.selectable,
|
||||
selectedBusinesses: selectedBusinesses,
|
||||
businesses: groupedBusinesses[businessTypes[i]]!));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (var i = 0; i < businessTypes.length; i++) {
|
||||
headers.add(BusinessHeader(
|
||||
type: businessTypes[i],
|
||||
widescreen: widget.widescreen,
|
||||
selectable: widget.selectable,
|
||||
selectedBusinesses: selectedBusinesses,
|
||||
businesses: groupedBusinesses[businessTypes[i]]!));
|
||||
}
|
||||
}
|
||||
headers.sort((a, b) => a.type.index.compareTo(b.type.index));
|
||||
return MultiSliver(children: headers);
|
||||
}
|
||||
}
|
||||
|
||||
class BusinessHeader extends StatefulWidget {
|
||||
final BusinessType type;
|
||||
final List<Business> businesses;
|
||||
final Set<Business> selectedBusinesses;
|
||||
final bool widescreen;
|
||||
final bool selectable;
|
||||
|
||||
const BusinessHeader({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.businesses,
|
||||
required this.selectedBusinesses,
|
||||
required this.widescreen,
|
||||
required this.selectable,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BusinessHeader> createState() => _BusinessHeaderState();
|
||||
}
|
||||
|
||||
class _BusinessHeaderState extends State<BusinessHeader> {
|
||||
refresh() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverStickyHeader(
|
||||
header: Container(
|
||||
height: 55.0,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _getHeaderRow(widget.selectable),
|
||||
),
|
||||
sliver: _getChildSliver(
|
||||
widget.businesses, widget.widescreen, widget.selectable),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getHeaderRow(bool selectable) {
|
||||
if (selectable) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0, right: 12.0),
|
||||
child: getIconFromType(
|
||||
widget.type, 24, Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
getNameFromType(
|
||||
widget.type, Theme.of(context).colorScheme.onPrimary),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12.0),
|
||||
child: Checkbox(
|
||||
checkColor: Theme.of(context).colorScheme.primary,
|
||||
activeColor: Theme.of(context).colorScheme.onPrimary,
|
||||
value: selectedBusinesses.containsAll(widget.businesses),
|
||||
onChanged: (value) {
|
||||
if (value!) {
|
||||
setState(() {
|
||||
selectedBusinesses.addAll(widget.businesses);
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
selectedBusinesses.removeAll(widget.businesses);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0, right: 12.0),
|
||||
child: getIconFromType(
|
||||
widget.type, 24, Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
getNameFromType(widget.type, Theme.of(context).colorScheme.onPrimary),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _getChildSliver(
|
||||
List<Business> businesses, bool widescreen, bool selectable) {
|
||||
if (widescreen) {
|
||||
return SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
mainAxisExtent: 250.0,
|
||||
maxCrossAxisExtent: 400.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
// childAspectRatio: 4.0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: businesses.length,
|
||||
(BuildContext context, int index) {
|
||||
return BusinessCard(
|
||||
business: businesses[index],
|
||||
selectable: selectable,
|
||||
widescreen: widescreen,
|
||||
callback: refresh,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: businesses.length,
|
||||
(BuildContext context, int index) {
|
||||
return BusinessCard(
|
||||
business: businesses[index],
|
||||
selectable: selectable,
|
||||
widescreen: widescreen,
|
||||
callback: refresh,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BusinessCard extends StatefulWidget {
|
||||
final Business business;
|
||||
final bool widescreen;
|
||||
final bool selectable;
|
||||
final Function callback;
|
||||
|
||||
const BusinessCard(
|
||||
{super.key,
|
||||
required this.business,
|
||||
required this.widescreen,
|
||||
required this.selectable,
|
||||
required this.callback});
|
||||
|
||||
@override
|
||||
State<BusinessCard> createState() => _BusinessCardState();
|
||||
}
|
||||
|
||||
class _BusinessCardState extends State<BusinessCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.widescreen) {
|
||||
return _businessTile(widget.business, widget.selectable);
|
||||
} else {
|
||||
return _businessListItem(
|
||||
widget.business, widget.selectable, widget.callback);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _businessTile(Business business, bool selectable) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => BusinessDetail(inputBusiness: business)));
|
||||
},
|
||||
child: Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_getTileRow(business, selectable, widget.callback),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
business.description,
|
||||
maxLines: selectable ? 7 : 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: !selectable
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.link),
|
||||
onPressed: () {
|
||||
launchUrl(
|
||||
Uri.parse('https://${business.website}'));
|
||||
},
|
||||
),
|
||||
if (business.locationName.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.location_on),
|
||||
onPressed: () {
|
||||
launchUrl(Uri.parse(Uri.encodeFull(
|
||||
'https://www.google.com/maps/search/?api=1&query=${business.locationName}')));
|
||||
},
|
||||
),
|
||||
if (business.contactPhone.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.phone),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.background,
|
||||
title: Text(
|
||||
'Contact ${business.contactName}?'),
|
||||
content: Text(
|
||||
'Would you like to call or text ${business.contactName}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Text'),
|
||||
onPressed: () {
|
||||
launchUrl(Uri.parse(
|
||||
'sms:${business.contactPhone}'));
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
TextButton(
|
||||
child: const Text('Call'),
|
||||
onPressed: () async {
|
||||
launchUrl(Uri.parse(
|
||||
'tel:${business.contactPhone}'));
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
if (business.contactEmail.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.email),
|
||||
onPressed: () {
|
||||
launchUrl(Uri.parse(
|
||||
'mailto:${business.contactEmail}'));
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: null),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getTileRow(Business business, bool selectable, Function callback) {
|
||||
if (selectable) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
child: Image.network('$apiAddress/logos/${business.id}',
|
||||
height: 48, width: 48, errorBuilder: (BuildContext context,
|
||||
Object exception, StackTrace? stackTrace) {
|
||||
return getIconFromType(
|
||||
business.type, 48, Theme.of(context).colorScheme.onSurface);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
business.name,
|
||||
style:
|
||||
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 24.0),
|
||||
child: _checkbox(callback),
|
||||
)
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
child: Image.network('$apiAddress/logos/${business.id}',
|
||||
height: 48, width: 48, errorBuilder: (BuildContext context,
|
||||
Object exception, StackTrace? stackTrace) {
|
||||
return getIconFromType(business.type, 48,
|
||||
Theme.of(context).colorScheme.onSurface);
|
||||
}),
|
||||
)),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
business.name,
|
||||
style:
|
||||
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _businessListItem(
|
||||
Business business, bool selectable, Function callback) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3.0),
|
||||
child: Image.network('$apiAddress/logos/${business.id}',
|
||||
height: 24, width: 24, errorBuilder: (BuildContext context,
|
||||
Object exception, StackTrace? stackTrace) {
|
||||
return getIconFromType(
|
||||
business.type, 24, Theme.of(context).colorScheme.onSurface);
|
||||
})),
|
||||
title: Text(business.name),
|
||||
subtitle: Text(business.description,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: _getCheckbox(selectable, callback),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => BusinessDetail(inputBusiness: business)));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _checkbox(Function callback) {
|
||||
return Checkbox(
|
||||
value: selectedBusinesses.contains(widget.business),
|
||||
onChanged: (value) {
|
||||
if (value!) {
|
||||
setState(() {
|
||||
selectedBusinesses.add(widget.business);
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
selectedBusinesses.remove(widget.business);
|
||||
});
|
||||
}
|
||||
callback();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _getCheckbox(bool selectable, Function callback) {
|
||||
if (selectable) {
|
||||
return _checkbox(callback);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FilterChips extends StatefulWidget {
|
||||
const FilterChips({super.key});
|
||||
|
||||
@override
|
||||
State<FilterChips> createState() => _FilterChipsState();
|
||||
}
|
||||
|
||||
class _FilterChipsState extends State<FilterChips> {
|
||||
List<Padding> filterChips() {
|
||||
List<Padding> chips = [];
|
||||
|
||||
for (var type in BusinessType.values) {
|
||||
chips.add(Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0, right: 4.0),
|
||||
child: FilterChip(
|
||||
showCheckmark: false,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
label: Text(type.name),
|
||||
selected: selectedChips.contains(type),
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
selectedChips.add(type);
|
||||
} else {
|
||||
selectedChips.remove(type);
|
||||
}
|
||||
});
|
||||
}),
|
||||
));
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
children: filterChips(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterDataTypeChips extends StatefulWidget {
|
||||
const FilterDataTypeChips({super.key});
|
||||
|
||||
@override
|
||||
State<FilterDataTypeChips> createState() => _FilterDataTypeChipsState();
|
||||
}
|
||||
|
||||
class _FilterDataTypeChipsState extends State<FilterDataTypeChips> {
|
||||
List<Padding> filterDataTypeChips() {
|
||||
List<Padding> chips = [];
|
||||
|
||||
for (var type in DataType.values) {
|
||||
chips.add(Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 3.0, right: 3.0, bottom: 3.0, top: 3.0),
|
||||
// child: ActionChip(
|
||||
// avatar: selectedDataTypes.contains(type) ? Icon(Icons.check_box) : Icon(Icons.check_box_outline_blank),
|
||||
// label: Text(type.name),
|
||||
// onPressed: () {
|
||||
// if (!selectedDataTypes.contains(type)) {
|
||||
// setState(() {
|
||||
// selectedDataTypes.add(type);
|
||||
// });
|
||||
// } else {
|
||||
// setState(() {
|
||||
// selectedDataTypes.remove(type);
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
|
||||
// ),
|
||||
child: FilterChip(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side:
|
||||
BorderSide(color: Theme.of(context).colorScheme.secondary)),
|
||||
label: Text(dataTypeFriendly[type]!),
|
||||
showCheckmark: false,
|
||||
selected: selectedDataTypes.contains(type),
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
selectedDataTypes.add(type);
|
||||
} else {
|
||||
selectedDataTypes.remove(type);
|
||||
}
|
||||
});
|
||||
}),
|
||||
));
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
children: filterDataTypeChips(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user