feat: initialize new Flutter project with core feature structure and cross-platform configuration
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
import '../../data/services/link_extraction_service.dart';
|
||||
|
||||
|
||||
class SenderState {
|
||||
final Service? connectedDevice;
|
||||
final bool isExtracting;
|
||||
final bool isSending;
|
||||
final List<String> history;
|
||||
|
||||
SenderState({
|
||||
this.connectedDevice,
|
||||
this.isExtracting = false,
|
||||
this.isSending = false,
|
||||
this.history = const [],
|
||||
});
|
||||
|
||||
SenderState copyWith({
|
||||
Service? connectedDevice,
|
||||
bool? isExtracting,
|
||||
bool? isSending,
|
||||
List<String>? history,
|
||||
}) {
|
||||
return SenderState(
|
||||
connectedDevice: connectedDevice ?? this.connectedDevice,
|
||||
isExtracting: isExtracting ?? this.isExtracting,
|
||||
isSending: isSending ?? this.isSending,
|
||||
history: history ?? this.history,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final senderProvider = NotifierProvider<SenderNotifier, SenderState>(() {
|
||||
return SenderNotifier();
|
||||
});
|
||||
|
||||
class SenderNotifier extends Notifier<SenderState> {
|
||||
final _dio = Dio();
|
||||
final _linkExtractionService = LinkExtractionService();
|
||||
|
||||
@override
|
||||
SenderState build() => SenderState();
|
||||
|
||||
void connectToDevice(Service device) {
|
||||
state = state.copyWith(connectedDevice: device);
|
||||
}
|
||||
|
||||
Future<void> sendLink(String url) async {
|
||||
if (state.connectedDevice == null) return;
|
||||
|
||||
state = state.copyWith(isExtracting: true);
|
||||
|
||||
final videoUrl = await _linkExtractionService.extractVideoUrl(url);
|
||||
|
||||
if (videoUrl == null) {
|
||||
state = state.copyWith(isExtracting: false);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isExtracting: false, isSending: true);
|
||||
|
||||
try {
|
||||
final device = state.connectedDevice!;
|
||||
final endpoint = 'http://${device.host}:${device.port}/play';
|
||||
|
||||
await _dio.post(endpoint, data: {
|
||||
'url': videoUrl,
|
||||
'title': 'Shared Video',
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
|
||||
state = state.copyWith(
|
||||
isSending: false,
|
||||
history: [url, ...state.history],
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isSending: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../discovery/presentation/providers/discovery_providers.dart';
|
||||
import '../providers/sender_providers.dart';
|
||||
|
||||
class MobileSenderScreen extends ConsumerStatefulWidget {
|
||||
const MobileSenderScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MobileSenderScreen> createState() => _MobileSenderScreenState();
|
||||
}
|
||||
|
||||
class _MobileSenderScreenState extends ConsumerState<MobileSenderScreen> {
|
||||
final _urlController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicesAsync = ref.watch(discoveredDevicesProvider);
|
||||
final senderState = ref.watch(senderProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('FastWatcher'),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
if (senderState.connectedDevice != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cast_connected, color: AppColors.primary),
|
||||
onPressed: () {
|
||||
// TODO: Show connection info or disconnect
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.backgroundGradient,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (senderState.connectedDevice == null) ...[
|
||||
Text(
|
||||
'Cast to TV',
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Select a TV on your network to begin',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Expanded(
|
||||
child: _buildDiscoveryList(devicesAsync),
|
||||
),
|
||||
] else ...[
|
||||
_buildLinkInput(senderState),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'History',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: _buildHistoryList(senderState),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiscoveryList(AsyncValue devicesAsync) {
|
||||
return devicesAsync.when(
|
||||
data: (devices) {
|
||||
if (devices.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 20),
|
||||
Text('Searching for TVs...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: (devices as List).length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: AppColors.primary,
|
||||
child: Icon(Icons.tv_rounded, color: Colors.white),
|
||||
),
|
||||
title: Text(device.name ?? 'Unknown TV'),
|
||||
subtitle: Text('${device.host}:${device.port}'),
|
||||
trailing: const Icon(Icons.cast),
|
||||
onTap: () {
|
||||
ref.read(senderProvider.notifier).connectToDevice(device);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkInput(SenderState state) {
|
||||
return Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste video link here...',
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: state.isExtracting || state.isSending
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.send_rounded, color: AppColors.primary),
|
||||
onPressed: () {
|
||||
if (_urlController.text.isNotEmpty) {
|
||||
ref.read(senderProvider.notifier).sendLink(_urlController.text);
|
||||
_urlController.clear();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.isExtracting)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
child: Text('Extracting video link...', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryList(SenderState state) {
|
||||
if (state.history.isEmpty) {
|
||||
return const Center(child: Text('No history yet'));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: state.history.length,
|
||||
itemBuilder: (context, index) {
|
||||
final url = state.history[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.history_rounded, size: 20),
|
||||
title: Text(url, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
onTap: () => _urlController.text = url,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user