winche_database 2.0.0 copy "winche_database: ^2.0.0" to clipboard
winche_database: ^2.0.0 copied to clipboard

Type-safe Dart client for Winche Database — an offline-first, real-time document store over a single WebSocket connection.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter/material.dart' as material show ConnectionState;
import 'package:winche_database/winche_database.dart';

// Hardcoded connection / identity — the sample server hard-codes uid = "user-123"
// and grants that uid full access to userData/user-123/{document=**}.
const kUri = 'ws://localhost:5183/documents/ws';
const kUid = 'user-123';
const kCollection = 'userData/$kUid/records';

void main() => runApp(const WincheDemoApp());

class WincheDemoApp extends StatelessWidget {
  const WincheDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Winche Records',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: const HomePage(),
    );
  }
}

final class Record {
  final String id;
  final String title;
  final String note;
  final bool done;

  const Record({
    required this.id,
    required this.title,
    this.note = '',
    this.done = false,
  });

  static const converter = RecordConverter();

  Record toggleDone() {
    return Record(id: id, title: title, note: note, done: !done);
  }
}

final class RecordConverter extends Converter<Record> {
  const RecordConverter() : super(_fromMap, _toMap);

  static Record _fromMap(Map<String, Object?> data) {
    return Record(
      id: data['id'] as String,
      title: data['title'] as String? ?? '',
      note: data['note'] as String? ?? '',
      done: data['done'] as bool? ?? false,
    );
  }

  static Map<String, Object?> _toMap(Record record) {
    return {
      'id': record.id,
      'title': record.title,
      'note': record.note,
      'done': record.done,
    };
  }
}

/// Which subset of records to display.
enum RecordFilter { all, active, done }

class HomePage extends StatefulWidget {
  const HomePage({super.key, this.autoConnect = true});

  /// Disabled in widget tests so no real socket is opened.
  final bool autoConnect;

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  late final _db = WincheDatabase(
    WincheDatabaseConfig(
      uri: Uri.parse(kUri),
      autoReconnect: widget.autoConnect,
      inMemory: true,
    ),
  );

  StreamSubscription<SyncEvent>? _syncSub;
  StreamSubscription<ConnectionState>? _connSub;

  List<PendingWrite> _pending = [];
  ConnectionState _connState = ConnectionState.connecting;
  bool _connecting = true;
  int _tab = 0;

  RecordFilter _filter = RecordFilter.all;

  CollectionReference<Record> get _recordsRef {
    return _db.collection(kCollection).withConverter(Record.converter);
  }

  /// Returns the query for the current filter.
  QueryReference<Record> get _filteredQuery {
    switch (_filter) {
      case RecordFilter.all:
        return _recordsRef.orderBy('title');
      case RecordFilter.active:
        return _recordsRef.where('done', isEqualTo: false).orderBy('title');
      case RecordFilter.done:
        return _recordsRef.where('done', isEqualTo: true).orderBy('title');
    }
  }

  @override
  void initState() {
    super.initState();
    if (widget.autoConnect) {
      _connect();
    } else {
      _connecting = false;
    }
  }

  @override
  void dispose() {
    _syncSub?.cancel();
    _connSub?.cancel();
    _db.close();
    super.dispose();
  }

  Future<void> _connect() async {
    _syncSub = _db.syncEvents.listen((event) {
      _refreshPending();
      if (event is WriteFailed) {
        _snack('Write failed: ${event.error.status} — ${event.error.message}');
      } else if (event is WriteConflict) {
        _snack(
          'Write conflict: ${event.error.message} — discarding local write',
        );
        event.discard();
      }
    });

    _connState = _db.connectionState;
    _connSub = _db.connectionStates.listen((s) {
      if (mounted) setState(() => _connState = s);
    });
    if (mounted) setState(() => _connecting = false);
    await _refreshPending();
  }

  Future<void> _refreshPending() async {
    final p = await _db.queue.all();
    if (mounted) setState(() => _pending = p);
  }

