Notes
Notes - notes.io |
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:async';
import 'package:geolocator/geolocator.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:latlong2/latlong.dart' as ll;
import 'auth_prefs.dart';
import 'api_config.dart';
import 'backend_api.dart';
// ─────────────────────────────────────────────
// Design tokens
// ─────────────────────────────────────────────
class AppColors {
static const primary = Color.fromRGBO(244, 245, 246, 1);
static const bg = Color.fromARGB(255, 255, 253, 253);
static const card = Color.fromRGBO(33, 88, 144, 1);
static const muted = Color.fromARGB(255, 191, 66, 66);
static const navBg = Color(0xFF2158A0);
}
class AppRadii {
static const r12 = BorderRadius.all(Radius.circular(12));
static const r16 = BorderRadius.all(Radius.circular(16));
static const r20 = BorderRadius.all(Radius.circular(20));
}
class AppShadows {
static List<BoxShadow> soft = const [
BoxShadow(
color: Color.fromARGB(31, 84, 35, 35),
blurRadius: 10,
offset: Offset(0, 4),
),
];
}
// ─────────────────────────────────────────────
// CarImage / CarModel / CarFormResult
// ─────────────────────────────────────────────
class CarImage {
final File? mobileFile;
final Uint8List? webBytes;
const CarImage({this.mobileFile, this.webBytes});
bool get isEmpty => mobileFile == null && webBytes == null;
ImageProvider? toImageProvider() {
if (mobileFile != null) return FileImage(mobileFile!);
if (webBytes != null) return MemoryImage(webBytes!);
return null;
}
}
class CarModel {
final String name;
final String plate;
final CarImage image;
const CarModel({
required this.name,
required this.plate,
required this.image,
});
CarModel copyWith({String? name, String? plate, CarImage? image}) => CarModel(
name: name ?? this.name,
plate: plate ?? this.plate,
image: image ?? this.image,
);
}
class CarFormResult {
final String name;
final String plate;
final CarImage image;
const CarFormResult({
required this.name,
required this.plate,
required this.image,
});
CarModel toModel() => CarModel(name: name, plate: plate, image: image);
}
// ─────────────────────────────────────────────
// MagicNavBar — bubble animation
// ─────────────────────────────────────────────
class MagicNavBar extends StatefulWidget {
final int selectedIndex;
final ValueChanged<int> onTap;
const MagicNavBar({
super.key,
required this.selectedIndex,
required this.onTap,
});
@override
State<MagicNavBar> createState() => _MagicNavBarState();
}
class _MagicNavBarState extends State<MagicNavBar>
with SingleTickerProviderStateMixin {
static const _items = [
_NavItem(icon: Icons.directions_car, label: 'Car'),
_NavItem(icon: Icons.location_on, label: 'Map'),
_NavItem(icon: Icons.payment, label: 'Payment'),
_NavItem(icon: Icons.notifications, label: 'Notification'),
];
late AnimationController _ctrl;
late Animation<double> _anim;
int _prev = 0;
@override
void initState() {
super.initState();
_prev = widget.selectedIndex;
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
}
@override
void didUpdateWidget(MagicNavBar old) {
super.didUpdateWidget(old);
if (old.selectedIndex != widget.selectedIndex) {
_prev = old.selectedIndex;
_ctrl.forward(from: 0);
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final itemW = w / _items.length;
const bubbleR = 28.0;
const barH = 64.0;
return SizedBox(
height: barH + bubbleR,
width: w,
child: Stack(
clipBehavior: Clip.none,
children: [
// Bar
Positioned(
bottom: 0,
left: 0,
right: 0,
height: barH,
child: Container(
decoration: const BoxDecoration(
color: AppColors.navBg,
borderRadius: AppRadii.r20,
boxShadow: [
BoxShadow(
color: Color(0x44000000),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: Row(
children: List.generate(_items.length, (i) {
final selected = i == widget.selectedIndex;
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onTap(i),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 10),
AnimatedOpacity(
opacity: selected ? 0.0 : 1.0,
duration: const Duration(milliseconds: 200),
child: Icon(
_items[i].icon,
color: Colors.white70,
size: 22,
),
),
const SizedBox(height: 3),
Text(
_items[i].label,
style: TextStyle(
fontSize: 10,
color: selected
? Colors.white
: Colors.white60,
fontWeight: selected
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
),
);
}),
),
),
),
// Floating bubble
AnimatedBuilder(
animation: _anim,
builder: (_, __) {
final from = _prev + 0.5;
final to = widget.selectedIndex + 0.5;
final cx = (from + (to - from) * _anim.value) * itemW;
return Positioned(
left: cx - bubbleR,
top: 0,
child: Container(
width: bubbleR * 2,
height: bubbleR * 2,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Color(0x33000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Center(
child: Icon(
_items[widget.selectedIndex].icon,
color: AppColors.navBg,
size: 24,
),
),
),
);
},
),
],
),
);
},
);
}
}
class _NavItem {
final IconData icon;
final String label;
const _NavItem({required this.icon, required this.label});
}
// ─────────────────────────────────────────────
// HomePage
// ─────────────────────────────────────────────
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _index = 1;
String _userName = '';
late final List<Widget> _pages = const [
CarListPage(),
MapScreen(),
PaymentScreen(),
NotificationScreen(),
];
@override
void initState() {
super.initState();
_loadUserName();
WidgetsBinding.instance.addPostFrameCallback((_) => _syncLocationOnce());
}
Future<void> _loadUserName() async {
final name = await AuthPrefs.getUserName();
if (mounted) setState(() => _userName = name ?? 'Хэрэглэгч');
}
Future<void> _syncLocationOnce() async {
try {
final t = await AuthPrefs.getToken();
if (t == null || t.isEmpty) return;
var p = await Geolocator.checkPermission();
if (p == LocationPermission.denied) {
p = await Geolocator.requestPermission();
if (p == LocationPermission.denied ||
p == LocationPermission.deniedForever)
return;
}
final pos = await Geolocator.getCurrentPosition();
await BackendApi.postLocation(
lat: pos.latitude,
lng: pos.longitude,
accuracyM: pos.accuracy,
);
} catch (_) {}
}
Future<void> _showLogoutSheet() async {
final confirmed = await showModalBottomSheet<bool>(
context: context,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Гарах уу?',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
const Text(
'Та системээс гарахдаа итгэлтэй байна уу?',
style: TextStyle(color: Colors.black54),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 13),
shape: RoundedRectangleBorder(borderRadius: AppRadii.r12),
),
child: const Text('Цуцлах'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
padding: const EdgeInsets.symmetric(vertical: 13),
shape: RoundedRectangleBorder(borderRadius: AppRadii.r12),
),
child: const Text(
'Гарах',
style: TextStyle(color: Colors.white),
),
),
),
],
),
],
),
),
);
if (confirmed == true && mounted) {
await AuthPrefs.clearAll();
if (!mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
}
Widget _buildProfileHeader() {
return Container(
width: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF2A2A72), Color(0xFF3D3DB8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
padding: const EdgeInsets.fromLTRB(20, 12, 16, 22),
child: Row(
children: [
GestureDetector(
child: CircleAvatar(
radius: 28,
backgroundColor: Colors.white24,
child: CircleAvatar(
radius: 25,
backgroundColor: const Color(0xFFc5c8f0),
child: const Icon(Icons.person, size: 28, color: Colors.white),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Тавтай морил',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
const SizedBox(height: 2),
Text(
_userName.isEmpty ? 'Хэрэглэгч' : _userName,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
],
),
),
TextButton.icon(
onPressed: _showLogoutSheet,
icon: const Icon(Icons.logout, size: 16, color: Colors.white),
label: const Text(
'Гарах',
style: TextStyle(color: Colors.white, fontSize: 13),
),
style: TextButton.styleFrom(
backgroundColor: Colors.white24,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
shape: RoundedRectangleBorder(
borderRadius: AppRadii.r12,
side: const BorderSide(color: Colors.white38),
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
if (_index == 0) ...[
SafeArea(bottom: false, child: _buildProfileHeader()),
],
Expanded(child: _pages[_index]),
],
),
// ── Magic Navigation Bar ──────────────────
bottomNavigationBar: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: MagicNavBar(
selectedIndex: _index,
onTap: (i) => setState(() => _index = i),
),
),
);
}
}
// ─────────────────────────────────────────────
// CarListPage
// ─────────────────────────────────────────────
class CarListPage extends StatefulWidget {
const CarListPage({super.key});
@override
State<CarListPage> createState() => _CarListPageState();
}
class _CarListPageState extends State<CarListPage> {
final List<CarModel> _cars = [];
Future<void> _addCar() async {
final CarFormResult? res = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CarFormPage(title: "Add New Car"),
),
);
if (res == null) return;
setState(() => _cars.add(res.toModel()));
}
Future<void> _editCar(int index) async {
final car = _cars[index];
final CarFormResult? res = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CarFormPage(
title: "Edit Car",
initialName: car.name,
initialPlate: car.plate,
initialImage: car.image,
),
),
);
if (res == null) return;
setState(() => _cars[index] = res.toModel());
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text("Editing Completed!")));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.bg,
body: _cars.isEmpty
? const _EmptyCars()
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _cars.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) {
final car = _cars[i];
return _CarCard(
car: car,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => CarDetailsPage(car: car)),
),
onEdit: () => _editCar(i),
);
},
),
floatingActionButton: FloatingActionButton.extended(
backgroundColor: const Color(0xFF3D8AF7),
onPressed: _addCar,
icon: const Icon(Icons.add, color: Colors.white),
label: const Text("нэмэх", style: TextStyle(color: Colors.white)),
),
);
}
}
// ─────────────────────────────────────────────
// Supporting widgets
// ─────────────────────────────────────────────
class _EmptyCars extends StatelessWidget {
const _EmptyCars();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color.fromARGB(255, 104, 93, 230),
borderRadius: AppRadii.r16,
boxShadow: AppShadows.soft,
),
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.directions_car,
size: 56,
color: Color.fromARGB(255, 7, 8, 8),
),
SizedBox(height: 12),
Text(
"Одоогоор машин нэмээгүй байна",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
],
),
),
),
);
}
}
class _CarCard extends StatelessWidget {
final CarModel car;
final VoidCallback onTap;
final VoidCallback onEdit;
const _CarCard({
required this.car,
required this.onTap,
required this.onEdit,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: AppRadii.r16,
child: InkWell(
borderRadius: AppRadii.r16,
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: AppRadii.r16,
boxShadow: AppShadows.soft,
),
child: Row(
children: [
CarAvatar(image: car.image, radius: 26),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
car.name,
style: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 16,
),
),
const SizedBox(height: 2),
Text(
car.plate,
style: const TextStyle(color: AppColors.muted),
),
],
),
),
IconButton(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
),
],
),
),
),
);
}
}
class CarFormPage extends StatefulWidget {
final String title;
final String? initialName;
final String? initialPlate;
final CarImage? initialImage;
const CarFormPage({
super.key,
required this.title,
this.initialName,
this.initialPlate,
this.initialImage,
});
@override
State<CarFormPage> createState() => _CarFormPageState();
}
class _CarFormPageState extends State<CarFormPage> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _name = TextEditingController(
text: widget.initialName ?? "",
);
late final TextEditingController _plate = TextEditingController(
text: widget.initialPlate ?? "",
);
CarImage _image = const CarImage();
@override
void initState() {
super.initState();
_image = widget.initialImage ?? const CarImage();
}
@override
void dispose() {
_name.dispose();
_plate.dispose();
super.dispose();
}
Future<void> _pick(ImageSource source) async {
try {
final picker = ImagePicker();
final XFile? img = await picker.pickImage(
source: source,
imageQuality: 80,
);
if (img == null) return;
if (kIsWeb) {
final bytes = await img.readAsBytes();
setState(() => _image = CarImage(webBytes: bytes));
} else {
setState(() => _image = CarImage(mobileFile: File(img.path)));
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Зураг авахад алдаа: $e")));
}
}
void _showPicker() {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text("Gallery-с сонгох"),
onTap: () {
Navigator.pop(context);
_pick(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text("Камераар авах"),
onTap: () {
Navigator.pop(context);
_pick(ImageSource.camera);
},
),
const SizedBox(height: 10),
],
),
),
);
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
Navigator.pop(
context,
CarFormResult(
name: _name.text.trim(),
plate: _plate.text.trim(),
image: _image,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color.fromARGB(255, 251, 251, 254),
appBar: AppBar(
title: Text(widget.title),
backgroundColor: const Color.fromARGB(255, 36, 96, 155),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadii.r16,
boxShadow: AppShadows.soft,
),
child: Form(
key: _formKey,
child: Column(
children: [
GestureDetector(
onTap: _showPicker,
child: Column(
children: [
CarAvatar(image: _image, radius: 54),
const SizedBox(height: 10),
Text(
_image.isEmpty ? "Зураг нэмэх" : "Зураг солих",
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
const SizedBox(height: 18),
_Input(
controller: _name,
label: "Машины нэр",
icon: Icons.directions_car,
validator: (v) =>
(v == null || v.trim().isEmpty) ? "Enter car name" : null,
),
const SizedBox(height: 12),
_Input(
controller: _plate,
label: "Улсын дугаар",
icon: Icons.confirmation_number_outlined,
validator: (v) => (v == null || v.trim().isEmpty)
? "Enter plate number"
: null,
),
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: AppRadii.r12),
),
onPressed: _submit,
child: const Text(
"Save",
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
),
],
),
),
),
),
);
}
}
class _Input extends StatelessWidget {
final TextEditingController controller;
final String label;
final IconData icon;
final String? Function(String?)? validator;
const _Input({
required this.controller,
required this.label,
required this.icon,
this.validator,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
filled: true,
fillColor: const Color(0xFFF9FAFB),
border: OutlineInputBorder(borderRadius: AppRadii.r12),
enabledBorder: OutlineInputBorder(
borderRadius: AppRadii.r12,
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
),
focusedBorder: OutlineInputBorder(
borderRadius: AppRadii.r12,
borderSide: const BorderSide(color: AppColors.primary, width: 1.4),
),
),
);
}
}
class CarAvatar extends StatelessWidget {
final CarImage image;
final double radius;
const CarAvatar({super.key, required this.image, this.radius = 28});
@override
Widget build(BuildContext context) {
final provider = image.toImageProvider();
return CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFFE5E7EB),
backgroundImage: provider,
child: provider == null
? Icon(Icons.directions_car, size: radius, color: Colors.black54)
: null,
);
}
}
class CarDetailsPage extends StatelessWidget {
final CarModel car;
const CarDetailsPage({super.key, required this.car});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.bg,
appBar: AppBar(
backgroundColor: AppColors.primary,
title: Text(car.name),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadii.r16,
boxShadow: AppShadows.soft,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(child: CarAvatar(image: car.image, radius: 70)),
const SizedBox(height: 16),
const Text(
"Машины нэр",
style: TextStyle(color: AppColors.muted),
),
Text(
car.name,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
const Text("Дугаар", style: TextStyle(color: AppColors.muted)),
Text(car.plate, style: const TextStyle(fontSize: 20)),
const Spacer(),
SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primary),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: AppRadii.r12),
),
onPressed: () => Navigator.pop(context),
child: const Text("Буцах"),
),
),
],
),
),
),
);
}
}
// ─────────────────────────────────────────────
// MapScreen
// ─────────────────────────────────────────────
class MapScreen extends StatefulWidget {
const MapScreen({super.key});
@override
State<MapScreen> createState() => _MapScreenState();
}
class _MapScreenState extends State<MapScreen> {
static const ll.LatLng ub = ll.LatLng(47.918873, 106.917701);
bool showCard = false;
String locationName = "";
String cardSubtitle = "";
String? _selectedSpotId;
final TextEditingController searchController = TextEditingController();
List<Marker> _markers = [];
List<Map<String, dynamic>> _spotRows = [];
Timer? _poll;
bool _loadingSpots = true;
String? _spotsError;
@override
void initState() {
super.initState();
_refreshSpots();
_poll = Timer.periodic(const Duration(seconds: 4), (_) => _refreshSpots());
}
Future<void> _refreshSpots() async {
try {
final rows = await BackendApi.fetchSpots();
if (!mounted) return;
final next = <Marker>[];
for (final data in rows) {
final id = data['id']?.toString() ?? '';
if (id.isEmpty) continue;
final pos = _spotLatLng(data);
if (pos == null) continue;
final name = (data['name'] as String?)?.trim().isNotEmpty == true
? data['name'] as String
: 'Зогсоол';
final price =
data['price_per_hour'] ?? data['pricePerHour'] ?? data['price'];
final status = _spotStatus(data);
final total = data['total'];
final avail = data['available'] ?? data['avail'] ?? data['free'];
final cap = (avail is num && total is num)
? 'Сул: ${avail.toInt()}/${total.toInt()}'
: (total is num ? 'Нийт: ${total.toInt()}' : '');
final snippetBase = price != null ? '₮$price/цаг · $status' : status;
final snippet = cap.isNotEmpty ? '$snippetBase · $cap' : snippetBase;
next.add(
Marker(
width: 40,
height: 40,
point: pos,
child: GestureDetector(
onTap: () {
final addr = (data['address'] as String?)?.trim() ?? '';
setState(() {
showCard = true;
_selectedSpotId = id;
locationName = name;
cardSubtitle = addr.isNotEmpty
? '$addr · $snippet'
: snippet.toString();
});
},
child: Icon(
Icons.location_pin,
size: 40,
color: status == 'free' ? Colors.green : Colors.red,
),
),
),
);
}
setState(() {
_spotRows = rows;
_markers = next;
_loadingSpots = false;
_spotsError = null;
});
} catch (e) {
debugPrint('fetch spots: $e');
if (!mounted) return;
setState(() {
_loadingSpots = false;
_spotsError = e.toString();
});
}
}
ll.LatLng? _spotLatLng(Map<String, dynamic> data) {
final lat = data['lat'];
final lng = data['lng'];
if (lat is num && lng is num)
return ll.LatLng(lat.toDouble(), lng.toDouble());
final loc = data['location'];
if (loc is Map) {
final la = loc['lat'];
final ln = loc['lng'];
if (la is num && ln is num)
return ll.LatLng(la.toDouble(), ln.toDouble());
}
return null;
}
String _spotStatus(Map<String, dynamic> data) {
final s = data['status'];
if (s is String) return s;
if (data['is_available'] == true || data['isAvailable'] == true)
return 'free';
if (data['is_available'] == false || data['isAvailable'] == false)
return 'occupied';
return 'free';
}
@override
void dispose() {
_poll?.cancel();
searchController.dispose();
super.dispose();
}
Widget _windowsSpotList() {
if (_spotRows.isEmpty) {
return const Center(
child: Text(
'Зогсоол алга эсвэл API холбогдохгүй байна.n'
'Docker: mobile_bas2 хавтас дээр `docker compose up`',
textAlign: TextAlign.center,
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: _spotRows.length,
itemBuilder: (context, i) {
final data = _spotRows[i];
final pos = _spotLatLng(data);
final name = (data['name'] as String?)?.trim().isNotEmpty == true
? data['name'] as String
: 'Зогсоол';
final price =
data['price_per_hour'] ?? data['pricePerHour'] ?? data['price'];
final sub = pos != null
? '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}'
: '—';
return Card(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
title: Text(name),
subtitle: Text('${price != null ? '₮$price/цаг · ' : ''}$sub'),
isThreeLine: true,
),
);
},
);
}
@override
Widget build(BuildContext context) {
if (!kIsWeb && Platform.isWindows) {
return Scaffold(
appBar: AppBar(title: const Text('Parking Map')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Material(
color: const Color(0xFFFFF8E1),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Windows дээр Google Map дэмжигдэхгүй. '
'Газрын зураг: Android эсвэл `flutter run -d chrome`.',
style: TextStyle(fontSize: 13, color: Colors.grey.shade900),
),
),
),
Expanded(child: _windowsSpotList()),
],
),
);
}
return Scaffold(
appBar: AppBar(title: const Text("Parking Map")),
body: Stack(
children: [
FlutterMap(
options: const MapOptions(initialCenter: ub, initialZoom: 14),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.parking_managment_system',
),
MarkerLayer(markers: _markers),
],
),
if (_loadingSpots)
Positioned(
top: 10,
left: 15,
right: 15,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 5),
],
),
child: const Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10),
Expanded(child: Text('Зогсоолууд ачаалж байна…')),
],
),
),
),
if (!_loadingSpots && (_spotsError != null || _markers.isEmpty))
Positioned(
top: 10,
left: 15,
right: 15,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF8E1),
borderRadius: BorderRadius.circular(10),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 5),
],
),
child: Text(
_spotsError != null
? 'Зогсоол татахад алдаа: $_spotsErrornAPI: ${apiBaseUrl()}'
: 'Одоогоор зогсоол алга.',
style: const TextStyle(fontSize: 13),
),
),
),
Positioned(
top: 10,
left: 15,
right: 15,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 5),
],
),
child: TextField(
controller: searchController,
decoration: const InputDecoration(
hintText: "Search location",
border: InputBorder.none,
icon: Icon(Icons.search),
),
),
),
),
if (showCard)
Positioned(
bottom: 20,
left: 20,
right: 20,
child: Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: const Color.fromARGB(255, 22, 31, 154),
borderRadius: BorderRadius.circular(15),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 10),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(
Icons.location_pin,
color: Color.fromARGB(255, 167, 154, 153),
size: 35,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locationName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
cardSubtitle.isNotEmpty
? cardSubtitle
: "Ulaanbaatar - Mongolia",
style: const TextStyle(color: Colors.white70),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => setState(() => showCard = false),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _selectedSpotId == null
? null
: () => _bookSelectedSpot(),
child: const Text('ЗАХИАЛАХ'),
),
),
],
),
),
),
],
),
);
}
Future<void> _bookSelectedSpot() async {
final spotId = _selectedSpotId;
if (spotId == null) return;
try {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Захиалга эхлүүлэх'),
content: Text(
'Та энэ зогсоолд зогсохыг эхлүүлэх үү?nnЗогсоол: $locationName',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Цуцлах'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Эхлүүлэх'),
),
],
),
);
if (ok != true) return;
await BackendApi.startBooking(spotId: spotId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Захиалга эхэллээ. Төлөх үед хугацаагаар бодож wallet-ээс хасна.',
),
),
);
setState(() => showCard = false);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Алдаа: $e')));
}
}
}
// ─────────────────────────────────────────────
// PaymentScreen
// ─────────────────────────────────────────────
class PaymentScreen extends StatefulWidget {
const PaymentScreen({super.key});
@override
State<PaymentScreen> createState() => _PaymentScreenState();
}
class _PaymentScreenState extends State<PaymentScreen> {
String selectedMethod = 'qpay';
final TextEditingController _amountController = TextEditingController(
text: '5000',
);
final TextEditingController _hoursController = TextEditingController(
text: '1',
);
final TextEditingController _topupAmountController = TextEditingController(
text: '5000',
);
int? _walletBalance;
bool _walletLoading = false;
bool _submitting = false;
Map<String, dynamic>? _activeBooking;
int get _amountTugrik {
final v = int.tryParse(
_amountController.text.trim().replaceAll(RegExp(r's'), ''),
);
return v ?? 0;
}
int get _hours {
final v = int.tryParse(
_hoursController.text.trim().replaceAll(RegExp(r's'), ''),
);
return v ?? 0;
}
@override
void initState() {
super.initState();
_loadWallet();
_loadActiveBooking();
}
Future<void> _loadWallet() async {
setState(() => _walletLoading = true);
try {
final r = await BackendApi.walletMe();
if (!mounted) return;
setState(() => _walletBalance = (r['balance'] as int?) ?? 0);
} catch (_) {
if (!mounted) return;
setState(() => _walletBalance = null);
} finally {
if (mounted) setState(() => _walletLoading = false);
}
}
Future<void> _loadActiveBooking() async {
try {
final b = await BackendApi.activeBooking();
if (!mounted) return;
setState(() => _activeBooking = b);
} catch (_) {
if (!mounted) return;
setState(() => _activeBooking = null);
}
}
Future<void> _payActiveBooking() async {
final b = _activeBooking;
if (b == null) return;
final id = b['id']?.toString() ?? '';
if (id.isEmpty) return;
setState(() => _submitting = true);
try {
final r = await BackendApi.payBooking(
bookingId: id,
method: selectedMethod,
);
if (!mounted) return;
setState(() {
_walletBalance = (r['balance'] as int?) ?? _walletBalance;
_activeBooking = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Төлбөр амжилттай. ₮${r['amount']} · ${r['hours']} цагnҮлдэгдэл: ₮${r['balance']}',
),
),
);
} catch (e) {
if (!mounted) return;
final msg = e.toString().contains('insufficient_balance')
? 'Үлдэгдэл хүрэхгүй байна.'
: 'Алдаа: $e';
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
} finally {
if (mounted) setState(() => _submitting = false);
await _loadWallet();
await _loadActiveBooking();
}
}
Future<void> _topupWallet() async {
final amount = _amountTugrik;
if (amount < 1) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Цэнэглэх дүн (₮) оруулна уу.')),
);
return;
}
setState(() => _submitting = true);
try {
final newBalance = await BackendApi.walletTopup(
amount: amount,
method: selectedMethod,
note: 'mobile topup',
);
if (!mounted) return;
setState(() => _walletBalance = newBalance);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Хэтэвч цэнэглэгдлээ. Үлдэгдэл: ₮$newBalance')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Алдаа: $e')));
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
void dispose() {
_amountController.dispose();
_hoursController.dispose();
_topupAmountController.dispose();
super.dispose();
}
Future<void> _showTopupDialog() async {
String method = selectedMethod;
String bank = 'khaan';
_topupAmountController.text = (_amountTugrik > 0 ? _amountTugrik : 5000)
.toString();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Хэтэвч цэнэглэх'),
content: StatefulBuilder(
builder: (ctx, setLocal) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _topupAmountController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Цэнэглэх дүн (₮)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 14),
RadioListTile<String>(
value: 'qpay',
groupValue: method,
onChanged: (v) => setLocal(() => method = v ?? 'qpay'),
title: const Text('QPay (QR төлбөр)'),
subtitle: const Text('Банкны апп-аар QR уншуулж төлнө'),
),
RadioListTile<String>(
value: 'bank',
groupValue: method,
onChanged: (v) => setLocal(() => method = v ?? 'bank'),
title: const Text('Дансанд шилжүүлэх'),
subtitle: const Text('Хаан, Голомт, Хас гэх мэт'),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: bank,
decoration: const InputDecoration(
labelText: 'Банк сонгох',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(
value: 'khaan',
child: Text('Хаан банк'),
),
DropdownMenuItem(value: 'xac', child: Text('Хас банк')),
DropdownMenuItem(
value: 'golomt',
child: Text('Голомт банк'),
),
DropdownMenuItem(
value: 'tdb',
child: Text('Худалдаа хөгжлийн банк'),
),
DropdownMenuItem(
value: 'state',
child: Text('Төрийн банк'),
),
],
onChanged: (v) => setLocal(() => bank = v ?? 'khaan'),
),
],
);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Цуцлах'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Цэнэглэх'),
),
],
);
},
);
if (ok != true) return;
final amount =
int.tryParse(
_topupAmountController.text.trim().replaceAll(RegExp(r's'), ''),
) ??
0;
if (amount < 1) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Цэнэглэх дүн (₮) оруулна уу.')),
);
return;
}
setState(() {
selectedMethod = '$method:$bank';
_amountController.text = amount.toString();
});
await _openBankForTopup(bank: bank, channel: method);
if (!mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Төлбөр баталгаажуулах'),
content: const Text(
'Сонгосон банкны апп дээр төлбөрөө хийсэн бол "Баталгаажуулах"-ыг дарна уу.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Цуцлах'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Баталгаажуулах'),
),
],
),
);
if (confirmed == true) await _topupWallet();
}
Future<void> _openBankForTopup({
required String bank,
required String channel,
}) async {
Uri url;
switch (bank) {
case 'khaan':
url = Uri.parse('https://khanbank.com/');
break;
case 'xac':
url = Uri.parse('https://www.xacbank.mn/');
break;
case 'golomt':
url = Uri.parse('https://golomtbank.com/');
break;
case 'tdb':
url = Uri.parse('https://www.tdbm.mn/');
break;
case 'state':
url = Uri.parse('https://www.statebank.mn/');
break;
default:
url = Uri.parse('https://www.qpay.mn/');
break;
}
final ok = await launchUrl(url, mode: LaunchMode.externalApplication);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Банкны апп нээгдсэнгүй. $channel:$bank')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.bg,
appBar: AppBar(
backgroundColor: AppColors.primary,
title: const Text("Төлбөрийн дэлгэц"),
centerTitle: true,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_Card(
child: Row(
children: [
const Icon(Icons.account_balance_wallet, color: Colors.white),
const SizedBox(width: 10),
Expanded(
child: Text(
_walletLoading
? 'Хэтэвч ачаалж байна…'
: (_walletBalance == null
? 'Хэтэвч: нэвтэрсний дараа харагдана'
: 'Хэтэвчний үлдэгдэл: ₮$_walletBalance'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
TextButton(
onPressed: (_walletLoading || _submitting)
? null
: _showTopupDialog,
child: const Text(
'Цэнэглэх',
style: TextStyle(color: Colors.white),
),
),
const SizedBox(width: 6),
IconButton(
onPressed: _walletLoading ? null : _loadWallet,
icon: const Icon(Icons.refresh, color: Colors.white),
tooltip: 'Refresh',
),
],
),
),
const SizedBox(height: 14),
if (_activeBooking != null) ...[
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Идэвхтэй захиалга',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
_activeBooking!['spot_name']?.toString() ?? '—',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 4),
Text(
'Эхэлсэн: ${_activeBooking!['started_at']?.toString() ?? '—'}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : _payActiveBooking,
child: const Text('ОДОО ТӨЛӨХ (WALLET)'),
),
),
],
),
),
const SizedBox(height: 14),
],
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Дүн (₮)", style: TextStyle(color: AppColors.muted)),
const SizedBox(height: 8),
TextField(
controller: _amountController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: 'Жишээ: 5000',
prefixText: '₮ ',
border: OutlineInputBorder(borderRadius: AppRadii.r12),
),
),
const SizedBox(height: 8),
TextField(
controller: _hoursController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: 'Цаг (1–24)',
suffixText: 'цаг',
border: OutlineInputBorder(borderRadius: AppRadii.r12),
),
),
const SizedBox(height: 8),
Text(
'Нийт: ₮ ${_amountTugrik > 0 ? _amountTugrik : '—'}'
' • ${_hours > 0 ? _hours : '—'} цаг',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: AppColors.primary,
),
),
],
),
),
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: _submitting ? null : _showTopupDialog,
icon: const Icon(Icons.add),
label: const Text('ХЭТЭВЧ ЦЭНЭГЛЭХ'),
),
],
),
);
}
}
// ─────────────────────────────────────────────
// NotificationScreen
// ─────────────────────────────────────────────
class NotificationScreen extends StatelessWidget {
const NotificationScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.bg,
appBar: AppBar(
backgroundColor: AppColors.primary,
title: const Text("Мэдэгдэл"),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: _Card(
child: const Text(
"Таны зогсоолын захиалсан хугацаа дуусахад 10 минут үлдсэн тул та сунгалтаа хийнэ үү!",
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.primary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}
// ─────────────────────────────────────────────
// Shared card widget
// ─────────────────────────────────────────────
class _Card extends StatelessWidget {
final Widget child;
const _Card({required this.child});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: AppRadii.r16,
boxShadow: AppShadows.soft,
),
child: child,
);
}
}
![]() |
Notes is a web-based application for online taking notes. You can take your notes and share with others people. If you like taking long notes, notes.io is designed for you. To date, over 8,000,000,000+ notes created and continuing...
With notes.io;
- * You can take a note from anywhere and any device with internet connection.
- * You can share the notes in social platforms (YouTube, Facebook, Twitter, instagram etc.).
- * You can quickly share your contents without website, blog and e-mail.
- * You don't need to create any Account to share a note. As you wish you can use quick, easy and best shortened notes with sms, websites, e-mail, or messaging services (WhatsApp, iMessage, Telegram, Signal).
- * Notes.io has fabulous infrastructure design for a short link and allows you to share the note as an easy and understandable link.
Fast: Notes.io is built for speed and performance. You can take a notes quickly and browse your archive.
Easy: Notes.io doesn’t require installation. Just write and share note!
Short: Notes.io’s url just 8 character. You’ll get shorten link of your note when you want to share. (Ex: notes.io/q )
Free: Notes.io works for 14 years and has been free since the day it was started.
You immediately create your first note and start sharing with the ones you wish. If you want to contact us, you can use the following communication channels;
Email: [email protected]
Twitter: http://twitter.com/notesio
Instagram: http://instagram.com/notes.io
Facebook: http://facebook.com/notesio
Regards;
Notes.io Team