  Future<void> _runOp(String label, Future<void> Function() fn) async {
    try {
      await fn();
    } on WincheException catch (e) {
      _snack('$label failed: ${e.status}');
    } catch (e) {
      _snack('$label failed: $e');
    }
    await _refreshPending();
  }

  Future<void> _addRecord(Record r) {
    return _runOp('Add', () async {
      await _recordsRef.doc(r.id).set(r);
    });
  }

  Future<void> _deleteRecord(String id) {
    return _runOp('Delete', () async {
      await _recordsRef.doc(id).delete();
    });
  }

  /// Toggle done using field transforms so we get a server timestamp and edit
  /// count alongside the boolean flip, without overwriting the whole document.
  Future<void> _toggleDone(Record r) {
    return _runOp('Toggle', () async {
      await _recordsRef.doc(r.id).update({
        'done': !r.done,
        'updatedAt': FieldValue.serverTimestamp(),
        'edits': FieldValue.increment(1),
      });
    });
  }

  Future<void> _updateRecord(Record r) {
    return _runOp('Update', () async {
      await _recordsRef.doc(r.id).set(r, merge: true);
    });
  }

  /// Asks the server for the count of documents matching the current filter
  /// and shows the result in a SnackBar.
  Future<void> _showCount() async {
    try {
      final n = await _filteredQuery.count();
      final filterLabel = switch (_filter) {
        RecordFilter.all => 'total',
        RecordFilter.active => 'active',
        RecordFilter.done => 'done',
      };
      _snack('Server count ($filterLabel): $n');
    } on WincheException catch (e) {
      _snack('Count failed: ${e.status} — ${e.message}');
    } catch (e) {
      _snack('Count failed: $e');
    }
  }

  void _snack(String msg) {
    if (!mounted) return;
    ScaffoldMessenger.of(context)
      ..clearSnackBars()
      ..showSnackBar(
        SnackBar(content: Text(msg), duration: const Duration(seconds: 3)),
      );
  }

  // --- editor bottom sheet ---------------------------------------------------

  Future<void> _openEditor({Record? existing}) async {
    final titleC = TextEditingController(text: existing?.title ?? '');
    final noteC = TextEditingController(text: existing?.note ?? '');
    var done = existing?.done ?? false;

    final saved = await showModalBottomSheet<bool>(
      context: context,
      isScrollControlled: true,
      showDragHandle: true,
      builder: (ctx) {
        return Padding(
          padding: EdgeInsets.only(
            left: 20,
            right: 20,
            top: 8,
            bottom: MediaQuery.of(ctx).viewInsets.bottom + 20,
          ),
          child: StatefulBuilder(
            builder: (ctx, setSheet) {
              return Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    existing == null ? 'New record' : 'Edit record',
                    style: Theme.of(ctx).textTheme.titleLarge,
                  ),
                  const SizedBox(height: 16),
                  TextField(
                    controller: titleC,
                    autofocus: true,
                    textCapitalization: TextCapitalization.sentences,
                    decoration: const InputDecoration(
                      labelText: 'Title',
                      border: OutlineInputBorder(),
                    ),
                    onChanged: (value) {
                      setSheet(() {});
                    },
                  ),
                  const SizedBox(height: 12),
                  TextField(
                    controller: noteC,
                    minLines: 2,
                    maxLines: 4,
                    decoration: const InputDecoration(
                      labelText: 'Note',
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 4),
                  SwitchListTile(
                    contentPadding: EdgeInsets.zero,
                    title: const Text('Done'),
                    value: done,
                    onChanged: (v) => setSheet(() => done = v),
                  ),
                  const SizedBox(height: 8),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.end,
                    children: [
                      TextButton(
                        onPressed: () => Navigator.pop(ctx, false),
                        child: const Text('Cancel'),
                      ),
                      const SizedBox(width: 8),
                      FilledButton(
                        onPressed: titleC.text.trim().isEmpty
                            ? null
                            : () => Navigator.pop(ctx, true),
                        child: const Text('Save'),
                      ),
                    ],
                  ),
                ],
              );
            },
          ),
        );
      },
    );

    if (saved == true && titleC.text.trim().isNotEmpty) {
      if (existing == null) {
        final newRecord = Record(
          id: DateTime.now().millisecondsSinceEpoch.toString(),
          title: titleC.text.trim(),
          note: noteC.text.trim(),
          done: done,
        );
        await _addRecord(newRecord);
      } else {
        final updated = Record(
          id: existing.id,
          title: titleC.text.trim(),
          note: noteC.text.trim(),
          done: done,
        );
        await _updateRecord(updated);
      }
    }
    titleC.dispose();
    noteC.dispose();
  }

  // --- UI --------------------------------------------------------------------

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Winche Records'),
        actions: [
          IconButton(
            tooltip: 'Server count',
            icon: const Icon(Icons.tag),
            onPressed: _showCount,
          ),
          Center(child: _connStatusChip()),
          const SizedBox(width: 12),
        ],
      ),
      body: _connecting
          ? const Center(child: CircularProgressIndicator())
          : IndexedStack(index: _tab, children: [_recordsTab(), _pendingTab()]),
      floatingActionButton: _tab == 0
          ? FloatingActionButton.extended(
              onPressed: () => _openEditor(),
              icon: const Icon(Icons.add),
              label: const Text('Add'),
            )
          : null,
      bottomNavigationBar: NavigationBar(
        selectedIndex: _tab,
        onDestinationSelected: (i) {
          setState(() => _tab = i);
          if (i == 1) _refreshPending();
        },
        destinations: [
          const NavigationDestination(
            icon: Icon(Icons.list_alt),
            label: 'Records',
          ),
          NavigationDestination(
            icon: Badge(
              isLabelVisible: _pending.isNotEmpty,
              label: Text('${_pending.length}'),
              child: const Icon(Icons.sync_problem),
            ),
            label: 'Pending',
          ),
        ],
      ),
    );
  }

  Widget _recordsTab() {
    return Column(
      children: [
        _filterRow(),
        Expanded(
          child: StreamBuilder(
            // Key forces a new StreamBuilder (and fresh stream) when the filter changes.
            key: ValueKey(_filter),
            stream: _filteredQuery.snapshots(),
            builder: (context, snapshot) {
              if (snapshot.connectionState ==
                  material.ConnectionState.waiting) {
                return const Center(child: CircularProgressIndicator());
              }
              if (snapshot.hasError) {
                return _empty(
                  Icons.error_outline,
                  'Error loading records',
                  snapshot.error.toString(),
                );
              }
              final records = snapshot.data!.docs;
              if (records.isEmpty) {
                return _empty(
                  Icons.inbox,
                  'No records yet',
                  'Tap "Add" to create your first record.',
                );
              }
              return ListView.separated(
                padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
                itemCount: records.length,
                separatorBuilder: (_, _) => const SizedBox(height: 8),
                itemBuilder: (context, i) => _recordTile(records[i]),
              );
            },
          ),
        ),
      ],
    );
  }

  /// Filter chip row — All / Active / Done.
  Widget _filterRow() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      child: Row(
        children: [
          for (final f in RecordFilter.values) ...[
            FilterChip(
              label: Text(switch (f) {
                RecordFilter.all => 'All',
                RecordFilter.active => 'Active',
                RecordFilter.done => 'Done',
              }),
              selected: _filter == f,
              onSelected: (_) => setState(() => _filter = f),
            ),
            if (f != RecordFilter.done) const SizedBox(width: 8),
          ],
        ],
      ),
    );
  }

  Widget _recordTile(DocumentSnapshot<Record> snapshot) {
    final r = snapshot.data()!;
    return Card(
      margin: EdgeInsets.zero,
      child: ListTile(
        onTap: () => _openEditor(existing: r),
        leading: Checkbox(value: r.done, onChanged: (_) => _toggleDone(r)),
        title: Text(
          r.title,
          style: TextStyle(
            decoration: r.done ? TextDecoration.lineThrough : null,
            color: r.done ? Theme.of(context).disabledColor : null,
          ),
        ),
        subtitle: r.note.isEmpty ? null : Text(r.note),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (snapshot.metadata.fromCache)
              const Icon(Icons.offline_bolt, size: 18, color: Colors.orange),
            IconButton(
              icon: const Icon(Icons.delete_outline),
              onPressed: () => _deleteRecord(r.id),
            ),
          ],
        ),
      ),
    );
  }

  Widget _pendingTab() {
    return Column(
      children: [
        ListTile(
          leading: const Icon(Icons.sync),
          title: Text('${_pending.length} queued'),
          subtitle: const Text(
            'Writes queue locally and sync automatically while connected.',
          ),
        ),
        const Divider(height: 1),
        Expanded(
          child: _pending.isEmpty
              ? _empty(
                  Icons.check_circle_outline,
                  'All synced',
                  'No pending writes. Disconnect the server to see writes queue.',
                )
              : ListView.separated(
                  padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
                  itemCount: _pending.length,
                  separatorBuilder: (_, _) => const SizedBox(height: 8),
                  itemBuilder: (context, i) => _pendingTile(_pending[i]),
                ),
        ),
      ],
    );
  }

  Widget _pendingTile(PendingWrite entry) {
    final op = entry.kind.name.toUpperCase();
    final path = entry.path.split('/').last;
    final seq = entry.seq;
    final (icon, color) = switch (op) {
      'set' => (Icons.save, Colors.blue),
      'update' => (Icons.edit, Colors.teal),
      'delete' => (Icons.delete, Colors.red),
      _ => (Icons.help_outline, Colors.grey),
    };
    return Card(
      margin: EdgeInsets.zero,
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: color.withValues(alpha: 0.15),
          child: Icon(icon, color: color, size: 20),
        ),
        title: Text('$op  •  #$seq'),
        subtitle: Text(path),
        trailing: const Icon(Icons.hourglass_bottom, size: 18),
      ),
    );
  }

  /// A live indicator of the actual socket state (distinct from the logical
  /// online/offline toggle), driven by `db.connectionStates`.
  Widget _connStatusChip() {
    final (label, color, spin) = switch (_connState) {
      ConnectionState.ready => ('live', Colors.green, false),
      ConnectionState.connecting => ('connecting', Colors.orange, true),
      ConnectionState.reconnecting => ('reconnecting', Colors.orange, true),
      ConnectionState.disconnected => ('offline', Colors.red, false),
      ConnectionState.closed => ('closed', Colors.grey, false),
    };
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.15),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (spin)
            SizedBox(
              width: 10,
              height: 10,
              child: CircularProgressIndicator(strokeWidth: 2, color: color),
            )
          else
            Container(
              width: 8,
              height: 8,
              decoration: BoxDecoration(color: color, shape: BoxShape.circle),
            ),
          const SizedBox(width: 6),
          Text(label, style: TextStyle(color: color, fontSize: 12)),
        ],
      ),
    );
  }

  Widget _empty(IconData icon, String title, String subtitle) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(icon, size: 56, color: Theme.of(context).disabledColor),
          const SizedBox(height: 12),
          Text(title, style: Theme.of(context).textTheme.titleMedium),
          const SizedBox(height: 4),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 40),
            child: Text(
              subtitle,
              textAlign: TextAlign.center,
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ),
        ],
      ),
    );
  }
}
0
likes
160
points
0
downloads

Documentation

API reference

Publisher

verified publisherwinchetechnologies.co.uk

Weekly Downloads

Type-safe Dart client for Winche Database — an offline-first, real-time document store over a single WebSocket connection.

Repository (GitHub)
View/report issues

Topics

#database #realtime #websocket #offline #nosql

License

MIT (license)

Dependencies

hive_ce, web_socket_channel

More

Packages that depend on winche_database